Constaia
Use-case guides

Form I-9 supporting documents

Verify the List A, B and C documents a new hire presents for Form I-9, tag them by list and pre-fill Section 2 for your HR reviewer, with Node or Python.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

When you hire someone in the United States, Form I-9 records that you examined documents proving the employee's identity and employment authorization. The employee presents one document from List A, or one from List B plus one from List C, and the employer writes each document's title, issuing authority, number and expiration date in Section 2.

Constaia can take the busywork out of that step: read each document with its catalogue type, tell you which list it belongs to, validate the format of its identifiers and pre-fill a Section 2 draft for the person who reviews it. It does not decide whether anyone can work.

The lists and their Constaia types

ListProvesCatalogue types
AIdentity and employment authorizationus_passport_book, us_passport_card, us_permanent_resident_card, us_foreign_passport_i551, us_ead, us_i94
BIdentity onlyus_driver_license, us_state_id, us_school_id, us_voter_registration_card, us_military_id, us_tribal_document, ca_driver_licence
CEmployment authorization onlyus_ssn_card, us_birth_certificate, us_consular_birth_report, us_tribal_document, us_citizen_id_card, us_resident_citizen_id_card

us_tribal_document appears in both List B and List C. Each type lists its lists in the i9_lists field of GET /v1/document-types, which is public and needs no key. The completed form itself also has a type: us_i9_form.

This is a simplified overview. The official lists, their exceptions (receipts, automatic extensions, restricted Social Security cards, the I-94 together with a foreign passport…) and how to examine documents are in the USCIS Handbook for Employers (M-274). Follow it, not this page.

What Constaia does not do

Constaia does not make employment eligibility determinations, does not connect to E-Verify or any government database, and does not replace the employer's examination of the documents required by USCIS. It tags which list each document belongs to, extracts data and flags issues; your code decides whether the combination of documents is complete, and your HR team completes and signs Section 2. The employer remains responsible for Form I-9. This guide is not legal advice.

Let the employee choose

Employers must not specify which documents an employee presents, ask for more documents than required, or reject a document because of its type or the employee's citizenship or national origin. In practice:

  • Show the employee all the acceptable choices (one List A document, or one List B plus one List C document) and let them pick.
  • Use Constaia only on the documents the employee chose. Don't build rules that favour one document over another.
  • Treat warnings and verdict reasons as a reason for a person to look again, never as an automatic rejection.

Anti-discrimination rules around Form I-9 are strict. Check your flow with your counsel.

The flow

The employee picks and uploads

Your onboarding app records the employee's choice (A, or B + C) and sends each file to your backend. The API key stays on your server.

Your backend verifies each document

For each document, call POST /v1/analyze with expect set to the types of the chosen list. You get the detected type, the fields, the format checks and a verdict whose reasons include the Form I-9 list.

A person reviews and signs

Show the reviewer the pre-filled draft, the confidence of each field and the reasons. They examine the documents as M-274 requires, correct what's needed and complete Section 2.

What Constaia returns

When you analyze a document with expect, verdict.reasons includes an informational reason with its list:

verdict.reasons (excerpt)
[
  { "code": "type_match", "severity": "info", "message": "The document is US driver's license." },
  { "code": "i9_list", "severity": "info", "message": "Acceptable Form I-9 document: List B." }
]

Depending on the type, you also get:

  • USCIS numbers (A-Number on the Permanent Resident Card, the EAD or the I-551 stamp): format validated with the us_uscis scheme, in checks[] as id_number_format.
  • Social Security number: format validated with us_ssn. Format only: Constaia does not check it against the Social Security Administration.
  • Driver's licenses and state IDs: number validated per state (us_dl) and the PDF417 barcode on the back read and cross-checked with the front (aamva_matches_visual). Details in US driver's license.
  • US passports: MRZ check digits and MRZ vs. printed data.
  • Expiration: not_expired is on by default for types that expire. For Form I-9 it is better to turn it off (not_expired: false) and flag the date for the reviewer, because some expired documents are still acceptable (for example, with an automatic extension).
  • Holder: with holder you can compare the name on the document with the one in Section 1.

Pre-fill Section 2

The example loads the lists once from the public catalogue, analyzes each document with the types of the list the employee chose and returns a draft for the reviewer. isComplete checks the combination (A, or B plus C on different documents); your code decides what to do with that result.

npm i @constaia/sdk
i9-draft.ts
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";

const constaia = new Constaia(); // reads CONSTAIA_API_KEY

type ListId = "A" | "B" | "C";

