Constaia
Use-case guides

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.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

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:

FieldContent
nameLine 1: name as shown on the income tax return.
business_nameLine 2: business name or disregarded entity name.
tax_classificationFederal tax classification (individual, C corp, S corp, partnership, LLC…).
exempt_payee_codeExempt payee code, if present.
address, postal_codeAddress and ZIP code.
ssn, einThe TIN, in the box where it was written (usually only one has a value).
signature_present, signature_dateWhether it is signed, and the signature date.
form_revisionForm 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.

w9.ts
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"):

verdict
{
  "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:

ein-letter.ts
// 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-9

holder.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" and keep_results: false, so Constaia stores neither the file nor the extracted fields (only billing metadata). Never put the TIN in metadata, 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_signature checks 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

Nesta página