Docsfra
API Documentation
v1

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_url field in the POST /v1/documents or POST /v1/batches body (see Upload). Different uploads from the same tenant can go to different URLs.
  • If callback_url is 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.completedresult populated, error: null
  • document.failedresult: null, error populated

Payload fields are derived from the same source as GET /v1/jobs/{id}:

  • id, batch_id, external_item_ref, external_batch_ref
  • status, page_count
  • partial — if true, at least one page could not be processed by all engines, but the document is still considered completed (partial result, see Polling).
  • result.markdown_url, result.json_url — populated only when completed. 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 with GET /v1/jobs/{id}.
  • error — populated only when failed.

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_url is left empty in the request, tenant.default_callback_url is used; if that's also absent, the webhook is never sent.