Webhooks
Receive signed events when a run finishes or a document changes, and verify each signature.
Events
Webhooks are on the Team plan and above. Add an endpoint in Settings, then Developers, or with POST /v1/webhooks. It must be a public https address. PDF Loft sends a POST with a JSON body to it for each event the endpoint subscribed to.
| Event | Sent when |
|---|---|
job.completed | A run succeeded. The data is the run, as GET /v1/jobs/{jobId} returns it. |
job.failed | A run failed. The error field holds the code. |
document.created | A document was saved to the library. |
document.deleted | A document was deleted. |
signature.request_sent | A request was sent and the first signers were asked. The data is the signature request. See Send a document for signature. |
signature.viewed | A signer opened the request, the first time. The data is the signature request. See Send a document for signature. |
signature.signed | A signer signed. The data is the signature request. See Send a document for signature. |
signature.declined | A signer declined. The request is closed and the reason is in the data. The data is the signature request. See Send a document for signature. |
signature.completed | Every signer signed and the signed document and the certificate are ready. The data is the signature request. See Send a document for signature. |
signature.voided | The request was voided. The reason is in the data. The data is the signature request. See Send a document for signature. |
signature.expired | The request passed its expiry before every signer signed. The data is the signature request. See Send a document for signature. |
{
"id": "evt_Xk2mQ9rT4vLw8aBc1dEf",
"type": "job.completed",
"createdAt": "2026-11-04T10:00:00.000Z",
"data": { "id": "cm3x8k2f10001", "tool": "protect", "status": "succeeded", "results": 1 }
}Events never carry file names, storage keys or download links. Fetch the run or the document to get them.
Headers
| Header | Meaning |
|---|---|
PDFLoft-Event-Id | The event id. It is the same on every attempt and every replay of the event. |
PDFLoft-Timestamp | Seconds since 1970 when this attempt was signed. |
PDFLoft-Signature | v1= followed by the HMAC-SHA256 signature in hexadecimal. |
PDFLoft-Delivery-Attempt | Which attempt this is, from 1. |
PDFLoft-Replay | true when the event was sent again by hand from the delivery log. |
Verify the signature
The signature is the HMAC-SHA256 of the timestamp, a dot and the exact body, keyed with the signing secret of the endpoint (whsec_..., shown once when the endpoint is made). Verify it on the raw body before you trust the event, compare in constant time, and refuse a timestamp more than five minutes from your clock.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string or Buffer), not parsed and printed again.
export function verifyPdfLoftWebhook(rawBody, headers, secret) {
const timestamp = headers['pdfloft-timestamp'];
const signature = headers['pdfloft-signature'] ?? '';
if (!/^[0-9]+$/.test(timestamp ?? '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // refuse a stale delivery
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify_pdfloft_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
timestamp = headers.get("pdfloft-timestamp", "")
signature = headers.get("pdfloft-signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False # refuse a missing or stale timestamp
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)# Compute the signature of a saved delivery to compare it with the PDFLoft-Signature header.
# body.json holds the exact body; TIMESTAMP is the PDFLoft-Timestamp header.
printf '%s.%s' "$TIMESTAMP" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$PDFLOFT_WEBHOOK_SECRET" -hex \
| sed 's/^.* /v1=/'Delivery, retries and replay
- Answer with any
2xxstatus within 10 seconds. Any other answer, a timeout or a connection failure is a failure. Redirects are not followed. - A failed delivery is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours: 8 attempts over about 21 hours. Then it is marked failed.
- An endpoint that fails 20 attempts in a row, with no success between, is switched off, and the owners can switch it on again in Settings.
- Delivery is at least once and not in order. Drop an event you have already handled by its
id, and order events bycreatedAt. - The delivery log in Settings shows every attempt, and a delivery can be replayed. A replay sends the same event id and sets the replay header.
- Addresses that resolve to private, loopback, link-local or metadata addresses are refused when the endpoint is saved and again before every delivery.