Events
Set one HTTPS address on the API keys page, and every job made with your keys that ends is posted there as JSON. The address gets a signing secret (whsec_тАж), shown when it is set and again on request.
| Name | Type | Description |
|---|---|---|
| job.succeeded | event | The job is done; its outputs are listed (names and sizes, no links). |
| job.failed | event | The job failed; data.job.error says why. |
| job.cancelled | event | The job was cancelled with DELETE, or never got its files. |
| ping | event | A test event, sent when you press тАЬSend a test eventтАЭ on the keys page; data is empty. |
{
"id": "8c1d2f30-5a7e-4b7f-8a1c-2e6b9d4f1a22",
"type": "job.succeeded",
"created": "2026-10-28T10:16:12.000Z",
"livemode": true,
"data": {
"job": {
"id": "3f6c2c0e-6d1b-4f1c-9a2f-1d7e2b5a9c10",
"tool": "compress",
"state": "done",
"error": null,
"outputs": [
{
"name": "report.pdf",
"size": 1102337,
"contentType": "application/pdf"
}
]
}
}
}livemode is false for a job made with a test key. Bodies carry no download links: read the job with your key for those.
The signature
Every delivery carries these headers:
| Name | Type | Description |
|---|---|---|
| UVPDF-Signature | t=1761646572,v1=5f1cтАж | An HMAC-SHA256 of "<t>.<body>" under your secret, hex; t is the time in seconds. |
| UVPDF-Event | job.succeeded | The event type, as in the body. |
| UVPDF-Delivery | 8c1d2f30-тАж | The deliveryтАЩs id. The same event can be sent twice; skip an id you have seen. |
| Content-Type | application/json |
The signature is an HMAC-SHA256, in hex, of the timestamp, a dot and the raw body, under your secret: HMAC_SHA256(secret, "<t>.<body>"). Check it on the bytes as they arrived, before parsing anything, and refuse a timestamp more than a few minutes old; a replayed body then fails on its age.
Checking it
import { createHmac, timingSafeEqual } from 'node:crypto';
/** True when the body came from UV pdfTool, unchanged, within the last five minutes. */
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((part) => part.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = String(parts.v1 ?? '');
// Hex only: then both are the same length in bytes, as timingSafeEqual needs.
return /^[0-9a-f]{64}$/.test(given) && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
// In an Express handler, keep the raw body: express.raw({ type: 'application/json' })
app.post('/webhooks/uvpdftool', express.raw({ type: 'application/json' }), (request, response) => {
const body = request.body.toString('utf8');
if (!verifyWebhook(body, request.get('UVPDF-Signature') ?? '', process.env.UVPDF_WEBHOOK_SECRET)) {
return response.status(400).end();
}
const event = JSON.parse(body);
if (event.type === 'job.succeeded') queueDownload(event.data.job.id);
response.status(200).end();
});Retries and the log
- Any 2xx answer counts as received. Answer quickly and do the work afterwards.
- Otherwise we try again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then give up on that delivery.
- We follow no redirects and wait 10 seconds at most; the address must resolve to a public host.
- The same event can arrive twice, for example when your answer was lost: skip a
UVPDF-Deliveryid you have already handled. - The keys page lists the last deliveries with their status and any error, keeps them 30 days, and says since when an address has been failing.