Guide
Packing-slip PDFs from a fulfillment webhook, and the redelivery that duplicates them
A fulfillment webhook that fires when an order ships doesn't promise to fire exactly once. Most providers document at-least-once delivery, so a timeout on your side, a 5xx from a cold start, or a stray network blip triggers a retry with the same payload, sometimes seconds later, sometimes minutes. A packing-slip handler that calls PDFops fill-form on every delivery fills the same slip twice, burns quota twice, and sometimes emails a customer the same document twice. Fixing it takes one atomic claim on the event id, checked and set in a single round trip, so the second delivery learns someone already has it before doing any of that work again.
The shape of the job
Say a small 3PL runs packing slips off its warehouse system's order-shipped webhook: an order ships, the webhook fires with the order number, the shipping address, and the line items, and a Vercel Function fills a packing-slip template through PDFops, uploads the result to Vercel Blob, and returns the URL so the warehouse UI can show a print button. One order should produce one slip, no matter how many times the webhook decides to tell you about it.
At-least-once means sometimes-twice
Webhook providers rarely guarantee exactly-once delivery, because guaranteeing it would mean holding a distributed lock across every subscriber before confirming an event even landed. What they promise instead is at-least-once: if your endpoint doesn't answer with a fast 2xx, the event goes back into a retry queue and fires again, carrying the same event id and the same body. A five-second cold start on a rarely-hit Function, a transient 502 from your own upstream, or a provider retry policy that fires on any non-2xx response are each enough to trigger a second delivery of an event your handler already finished.
A single atomic claim, not two separate steps
The obvious first attempt reads: check a table for this event
id, and if it's missing, do the work and insert a row. Under a
single delivery that's correct. Under two deliveries that
arrive close together, both requests can pass the check before
either one finishes the insert, and both go on to call
fill-form. The fix is to make the check and the reservation the
same operation. A SET with NX (set
only if the key doesn't already exist) is atomic in Redis, so
exactly one of two concurrent requests gets back confirmation
that it won the claim.
The handler
Everything above collapses into a route handler and a small helper, reachable from a Vercel Function over Upstash's REST API without pinning a TCP connection to a serverless invocation.
// app/api/webhooks/fulfillment/route.ts
import { Redis } from '@upstash/redis';
import { put } from '@vercel/blob';
const redis = Redis.fromEnv();
const PDFOPS_KEY = process.env.PDFOPS_API_KEY!;
export async function POST(req: Request) {
const event = await req.json();
// Field name varies by provider; use whatever id it signs on the event.
const claimKey = `packing-slip:${event.id}`;
const claimed = await redis.set(claimKey, 'in_progress', {
nx: true,
ex: 60, // clears itself if the handler crashes before finishing
});
if (claimed !== 'OK') {
const state = await redis.get<string>(claimKey);
if (state === 'in_progress') {
// A concurrent delivery already owns this event; let the
// provider's own retry backoff handle the wait.
return new Response(null, { status: 409 });
}
// state holds the URL from a run that already finished.
return Response.json({ url: state });
}
try {
const url = await generatePackingSlip(event.order);
await redis.set(claimKey, url, { ex: 60 * 60 * 24 * 30 }); // keep 30 days
return Response.json({ url });
} catch (e) {
await redis.del(claimKey); // let the next redelivery try again
throw e;
}
}
async function generatePackingSlip(order: {
number: string;
shipTo: string;
items: { qty: number; sku: string }[];
}): Promise<string> {
const template = await fetch(process.env.PACKING_SLIP_TEMPLATE_URL!).then(
(r) => r.arrayBuffer(),
);
const form = new FormData();
form.set('pdf', new Blob([template], { type: 'application/pdf' }), 'template.pdf');
form.set(
'fields',
JSON.stringify({
order_number: order.number,
ship_to: order.shipTo,
items: order.items.map((i) => `${i.qty}x ${i.sku}`).join('\n'),
}),
);
form.set('flatten', 'true');
const res = await fetch('https://pdfops.dev/api/fill-form', {
method: 'POST',
headers: { 'X-API-Key': PDFOPS_KEY },
body: form,
});
if (!res.ok) {
const err = await res.json();
throw new Error(`fill-form failed: ${err.error} (${err.details})`);
}
const pdf = await res.arrayBuffer();
const blob = await put(`packing-slips/${order.number}.pdf`, pdf, {
access: 'public',
contentType: 'application/pdf',
});
return blob.url;
}
The claim key carries three possible states over its life:
absent (no delivery has touched this event yet),
in_progress (a delivery is mid-flight, held under a
short expiry so a crash doesn't wedge it forever), and a stored
URL (the run that finished, kept for a month so a very late
redelivery gets the same answer instead of a second document).
A redelivery that lands while the first is still working gets a
409 back; a redelivery that lands after success gets the URL
straight back, with no call to fill-form made at all.
Releasing a claim that never finished
A handler that crashes mid-fill, say because the template fetch
times out, leaves its claim in the in_progress
state until the expiry clears it. That's why the TTL matters:
pick it to comfortably exceed the slowest real run, short
enough that a genuine crash doesn't block a legitimate retry for
long. The catch block also deletes the claim directly on a
known failure, so a clean error doesn't have to wait out the
timer at all before the next delivery can try again.
Try it
The same claim pattern covers any webhook-triggered document: an invoice on payment succeeded, a contract on signature completed, a statement on billing cycle closed, anywhere a provider might redeliver an event your handler already finished. A free key from /docs/signup covers 250 calls a month, worth protecting from accidental doubles for exactly the reason above: a duplicate fill on a retried webhook is quota spent on a document nobody asked for twice. Check usage anytime at /docs/usage or the dashboard, and confirm a template's field names at /docs/inspect before wiring the fields object above to your own PDF.