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.
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
| List | Proves | Catalogue types |
|---|---|---|
| A | Identity and employment authorization | us_passport_book, us_passport_card, us_permanent_resident_card, us_foreign_passport_i551, us_ead, us_i94 |
| B | Identity only | us_driver_license, us_state_id, us_school_id, us_voter_registration_card, us_military_id, us_tribal_document, ca_driver_licence |
| C | Employment authorization only | us_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:
[
{ "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_uscisscheme, inchecks[]asid_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_expiredis 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
holderyou 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/sdkimport { 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"andkeep_results: false(zero retention by default) and keep the data in your I-9 system, not in Constaia. Don't put document numbers inmetadata. - 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
Verify a US driver's license
Verify a US driver's license or state ID with the PDF417 barcode (AAMVA), each state's license number format, expiration and minimum age, with Node or Python.
Onboard contractors with Form W-9
Validate Form W-9 with the us_w9 type without storing it, get a verdict covering signature and name, and receive the name, tax classification, TIN and address.