Webhooks

PDFox POSTs a job.completed event to your endpoint when a PDF has been generated. No polling required.

Webhook delivery is non-blocking — a delivery failure never affects the job status or your credits.

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:

  1. Per-request — include webhookUrl in the generate body. Takes precedence over the org default.
  2. Organisation default — set a URL once in Settings → Developer → Webhooks. All jobs that don't supply their own webhookUrl use 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

FieldTypeDescription
eventstringAlways "job.completed"
jobIdstring (UUID)Job identifier
orgIdstring (UUID)Organisation that owns the job
statusstringAlways "completed" in this event
templateIdstring (UUID)Template used to generate the PDF
documentIdstring (UUID)ID of the stored document
documentFilenamestringFilename of the generated PDF
documentExpiresAtstringISO 8601 timestamp when the PDF will be deleted from storage
downloadUrlstringSigned URL, valid for 1 hour — no auth header required to download
completedAtstringISO 8601 timestamp when the job completed

Request headers

HeaderDescription
PDFox-Signaturet=<unix seconds>,v1=<hex>. See Verifying signatures
PDFox-EventThe event name, e.g. job.completed
PDFox-Delivery-IdUnique per delivery and unchanged across retries. Use it to ignore duplicates
Content-Typeapplication/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.

  1. Read the raw request body, before any JSON parsing.
  2. Split the PDFox-Signature header on commas to get t (a Unix timestamp in seconds) and v1.
  3. Compute HMAC-SHA256 of t + "." + rawBody using your signing secret, hex encoded.
  4. Compare it with v1 using a constant-time comparison, and reject requests whose t is more than 5 minutes from your clock.
Rotating the secret takes effect immediately: new deliveries are signed with the new secret, and your receiver will reject them until you update it.

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.

The 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()));
});