Proof of address with utility bills
Verify a US utility bill or bank statement as proof of address, with its recency, the account holder and the ZIP code, and compare the address with your form.
Account opening, tenant onboarding, local memberships and resident discounts often ask for a recent utility bill or a bank statement as proof of address. The questions are always the same: is it in this person's name, is it for the address they gave you, and is it recent enough?
Constaia has the us_utility_bill (electricity, gas, water, internet or landline) and us_bank_statement types. With
expect it applies the maximum age and the holder check for you and returns a verdict; comparing the address with
your form happens in your code.
The flow
The customer fills in the form and uploads a bill
Your form collects the name and address; the bill reaches your backend together with them. The API key never leaves your server.
Your backend asks Constaia to verify it
Call POST /v1/analyze with expect: ["us_utility_bill", "us_bank_statement"], checks.max_age_days and
checks.holder with the name from the form. You get the fields, the checks and a verdict.
Your code compares the address and decides
Compare the extracted address with the one in the form. Accept clear matches, send near-misses to a person, and ask for a different document when the bill is too old.
The fields
| Type | Holder | Address | ZIP code | Date used for recency |
|---|---|---|---|---|
us_utility_bill | account_holder | service_address | postal_code | issue_date |
us_bank_statement | account_holder | address | postal_code | period_end |
us_utility_bill also has provider, service_type, account_number, due_date, service_period_start,
service_period_end and amount_due. The full list for each type is in the document catalogue and in
GET /v1/document-types.
On the bill, service_address is the service address, not the mailing address: a bill can be mailed to a PO box or
somewhere else.
What Constaia checks
- Recency (
max_age_days): most businesses accept documents from the last 60 or 90 days. Withmax_age_days: 90, an older document produces anerrorreason and the verdict isinvalid. - Holder (
holder): compares the account holder with the name you send, ignoring case and accents and tolerating word order and small typos. - ZIP code: 5- or 9-digit format validated with the
us_zipscheme, inchecks[]asid_number_format. On a bill, an invalid ZIP format is a failure; on a bank statement it only produces a warning.
Constaia does not contact the utility or the bank, and it does not compare the address with your form: your code does that. More in Checks.
Verify and compare
The address comparison below is deliberately simple: it normalizes common abbreviations and compares number, street, unit and ZIP. In production, consider a USPS normalization or geocoding service in your backend.
npm i @constaia/sdkimport { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
const MAX_AGE_DAYS = 90;
const ABBREVIATIONS: Record<string, string> = {
STREET: "ST", AVENUE: "AVE", ROAD: "RD", BOULEVARD: "BLVD", DRIVE: "DR", LANE: "LN", COURT: "CT",
PLACE: "PL", TERRACE: "TER", HIGHWAY: "HWY", APARTMENT: "APT", SUITE: "STE", NORTH: "N", SOUTH: "S",
EAST: "E", WEST: "W", "#": "APT", UNIT: "APT",
};
function normalizeAddress(s: string): string {
return s
.toUpperCase()
.replace(/[.,]/g, " ")
.replace(/#/g, " # ")
.split(/\s+/)
.filter(Boolean)
.map((w) => ABBREVIATIONS[w] ?? w)
.join(" ");
}
type FormData = { name: string; street: string; zip: string };
export async function checkProofOfAddress(path: string, form: FormData) {
const analysis = await constaia.analyze(await fromPath(path), {
expect: ["us_utility_bill", "us_bank_statement"],
checks: { maxAgeDays: MAX_AGE_DAYS, holder: { fullName: form.name } },
storage: "none",
keepResults: false,
language: "en",
});
if (analysis.status !== "completed" || !analysis.verdict) return { decision: "pending", issues: [analysis.id] };
const reasons = analysis.verdict.reasons;
const has = (code: string) => reasons.some((r) => r.code === code && r.severity === "error");
if (has("max_age_days")) return { decision: "ask_new_document", issues: [`older_than_${MAX_AGE_DAYS}_days`] };
if (has("type_mismatch")) return { decision: "ask_new_document", issues: ["not_a_bill_or_statement"] };
// Different holder, invalid ZIP format, quality…: to review, not to rejection.
const issues = reasons.filter((r) => r.severity !== "info").map((r) => `${r.code}: ${r.message}`);
const v = (name: string) => {
const x = analysis.fields[name]?.value;
return typeof x === "string" ? x.trim() : "";
};
const street = analysis.document?.type === "us_bank_statement" ? v("address") : v("service_address");
if (!normalizeAddress(street).startsWith(normalizeAddress(form.street))) issues.push("street_differs");
if (v("postal_code").slice(0, 5) !== form.zip.slice(0, 5)) issues.push("zip_differs");
return { decision: issues.length ? "manual_review" : "accept", issues, type: analysis.document?.type };
}
checkProofOfAddress(process.argv[2] ?? "./bill.pdf", { name: "Jane Doe", street: "123 Main Street Apt 4B", zip: "94107" })
.then((r) => console.log(r))
.catch((err) => {
if (err instanceof ConstaiaError) console.error(`Constaia error ${err.status} ${err.code} (request ${err.requestId})`);
else console.error(err);
process.exit(1);
});The extracted address usually includes the city and state after the street (for example
2570 24TH STREET, SACRAMENTO, CA), which is why the example checks that it starts with the street from the form.
A name mismatch is often legitimate (the account holder is a spouse or roommate, a middle name is missing). That's why
the example sends holder reasons to human review instead of rejecting them; let
your policy define which alternatives you accept.
Warnings such as edited_suspected or screen_photo_suspected are signals for a person to look again, not proof of
fraud. If you also accept bills from outside the US, add the generic utility_bill type to expect.
Test mode
With a ck_test_ key the simulator has no US-specific scenarios: a US bill is answered with the generic scenario, so
the verdict is review with type_unknown and the code above returns manual_review. To see real results, use a
live key: the free plan includes 150 credits a month (free live credits require a verified email). See
Test mode.
If you only need a few fields
If you don't need a verdict, you can pass your own JSON Schema as extract without expect (for example just
provider, holder, address and date). In that case verdict is null and max_age_days and holder don't apply:
you check recency and name in your code.
Each document costs 1 credit (up to 2 pages). With storage: "none" and keep_results: false Constaia keeps nothing.
Today everything is processed and stored in the EU, including for US customers; a US region is planned,
coming soon, with no date yet. See Data residency & compliance.
Next steps
Certificates of insurance (ACORD 25)
Validate vendor certificates of insurance with the acord_25 type, with expiry and insured checked, and read policies, limits and additional insured to apply your contract rules.
Integrations
Integrate Constaia with any stack: JavaScript, PHP, Python, Go, Java, .NET, Ruby, no-code tools and AI agents. Full code with uploads, errors and webhooks.