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
- Onboarding form → Edge Function: webhook fires, signature verified
- Edge Function → Vercel Blob: fetch the W-9 template PDF (cached at the edge after first fetch;
~10-30ms) - Edge Function → PDFops:
POST /api/fill-formwith the template + the onboarding answers as field values (~100-200msincluding network) - Edge Function → Vercel Blob: stash the filled W-9 (
~30-50ms) - Edge Function → email service: fire-and-forget a transactional email with the download URL (
~10msvia webhook to Resend or similar) - 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:
- Manual back-and-forth: ~10 minutes of human time per contractor (send blank PDF, chase, scan, OCR, re-key into your system). At $30/hr loaded ops cost, 5,000 × 10 min = ~833 hours = ~$25,000/yr in ops drag. And the data quality is worse than what the contractor already typed into the onboarding form.
- DocRaptor or similar hosted PDF API: $0.05–$0.20/doc × 5,000 = $250–$1,000/yr. Cheaper than the manual approach but priced from a substrate that doesn't fit edge-deployed apps.
- Headless Chrome on Lambda: ~$5-15/yr of compute at this volume, but the operational cost is the same as ever — Chrome layer, cold starts, the occasional render-failure rabbit hole. And it's not edge-deployed.
- PDFops (post-beta): priced from the substrate's unit economics. Per-document pricing lands when paid tiers open; the substrate the API runs on puts the floor at a different number entirely. Globally distributed by default. During beta: 100 requests per IP per month, free, no signup.
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
- You need the contractor to e-sign the PDF inside your app, not just download-print-sign-email-back. That's a DocuSign / Dropbox Sign / HelloSign workflow on top of the filled PDF. The fill-form step above is still upstream of that — auto-fill the W-9 from onboarding answers, then feed the filled PDF into your e-sign provider.
- You need to extract data from a W-9 the contractor uploaded (the reverse direction — OCR or AcroForm field extraction). That endpoint (
/api/extract-text) is on the roadmap but not shipped yet. The waitlist is how I'm prioritizing. - You're handling thousands of W-9s per month and need them archived for IRS audit purposes. The fill-form pattern still works; you'll want a real storage layer with retention and access logging in front of Vercel Blob (most ops teams already have one — S3 + a retention policy is fine). The PDFops call doesn't touch the long-term archive; it just produces the filled PDF.
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.