Use Case

Auto-filling a W-9 from a contractor-onboarding webhook on Vercel Edge

A US contractor signs up to do work for your business. You ask them the same fifteen questions you already ask every contractor — name, address, EIN or SSN, entity type. Then you also ask them to download a blank W-9, fill it in, scan it, and send it back. They're typing the same answers twice and you're collecting low-quality scans. Better: take the data from the form they already filled out, write it into a W-9 template, and email them back a clean PDF ready to sign. The whole flow runs on a single Vercel Edge Function and completes in under 300ms.

The setup

Every multi-product business I run hires contractors — designers, developers, accountants, freelance writers. IRS rules require a W-9 on file for every US person or entity I pay more than $600 in a calendar year, and a W-8BEN equivalent for non-US contractors. The W-9 itself is mostly fields the contractor has already given me during onboarding: legal name, business name, federal tax classification, address, taxpayer identification number. Asking for the same data twice — once in my onboarding form, once on the W-9 itself — is wasted UX and produces worse data the second time. (Companion: contract PDFs from onboarding flows covers the broader pattern across NDAs, MSAs, and vendor onboarding packets.)

The shape of the problem maps to a webhook. The contractor finishes my onboarding form (Tally, Typeform, Formspark, or a hand-rolled HTML form posting to my own endpoint — doesn't matter). The form provider POSTs the answers to my Vercel Edge Function. The function calls /api/fill-form against a W-9 template with those answers, stashes the result, and emails the contractor a download link. Total time from submit to inbox: under a second.

The flow

  1. Onboarding form → Edge Function: webhook fires, signature verified
  2. Edge Function → Vercel Blob: fetch the W-9 template PDF (cached at the edge after first fetch; ~10-30ms)
  3. Edge Function → PDFops: POST /api/fill-form with the template + the onboarding answers as field values (~100-200ms including network)
  4. Edge Function → Vercel Blob: stash the filled W-9 (~30-50ms)
  5. Edge Function → email service: fire-and-forget a transactional email with the download URL (~10ms via webhook to Resend or similar)
  6. Edge Function → form provider: 200 OK

End-to-end: ~150-300ms. The form provider's webhook is happy, the contractor gets a pre-filled W-9 in their inbox in the time it takes them to click away from the thank-you page.

The function

Here's the whole thing — ~60 lines, TypeScript, no abstractions. Webhook signature verification is sketched but not implemented (each form provider has its own scheme — Tally uses an HMAC of the raw body against your webhook secret, Typeform uses a similar shape with a Typeform-Signature header; both are five lines of crypto.subtle.verify).

// app/api/contractor-onboarded/route.ts
//
// Vercel Edge Function. Fires when the onboarding form POSTs.
import { put } from '@vercel/blob';

export const runtime = 'edge';

interface OnboardingPayload {
  legal_name: string;          // "Alex Doe" or "Acme Consulting LLC"
  business_name?: string;      // optional — populated for entities
  tax_classification: 'individual' | 'c-corp' | 's-corp' | 'partnership' | 'llc' | 'other';
  address_line_1: string;
  address_line_2?: string;
  city: string;
  state: string;               // 2-letter US state code
  zip: string;
  tin: string;                 // SSN or EIN; format-validated client-side
  email: string;
}

export async function POST(req: Request): Promise<Response> {
  // 1. Verify the form provider's webhook signature (per-provider; ~5 lines)
  const raw = await req.text();
  if (!(await verifySignature(req, raw, process.env.FORM_WEBHOOK_SECRET!))) {
    return new Response('bad signature', { status: 401 });
  }
  const data = JSON.parse(raw) as OnboardingPayload;

  // 2. Fetch the W-9 template from Vercel Blob (or bundle at build time).
  const templateResp = await fetch(process.env.W9_TEMPLATE_URL!);
  if (!templateResp.ok) return new Response('template missing', { status: 500 });
  const templatePdf = await templateResp.arrayBuffer();

  // 3. Call PDFops /api/fill-form. Field names must match the W-9
  //    template's AcroForm field names — see the "template" section below
  //    for how I named them. Keep the W-9 signature/date lines blank;
  //    the contractor still signs the final doc.
  const fd = new FormData();
  fd.append('pdf', new Blob([templatePdf], { type: 'application/pdf' }), 'w9-template.pdf');
  fd.append('fields', JSON.stringify({
    legal_name:         data.legal_name,
    business_name:      data.business_name ?? '',
    tax_class:          data.tax_classification,
    address:            [data.address_line_1, data.address_line_2].filter(Boolean).join(', '),
    city_state_zip:     `${data.city}, ${data.state} ${data.zip}`,
    tin:                data.tin,
  }));
  const pdfResp = await fetch('https://pdfops.dev/api/fill-form', { method: 'POST', body: fd });
  if (!pdfResp.ok) {
    // Return 500 so the form provider retries (Tally/Typeform both retry on 5xx)
    return new Response(`PDFops error: ${await pdfResp.text()}`, { status: 500 });
  }
  const filledPdf = await pdfResp.arrayBuffer();

  // 4. Stash the filled W-9 in Vercel Blob with a non-guessable key.
  //    Do NOT use the contractor's email or TIN in the key — see the
  //    "auth boundary" paragraph below.
  const objectKey = `w9/${crypto.randomUUID()}.pdf`;
  const { url } = await put(objectKey, filledPdf, {
    access: 'public',          // see auth-boundary discussion below
    contentType: 'application/pdf',
  });

  // 5. Fire-and-forget email with the download URL.
  fetch(process.env.EMAIL_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      to:      data.email,
      subject: 'Your pre-filled W-9 — please review and sign',
      html:    `<p>We filled in your W-9 from your onboarding answers. ` +
               `Open, double-check, sign, and email it back: ` +
               `<a href="${url}">${url}</a></p>`,
    }),
  }).catch(() => {});

  return new Response('OK');
}

