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.

Webhook events
EventSent when
job.completedA run succeeded. The data is the run, as GET /v1/jobs/{jobId} returns it.
job.failedA run failed. The error field holds the code.
document.createdA document was saved to the library.
document.deletedA document was deleted.
signature.request_sentA request was sent and the first signers were asked. The data is the signature request. See Send a document for signature.
signature.viewedA signer opened the request, the first time. The data is the signature request. See Send a document for signature.
signature.signedA signer signed. The data is the signature request. See Send a document for signature.
signature.declinedA 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.completedEvery signer signed and the signed document and the certificate are ready. The data is the signature request. See Send a document for signature.
signature.voidedThe request was voided. The reason is in the data. The data is the signature request. See Send a document for signature.
signature.expiredThe request passed its expiry before every signer signed. The data is the signature request. See Send a document for signature.
JSON
{
  "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

Webhook headers
HeaderMeaning
PDFLoft-Event-IdThe event id. It is the same on every attempt and every replay of the event.
PDFLoft-TimestampSeconds since 1970 when this attempt was signed.
PDFLoft-Signaturev1= followed by the HMAC-SHA256 signature in hexadecimal.
PDFLoft-Delivery-AttemptWhich attempt this is, from 1.
PDFLoft-Replaytrue 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.

JavaScript
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);
}
Python
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)
curl
# 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 2xx status 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 by createdAt.
  • 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.