Constaia
Use-case guides

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

TypeHolderAddressZIP codeDate used for recency
us_utility_billaccount_holderservice_addresspostal_codeissue_date
us_bank_statementaccount_holderaddresspostal_codeperiod_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. With max_age_days: 90, an older document produces an error reason and the verdict is invalid.
  • 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_zip scheme, in checks[] as id_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/sdk
proof-of-address.ts
import { 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

On this page