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.
Before you pay a US contractor, you collect a Form W-9 with their legal name, business name, federal tax classification, address and taxpayer identification number (TIN: an SSN or an EIN). Reading those PDFs by hand is slow and error-prone; keeping copies lying around is a risk.
This guide uses the catalogue type us_w9 with expect: Constaia extracts the fields, checks the TIN format, the
signature and the name, and returns a verdict, without ever storing the document. Matching the TIN against the IRS
remains your job.
The flow
The contractor uploads the signed W-9
Your onboarding portal sends the PDF or photo to your backend. The API key never leaves your server.
Your backend analyzes the W-9 without storing it
Call POST /v1/analyze with expect: "us_w9", the checks you need, storage: "none" and keep_results: false. The
response is the only place the TIN appears; nothing remains in Constaia.
Your code decides and stores
Based on verdict.status (valid, review or invalid) you onboard the contractor, send the form to review or ask
for a new one. You store the TIN encrypted in your system and pass the record to your payments team or to TIN matching.
The us_w9 type
The us_w9 type returns these fields:
| Field | Content |
|---|---|
name | Line 1: name as shown on the income tax return. |
business_name | Line 2: business name or disregarded entity name. |
tax_classification | Federal tax classification (individual, C corp, S corp, partnership, LLC…). |
exempt_payee_code | Exempt payee code, if present. |
address, postal_code | Address and ZIP code. |
ssn, ein | The TIN, in the box where it was written (usually only one has a value). |
signature_present, signature_date | Whether it is signed, and the signature date. |
form_revision | Form revision, for example Rev. March 2024. |
On every analysis, Constaia validates the format of ssn (scheme us_ssn), ein (us_ein) and postal_code
(us_zip). The result appears in checks[] with the code id_number_format. These validations are soft: if they
fail, the reason reaches verdict.reasons with severity warning and the verdict becomes review, not invalid.
The checks the type supports are holder, require_fields, require_signature, max_age_days (on signature_date)
and reference_date. The full, always up-to-date list is in
GET /v1/document-types/us_w9 and in the catalogue.
Analyze and decide
Format only
id_number_format checks the structure of the number (nine digits, unassigned ranges such as 000, 666 or 9xx
in an SSN, valid EIN prefixes). It is not a validation against the IRS or the Social Security Administration: a TIN
with a valid format can still be wrong or belong to someone else.
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
type Contractor = { id: string; legalName: string; tin?: string }; // what they typed in your onboarding form
export async function processW9(path: string, contractor: Contractor) {
const analysis = await constaia.analyze(await fromPath(path), {
expect: "us_w9",
checks: {
requireSignature: true,
requireFields: ["name", "tax_classification"],
holder: { fullName: contractor.legalName },
},
storage: "none",
keepResults: false,
metadata: { contractor_id: contractor.id }, // never the TIN
});
if (analysis.status !== "completed") return { status: "pending", analysisId: analysis.id };
const v = (name: string) => analysis.fields[name]?.value ?? null;
const tinType = v("ein") ? "ein" : v("ssn") ? "ssn" : null;
const tin = String(v("ein") ?? v("ssn") ?? "").replace(/\D/g, "");
// Non-informational reasons: type_mismatch, holder, require_signature, id_number_format…
const issues = (analysis.verdict?.reasons ?? [])
.filter((r) => r.severity !== "info")
.map((r) => `${r.code}:${r.severity}`);
if (contractor.tin && contractor.tin.replace(/\D/g, "") !== tin) issues.push("tin_differs_from_form:error");
const status =
analysis.verdict?.status === "invalid" ? "rejected" : issues.length ? "needs_review" : "ok";
return {
status,
issues,
record: {
name: v("name"),
businessName: v("business_name"),
taxClassification: v("tax_classification"),
exemptPayeeCode: v("exempt_payee_code"),
address: v("address"),
postalCode: v("postal_code"),
tinType,
tin, // store encrypted; show and log only the last 4
tinLast4: tin.slice(-4),
signatureDate: v("signature_date"),
formRevision: v("form_revision"),
},
};
}
processW9(process.argv[2] ?? "./w9.pdf", { id: "ctr_42", legalName: "Specimen Consulting LLC" })
.then(({ status, issues, record }) => console.log({ status, issues, tinLast4: record?.tinLast4 }))
.catch((err) => {
if (err instanceof ConstaiaError) console.error(`Constaia error ${err.status} ${err.code} (request ${err.requestId})`);
else console.error(err);
process.exit(1);
});A W-9 with a malformed SSN and everything else correct comes back like this (with language: "en"):
{
"expected": ["us_w9"],
"match": true,
"status": "review",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is IRS Form W-9." },
{ "code": "holder", "severity": "info", "message": "The holder details match (full_name)." },
{ "code": "require_signature", "severity": "info", "message": "The document is signed." },
{ "code": "id_number_format", "severity": "warning", "message": "Validation failed: ssn (us_ssn) has an invalid format: 666123456." }
]
}What each result means:
valid: it is a W-9, it is signed, the name matches and the TIN has a valid format.review: something deserves a look: a TIN with an invalid format, a field that could not be read, poor image quality or low confidence. Send it to human review.invalid: it is not a W-9 (type_mismatch, for example a W-8BEN), the signature is missing, a required field is missing or the name does not match. Ask the contractor for a new form.
ITIN in the SSN box
The ssn field is validated with the us_ssn scheme, which rejects numbers starting with 9. An ITIN written in the
SSN box, which is common and correct on a W-9, therefore produces an id_number_format warning and a review
verdict. If you accept ITINs, check for them in your code (nine digits starting with 9) before sending the form to
review.
A full W-9 with its instructions is 6 pages and costs 3 credits (1 credit up to 2 pages, +1 per 2 extra pages). If you ask for the first page only, it costs 1. See Credits and billing.
Contractors with an EIN: the IRS letter
If the contractor is a business, you can also ask for the letter in which the IRS assigned the EIN (CP 575) or
verified it (147C). The us_irs_ein_letter type extracts business_name, ein, address, postal_code,
notice_type and issue_date. In this type the EIN validation is not soft: an EIN with an invalid format gives
invalid.
To cross-check it with the W-9, send the name and EIN read from the W-9 in holder:
// w9: the record returned by processW9
const letter = await constaia.analyze(await fromPath("./cp575.pdf"), {
expect: "us_irs_ein_letter",
checks: { holder: { fullName: w9.businessName ?? w9.name, documentNumber: w9.tin } },
storage: "none",
keepResults: false,
});
console.log(letter.verdict?.status); // "valid" if name and EIN match the W-9holder.document_number is compared without spaces or hyphens. A letter that matches the W-9 does not replace IRS TIN
matching: both documents come from the contractor.
Handling the TIN
- Treat it as highly sensitive. Keep
storage: "none"andkeep_results: false, so Constaia stores neither the file nor the extracted fields (only billing metadata). Never put the TIN inmetadata, logs or error messages; show only the last four digits. - Encrypt it at rest in your system and restrict who can read it.
- Match it against the IRS yourself. Constaia validates the SSN and EIN format, but it does not query the IRS (it does not use the TIN Matching program), the Social Security Administration or any other public database. If you need to confirm that the name and TIN combination is correct, use TIN Matching if you are eligible, or your payments provider's service.
- Signature.
require_signaturechecks that there is a visible signature in the Sign Here box; it does not verify who signed.
Constaia acts as a service provider under the CCPA and within your GLBA information security program where it applies to you. Check backup withholding, record retention and electronic W-9 requirements with your legal or tax advisor; this guide is not legal advice.
Test mode
The test-mode simulator (ck_test_ keys) has no US document scenarios. A W-9 is answered with the generic
scenario, so with expect: "us_w9" the verdict is review with the reason type_unknown (filenames containing
invoice or receipt are answered with Spanish invoice and payment receipt scenarios). Use it to test your
integration and error paths; to see real W-9 results use a live key. The free plan includes 150 credits a month; free
live credits require a verified email. See Test mode.
Coming soon: US region
Today all processing and storage, including for US customers, happens in the EU. A US region is planned, with no date yet. See Data residency and compliance.
Next steps
US documents
Every US type in the catalogue and what each one checks.
Analyze without storing
The zero-retention setup in detail.
Form I-9 documents
Pre-fill Section 2 from the documents a new hire presents.
Human review
Send forms with issues to a person.
Checks
holder, require_signature, require_fields and the rest.
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.
Income verification with paystubs and bank statements
Analyze US paystubs and bank statements in one batch with the us_paystub and us_bank_statement types, with recency and holder checked, and compute monthly income in your code.