That's the production-shape. Deploy it to Vercel, point your onboarding form's webhook at the URL, drop your W-9 template into Vercel Blob, and every contractor who finishes the form receives a pre-filled W-9 in their inbox within a second.

One auth-boundary footgun worth flagging: a W-9 contains the contractor's TIN (SSN for individuals, EIN for entities) — that's PII that belongs nowhere near a publicly-guessable URL. The code above uses crypto.randomUUID() as the blob key, which is unguessable in practice, but the object is still access: 'public' — anyone with the UUID can fetch it. That's fine for a short-lived email link the contractor opens once, but if you want the link to be revocable, store the blob with access: 'private' and email a signed URL (Vercel Blob's head() + a short-TTL signed redirect via your own route, or move to S3-compatible storage with native presigned URLs). The code above keeps the happy-path visible; tighten the download boundary before you send the first email.

The template

Download the IRS's current W-9 PDF from irs.gov/pub/irs-pdf/fw9.pdf — the official version is already AcroForm-enabled. Open it in Acrobat, Mac Preview, or LibreOffice Draw, and rename the form fields to short, predictable names that your code can target (legal_name, business_name, tax_class, address, city_state_zip, tin). The IRS's default field names are long and inconsistent across PDF versions; renaming them once lets your code survive the annual W-9 form refresh.

Upload the renamed template to Vercel Blob via vercel blob put or the dashboard. ~80-200 KB is typical; well under any practical limit. For W-8BEN (non-US contractors) the same approach works against fw8ben.pdf — branch on contractor country in the function and pick the right template.

The cost math

Contractor onboarding is usually low-volume per business (tens to hundreds per year), but multiply across a portfolio and it adds up. At 5,000 W-9s per year across a holdco — a realistic number for an agency, staffing firm, or multi-business operator — here's how the substrate matters:

The point isn't that PDFops generates W-9s faster than alternatives. It's that the hosting substrate has different unit economics, and that difference passes through to per-document pricing — while removing the ops drag of the manual back-and-forth entirely.

When this pattern doesn't fit

Try it

The fill-form endpoint is live and works against any AcroForm PDF — not just W-9s. Same shape for I-9s, 1099 templates, vendor-onboarding NDAs, or any pre-designed form you want a webhook to populate:

curl -X POST https://pdfops.dev/api/fill-form \
  -F "pdf=@w9-template.pdf" \
  -F 'fields={"legal_name":"Alex Doe","business_name":"","tax_class":"individual","address":"123 Main St","city_state_zip":"Austin, TX 78701","tin":"123-45-6789"}' \
  -o filled-w9.pdf

You'll get a filled W-9 PDF back. From there it's just wiring it into a webhook handler shaped like the function above.

Onboarding-flow questions, missing endpoints, edge-deploy weirdness? Drop a note on the waitlist form — the message field is the fastest way to influence what ships next.