Compliance owns the KYC application template, not engineering — and they revise it without filing a ticket. Hardcode the field names once and the next template swap either breaks silently (a field goes unfilled) or throws unknown_field in production. The fix is to stop assuming the shape of a PDF you don't control: ask it, at request time, what fields it actually has, fill whatever comes back, then merge the filled application with the applicant's uploaded ID scan into one packet. Here's the whole flow in ~45 lines on Deno Deploy, using the new pdfops-sdk instead of raw fetch calls.
Why inspect first
Every other example on this blog hardcodes a JSON object of field names — reasonable when the template PDF is a file you committed to your own repo. A KYC application template isn't that. It's owned by a compliance or ops team who edit it in Acrobat, rename a field, add a new disclosure checkbox, and re-upload it to wherever your app fetches it from — all without a code review on your side. Filling against a hardcoded key list against a template you don't control is a bug waiting for the next revision.
POST /api/inspect answers "what fields does this PDF have right now?" — every AcroForm field's name, type, and (for dropdowns/radios) its options, plus a ready-to-fill fillTemplate object keyed by name. Call it once per request against the live template, and the fill step only ever writes into fields that actually exist. A template revision changes what gets filled, not whether the request throws.
Install the SDK, get a key
Every earlier post here uses fetch and FormData directly against the HTTP API — that still works and always will. pdfops-sdk is a thin typed wrapper around the same endpoints: no dependencies, edge-safe (Workers, Vercel Edge, Deno, Bun, Node 18+, browsers), and it turns the multipart plumbing into three method calls.
npm install pdfops-sdk
You need a key to run more than the anonymous trial. A free one costs nothing and needs no card — email in, key out (the key arrives by email, not in the response body, so inbox possession is the verification step):
import { PdfOps } from 'pdfops-sdk';
const trial = new PdfOps(); // no key: 100 requests/IP/month
await trial.signup('you@example.com'); // free key, 250/month, no card — check your inbox
Once the key lands, wire it in and every call meters against your own quota instead of your IP's:
const pdfops = new PdfOps({ apiKey: Deno.env.get('PDFOPS_API_KEY') });
The Deno Deploy handler
The endpoint takes a multipart POST — the applicant's data as a JSON field, their ID scan as a file — pulls the current application template, inspects it, fills only the fields it finds, and merges in the ID scan. No browser, no second runtime, ~45 lines.
// main.ts — Deno Deploy
import { PdfOps, PdfOpsError } from 'pdfops-sdk';
const pdfops = new PdfOps({ apiKey: Deno.env.get('PDFOPS_API_KEY') });
Deno.serve(async (req: Request) => {
if (req.method !== 'POST') {
return new Response('POST multipart/form-data: applicant, id_scan', { status: 405 });
}
const form = await req.formData();
const idScan = form.get('id_scan');
const applicantRaw = form.get('applicant');
if (!(idScan instanceof File) || typeof applicantRaw !== 'string') {
return new Response('expected fields "applicant" (JSON) and "id_scan" (file)', { status: 400 });
}
const applicant: Record<string, unknown> = JSON.parse(applicantRaw);
// 1. Pull the CURRENT template — compliance swaps this file on their
// own schedule, so the fetch (not a bundled asset) is deliberate.
const templateResp = await fetch(Deno.env.get('TEMPLATE_URL')!);
const template = new Uint8Array(await templateResp.arrayBuffer());
// 2. Ask the template what fields it has right now, instead of
// assuming a fixed shape.
const { fillTemplate } = await pdfops.inspect(template);
// 3. Map applicant data onto whatever fields actually exist. A field
// the template dropped is silently skipped; a field the applicant
// didn't send stays at the template's default.
const values: Record<string, string> = {};
for (const name of Object.keys(fillTemplate)) {
if (name in applicant) values[name] = String(applicant[name]);
}
try {
// 4. Fill the application.
const filled = await pdfops.fillForm(template, values);
// 5. Merge the filled application with the uploaded ID scan into
// one packet — page order follows array order.
const idBytes = new Uint8Array(await idScan.arrayBuffer());
const packet = await pdfops.merge([filled, idBytes]);
return new Response(packet, { headers: { 'content-type': 'application/pdf' } });
} catch (e) {
if (e instanceof PdfOpsError) {
return new Response(JSON.stringify({ error: e.code, details: e.message }), { status: e.status });
}
throw e;
}
});
deployctl deploy main.ts, set PDFOPS_API_KEY and TEMPLATE_URL as environment variables, and you have a live endpoint. The fillForm and merge calls both return Uint8Array PDF bytes — hand them to storage, a Response, or an email attachment as-is; nothing in this handler buffers more than one document at a time.
Why merge goes last, and what else it can hold
merge([filled, idBytes]) concatenates pages in array order, so the filled application always lands as page 1 — the reviewer opens the packet and reads the application first, attachments after. The array isn't capped at two: a real KYC packet is usually merge([filled, idFront, idBack, proofOfAddress]) — every attachment the applicant uploaded, in the order you want a reviewer to see them, still one POST /api/merge call.
Errors and watching your quota
Every non-2xx response the SDK sees throws a PdfOpsError with the API's stable error slug — the catch block above turns unknown_field, invalid_pdf, or a 429 rate_limited straight into the right HTTP status for the caller, instead of a generic 500. And because the free tier has a real ceiling (250/month per key), check it before you're surprised by one:
const { used, remaining, resets_at } = await pdfops.usage();
That's GET /api/usage under the hood — it reads the same counter the 429 fires on, so the number you show a user is never out of sync with the number that blocks them.
Where this fits a real app
Swap the runtime and the trigger, and the same three-call shape — inspect, fill, merge — covers most "combine a filled form with something a user uploaded" flows:
The part that's new here is inspect in the chain — reach for it whenever the template isn't a file you fully control, not just for KYC packets.
Try it
All three endpoints are live. Prove the chain from your terminal before writing a line of Deno:
# 1. What fields does this template have?
curl -X POST https://pdfops.dev/api/inspect -F "pdf=@application.pdf"
# 2. Fill it
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@application.pdf" \
-F 'fields={"applicant_name":"Ada Lovelace","country":"UK"}' \
-o filled.pdf
# 3. Merge with the ID scan
curl -X POST https://pdfops.dev/api/merge \
-F "pdf=@filled.pdf" -F "pdf=@id-scan.pdf" \
-o packet.pdf
You get 100 keyless requests per IP per month, or pdfops.signup('you@example.com') / the form at /pricing for a free key (250/mo, no card — delivered by email).
Building a KYC or onboarding flow and something about inspect's field-type coverage doesn't fit yours? Drop a note on the feedback form — it's the fastest way to influence what ships next.
Top comments (0)