Webhooks
PDFox POSTs a job.completed event to your endpoint when a PDF has been generated. No polling required.
job.completed is the only event. Failed jobs don't send a webhook — if a job might fail, poll job status as a fallback and check errorMessage.
Configuring a webhook URL
There are two ways to supply a webhook URL:
- Per-request — include
webhookUrlin the generate body. Takes precedence over the org default. - Organisation default — set a URL once in Settings → Developer → Webhooks. All jobs that don't supply their own
webhookUrluse this. Only org owners can update the default.
There's no per-job opt-out: if an org default is set, jobs without a webhookUrl are always delivered to it.
Payload fields
| Field | Type | Description |
|---|---|---|
event | string | Always "job.completed" |
jobId | string (UUID) | Job identifier |
orgId | string (UUID) | Organisation that owns the job |
status | string | Always "completed" in this event |
templateId | string (UUID) | Template used to generate the PDF |
documentId | string (UUID) | ID of the stored document |
documentFilename | string | Filename of the generated PDF |
documentExpiresAt | string | ISO 8601 timestamp when the PDF will be deleted from storage |
downloadUrl | string | Signed URL, valid for 1 hour — no auth header required to download |
completedAt | string | ISO 8601 timestamp when the job completed |
Request headers
| Header | Description |
|---|---|
PDFox-Signature | t=<unix seconds>,v1=<hex>. See Verifying signatures |
PDFox-Event | The event name, e.g. job.completed |
PDFox-Delivery-Id | Unique per delivery and unchanged across retries. Use it to ignore duplicates |
Content-Type | application/json |
Verifying signatures
Every delivery is signed so you can confirm it came from PDFox and wasn't altered. Find your organisation's signing secret (it starts with whsec_) in Settings → Developer → Webhooks; owners and admins can reveal and rotate it.
- Read the raw request body, before any JSON parsing.
- Split the
PDFox-Signatureheader on commas to gett(a Unix timestamp in seconds) andv1. - Compute HMAC-SHA256 of
t + "." + rawBodyusing your signing secret, hex encoded. - Compare it with
v1using a constant-time comparison, and reject requests whosetis more than 5 minutes from your clock.
Retries
Each attempt times out after 10 seconds. If your endpoint can't be reached, times out or returns a non-2xx status, PDFox retries up to 4 more times with exponential back-off, starting at about 1 minute. Delivery history is visible in Settings → Developer → Webhooks.
downloadUrl in the payload expires after 1 hour. Download the PDF promptly after receiving the webhook. To get a fresh URL later, call GET /v1/generate/jobs/:jobId/download.POST https://your-server.com/hooks/pdfox
Content-Type: application/json
{
"event": "job.completed",
"jobId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"orgId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "completed",
"templateId": "a3f1d2e4-1111-4562-b3fc-2c963f66afa6",
"documentId": "d5f3b4a6-3333-4562-b3fc-2c963f66afa6",
"documentFilename": "invoice-INV-0042.pdf",
"documentExpiresAt": "2026-06-11T10:00:03.000Z",
"downloadUrl": "https://api.pdf-fox.com/v1/generate/jobs/7c9e6679-7425-40de-944b-e07fc1f90ae7/file?token=...",
"completedAt": "2026-06-04T10:00:03.000Z"
}import crypto from 'node:crypto';
const SECRET = process.env.PDFOX_WEBHOOK_SECRET; // whsec_... from Developer → Webhooks
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // reject replays
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the RAW body: re-serialising parsed JSON changes the bytes and breaks the signature
app.post('/hooks/pdfox', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.get('PDFox-Signature') ?? '')) return res.sendStatus(401);
const event = JSON.parse(rawBody);
// Acknowledge fast: PDFox waits at most 10 seconds for a response
res.sendStatus(200);
if (event.event !== 'job.completed') return;
// Retries reuse the same PDFox-Delivery-Id, so you can skip ones you've handled
const pdf = await fetch(event.downloadUrl); // valid for 1 hour, no auth header needed
await savePdfToStorage(event.documentFilename, Buffer.from(await pdf.arrayBuffer()));
});