Developers
Webhook Events and Payload Reference
Subscribe to events, validate HMAC signatures, handle retries and dead-letters.
Webhook Events and Payload Reference
Webhooks push events to your endpoint when documents are uploaded, workflows complete, or other things happen. Better than polling.
Subscribe
Create a webhook subscription under Admin → Webhooks → New. Specify:
- A URL to receive events
- A set of event types to subscribe to
- A signing secret (auto-generated, shown once)
- Optional filters (e.g., only events on documents tagged
invoice)
Event types
| Event | When fired |
|---|---|
document.uploaded |
A document is uploaded |
document.classified |
AI classification completes |
document.extracted |
Field extraction completes |
document.updated |
Document metadata changed |
document.deleted |
Document soft-deleted |
document.shared.external |
An external share link is created |
workflow.started |
A workflow instance starts |
workflow.task.assigned |
A task assigned to a user |
workflow.task.completed |
A task is approved / rejected |
workflow.completed |
A workflow instance completes |
signature.requested |
A signature request is sent |
signature.signed |
A signature is captured |
signature.completed |
All signatures collected |
user.created |
A new user is added |
user.removed |
A user is removed |
Payload structure
{
"id": "evt_2c8f...",
"type": "document.uploaded",
"createdAt": "2026-05-13T14:32:11Z",
"tenantId": "11111111-1111-1111-1111-111111111111",
"tenantSlug": "yourtenant",
"data": {
"documentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"title": "invoice.pdf",
"uploadedBy": "user_abc123",
"folderId": "...",
"size": 245680,
"classification": null,
"status": "Processing"
}
}
Signature verification
Every webhook delivery includes an X-Papyrus-Signature header:
X-Papyrus-Signature: t=1715608331,v1=a3f8b2c4d5e6...
Verify with HMAC-SHA256 over t=<timestamp>.<body>:
import hmac
import hashlib
def verify(signature_header, body_bytes, secret):
parts = dict(p.split('=', 1) for p in signature_header.split(','))
t = parts['t']
expected = parts['v1']
signed = f"{t}.{body_bytes.decode()}"
computed = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, expected)
Reject anything older than 5 minutes (replay protection).
Retry behaviour
Failed deliveries (non-2xx response) retry with exponential backoff:
- Attempt 1: immediate
- Attempt 2: 30 seconds
- Attempt 3: 2 minutes
- Attempt 4: 10 minutes
- Attempt 5: 1 hour
- Attempt 6: 4 hours
- Attempt 7: 12 hours
- Attempt 8: 24 hours (final)
After 8 failed attempts, the event goes to the dead-letter queue, visible at Admin → Webhooks → Failed Deliveries. You can manually retry from there.
Best practices
- Return 200 quickly — process asynchronously
- Validate the signature before parsing the body
- Be idempotent — the same event might arrive twice if your handler crashes mid-processing
- Subscribe only to events you actually handle
- Use filters to reduce traffic