Webhook
GET /v1/jobs/{id} polling's push-based alternative: instead of
polling for status continuously, when a document becomes completed or
failed, the backend sends a signed HTTP POST request to the URL you've
configured. Polling always remains operational (it's the reliable
fallback even if the webhook fails, see Polling); the webhook
only reduces latency and unnecessary query traffic.
There is no batch-level aggregate webhook — each document within a batch receives its own notification separately.
How to set the callback URL
The webhook destination isn't a persistent dashboard setting — it's set per request:
- Sent via the optional
callback_urlfield in thePOST /v1/documentsorPOST /v1/batchesbody (see Upload). Different uploads from the same tenant can go to different URLs. - If
callback_urlis left empty in the request, the tenant's default (default_callback_url) is used. - This default and the HMAC signing secret (
webhook_secret) currently cannot be set from a self-service panel — they're defined on the tenant record by the DocsFra operations team; you need to share these with us when starting integration. - If both are empty, the webhook is never sent (no-op) — in that case the client should rely on polling only.
Event types and payload schema
document.completed—resultpopulated,error: nulldocument.failed—result: null,errorpopulated
Payload fields are derived from the same source as GET /v1/jobs/{id}:
id,batch_id,external_item_ref,external_batch_refstatus,page_countpartial— iftrue, at least one page could not be processed by all engines, but the document is still consideredcompleted(partial result, see Polling).result.markdown_url,result.json_url— populated only whencompleted. These are time-limited signed links, generated once when the webhook is sent and embedded in the payload; retry attempts reuse the same URL (it is not re-signed on each attempt), so if you receive a delayed delivery the link may have expired — in that case get a fresh link withGET /v1/jobs/{id}.error— populated only whenfailed.
Sample payload and full schema in the right panel.
Signature verification
Every request carries an X-Signature: sha256=<hex hmac> header. The
value is the HMAC-SHA256 signature of the request body (the raw JSON
body, before parsing) using your tenant's webhook_secret.
On your side, you should perform the same computation over the raw body
bytes and do a constant-time comparison (timingSafeEqual /
hmac.compare_digest) — string equality (===) should not be used since
it's vulnerable to timing attacks. Code examples in the right panel.
Response expectations
- Your endpoint must respond within 10 seconds; exceeding this counts as a timeout and enters the retry queue.
- Only the HTTP status code is checked:
2xx(200-299) is considered successful, body content doesn't matter. Any other code (including 4xx/5xx) and connection errors are considered failures.
Retry policy
If the target returns a non-2xx code or times out, it's retried with
exponential backoff:
1min -> 5min -> 30min -> 2h -> exhausted
For a job that reaches the exhausted state, the client should fall back
to GET /v1/jobs/{id} polling — this endpoint always returns the current
status.
Idempotency
Your webhook endpoint must be idempotent; multiple notifications may
arrive for the same document.id (in retry scenarios, or rarely when the
same attempt is re-queued). Implement a check based on the
document.id + status combination so you can safely ignore duplicate
requests.
Notes
- In the MVP, the payload format is fixed; there is no support for tenant-specific templates.
- If
callback_urlis left empty in the request,tenant.default_callback_urlis used; if that's also absent, the webhook is never sent.