// Public catalogue (no key): type → Form I-9 lists.
async function loadI9Lists(): Promise<Map<string, ListId[]>> {
  const res = await fetch("https://api.constaia.com/v1/document-types?limit=500");
  const { data } = (await res.json()) as { data: { type: string; i9_lists: ListId[] | null }[] };
  return new Map(data.filter((t) => t.i9_lists?.length).map((t) => [t.type, t.i9_lists!]));
}
const I9_LISTS = await loadI9Lists();
const typesFor = (list: ListId) => [...I9_LISTS].filter(([, lists]) => lists.includes(list)).map(([type]) => type);

// Fields not named document_number / expiry_date in some types.
const NUMBER_FIELD: Record<string, string> = {
  us_ssn_card: "ssn",
  us_i94: "admission_number",
  us_birth_certificate: "certificate_number",
  us_consular_birth_report: "certificate_number",
};
const EXPIRY_FIELD: Record<string, string> = {
  us_i94: "admit_until_date",
  us_foreign_passport_i551: "stamp_expiry_date",
};

export async function draftSection2(path: string, list: ListId, employeeId: string, section1Name: string) {
  const analysis = await constaia.analyze(await fromPath(path), {
    expect: typesFor(list),
    checks: { notExpired: false, holder: { fullName: section1Name } },
    storage: "none",
    keepResults: false,
    language: "en", // document title in English, as on the form
    metadata: { employee_id: employeeId, i9_list: list },
  });
  if (analysis.status !== "completed" || !analysis.verdict) return { status: "pending", analysisId: analysis.id };

  const type = analysis.document?.type ?? "generic";
  const value = (name: string) => {
    const v = analysis.fields[name]?.value;
    return typeof v === "string" && v.trim() ? v.trim() : null;
  };

  // Anything that isn't informational goes to the reviewer: other type, other holder, number format, quality…
  const flags = analysis.verdict.reasons
    .filter((r) => r.severity !== "info")
    .map((r) => `${r.code}: ${r.message}`);
  for (const [name, field] of Object.entries(analysis.fields)) {
    if (field.value != null && field.confidence < 0.8) flags.push(`low_confidence: ${name}`);
  }
  const expiration = value(EXPIRY_FIELD[type] ?? "expiry_date");
  if (expiration && expiration < new Date().toISOString().slice(0, 10)) flags.push("expiration_date_in_past");

  return {
    status: "draft",
    type,
    lists: I9_LISTS.get(type) ?? [], // same as the verdict's i9_list reason
    documentTitle: analysis.document?.label ?? null,
    issuingAuthority: value("issuing_authority") ?? value("issuing_state") ?? value("tribe"),
    documentNumber: value(NUMBER_FIELD[type] ?? "document_number"),
    expirationDate: expiration ?? "N/A",
    flags, // for the HR reviewer, never an automatic rejection
  };
}

// One List A document, or one List B plus a different List C document.
export function isComplete(docs: { lists: ListId[] }[]): boolean {
  if (docs.some((d) => d.lists.includes("A"))) return true;
  return docs.some((b, i) => b.lists.includes("B") && docs.some((c, j) => j !== i && c.lists.includes("C")));
}

draftSection2(process.argv[2] ?? "./document.jpg", "A", "emp_123", "Jane Q Doe")
  .then((draft) => console.log(draft))
  .catch((err) => {
    if (err instanceof ConstaiaError) console.error(`Constaia error ${err.status} ${err.code} (request ${err.requestId})`);
    else console.error(err);
    process.exit(1);
  });

Each flag is a prompt for the reviewer, not a verdict. An expiration date in the past may still be acceptable in cases M-274 describes (such as automatic extensions); a name difference may be a married name; a type_mismatch may mean the employee uploaded a different document from the one they chose. A person decides.

Test mode

With a ck_test_ key the simulator has no US-specific scenarios: a US document is answered with the generic scenario (unless the file name contains keywords such as passport), so the verdict is usually review with type_unknown and no i9_list reason. 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.

Each document costs 1 credit (up to 2 pages). You can also use POST /v1/classify (0.2 credits) to catch obviously wrong uploads, such as a blank page, before analyzing.

If you only need a few fields

If you need neither a verdict nor list tagging, you can pass your own JSON Schema as extract without expect (for example with just title, authority, number and expiration). In that case verdict is null, there is no i9_list reason and the automatic checks don't apply to your fields.

Privacy

  • Use storage: "none" and keep_results: false (zero retention by default) and keep the data in your I-9 system, not in Constaia. Don't put document numbers in metadata.
  • Whether you retain copies of the documents is your policy decision; M-274 expects the same practice for all employees. Check retention with your counsel.
  • Constaia does no face matching and processes no biometric data. It acts as a service provider under the CCPA.
  • 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

Sur cette page