API documentation
Webhooks
Receive signed document lifecycle events from exdata.
Webhook delivery
Configure webhook endpoints in the app to receive document lifecycle events without polling. exdata sends POST requests with JSON payloads to each active endpoint that subscribes to the event.
| Setting | Description |
|---|---|
| Endpoint URL | HTTPS URL in your system that accepts webhook POST requests. |
| Signing secret | Reveal-once secret generated when the endpoint is created. Store it in your receiver configuration. |
| Subscribed events | Document lifecycle events selected for the endpoint. |
| Delivery ID | Unique per delivery and replay. Use it for receiver idempotency. |
| Retries | Failed deliveries are retried by the webhook queue. A replay creates a new delivery ID and timestamp. |
Signature headers
Verify the signature before trusting the payload. Store delivery IDs you have processed so retries do not run the same automation twice.
| Header | Type | Description |
|---|---|---|
X-Event | String | Event type, such as document.completed. |
X-Delivery | UUID string | Unique delivery ID. Use this for receiver idempotency. |
X-Signature | String | HMAC-SHA256 signature generated from the JSON payload and endpoint secret. |
X-Timestamp | Unix timestamp | Delivery timestamp. Reject stale deliveries according to your own tolerance window. |
User-Agent | String | Webhook client user agent, currently exdata-webhooks/1.0. |
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/exdata", express.raw({ type: "application/json" }), (req, res) => {
const payload = req.body.toString("utf8");
const signature = req.header("X-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.EXDATA_WEBHOOK_SECRET)
.update(payload)
.digest("hex");
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.sendStatus(401);
}
const event = req.header("X-Event");
const delivery = req.header("X-Delivery");
const body = JSON.parse(payload);
// Persist delivery before starting async work.
return res.status(202).json({ accepted: true, event, delivery });
});
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/exdata")
def exdata_webhook():
payload = request.get_data()
signature = request.headers.get("X-Signature", "")
expected = hmac.new(
os.environ["EXDATA_WEBHOOK_SECRET"].encode(),
payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.headers.get("X-Event")
delivery = request.headers.get("X-Delivery")
body = request.get_json()
# Persist delivery before starting async work.
return {"accepted": True, "event": event, "delivery": delivery}, 202
$payload = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, $webhookSecret);
if (! hash_equals($expected, $signature)) {
http_response_code(400);
exit('Invalid signature');
}
$event = $_SERVER['HTTP_X_EVENT'] ?? '';
$delivery = $_SERVER['HTTP_X_DELIVERY'] ?? '';
$body = json_decode($payload, true);
// Persist $delivery before starting async work.
http_response_code(202);
Event types
Subscribe only to the events your integration needs. Many integrations use document.completed for automation and keep failure or blocked events for support workflows.
| Event | When it is sent | Typical use |
|---|---|---|
document.queued | A document was accepted and extraction work was queued. | Mark a local upload as accepted. |
document.processing | The document entered a processing stage such as analysis or extraction. | Update UI state without polling. |
document.completed | Processing finished successfully. | Read extraction fields and trigger downstream mapping. |
document.failed | Processing failed. | Open a support task or move the document to manual review. |
document.blocked | Processing was intentionally not queued, commonly because the account lacked credits. | Notify operators and resolve account state before retrying. |
webhook.test | Manual test delivery sent from the app. | Validate receiver URL, signature verification, and idempotency storage. |
Payload shape
Document events include the current document resource under data.document. The shape matches the document response object from the endpoint reference.
{
"id": "1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071",
"type": "document.completed",
"created_at": "2026-05-10T01:00:19.000000Z",
"data": {
"document": {
"id": 123,
"mode": "live",
"status": "completed",
"processing_stage": "completed",
"processing_error": null,
"blocked_reason": null,
"scanner_status": "clean",
"scanner_provider": "local_noop",
"scanner_message": null,
"scanned_at": "2026-05-10T01:00:02.000000Z",
"processing_started_at": "2026-05-10T01:00:04.000000Z",
"processed_at": "2026-05-10T01:00:18.000000Z",
"filename": "invoice-re-2026-1048.pdf",
"file_format": "pdf",
"file_size": 240123,
"additional_text": null,
"additional_text_plain": null,
"custom_types": ["invoice"],
"requester": "accounts-payable",
"locale": "en",
"number_of_pages": 1,
"extracted_text": "Invoice RE-2026-1048...",
"extracted_text_plain": "Invoice RE-2026-1048...",
"origin": "api",
"ai_processing": true,
"is_e_invoice": false,
"thumbnail": "https://www.exdata.app/api/v1/documents/123/thumbnail",
"previews": [
{
"id": 987,
"filename": "page-1.png",
"file_format": "png",
"file_size": 94812,
"preview": "https://www.exdata.app/api/v1/previews/987"
}
],
"extractions": {
"document_number": {
"value": "RE-2026-1048",
"candidates": ["RE-2026-1048"]
},
"gross_amount": {
"value": "1079.50",
"candidates": ["Amount due EUR 1,079.50"]
}
},
"latest_extraction_run": {
"id": 456,
"mode": "live",
"source": "api",
"status": "completed",
"blocked_reason": null,
"error_code": null,
"error_message": null,
"extraction_schema_version": "2026-05-17",
"extractor_version": "document:2026-05-17",
"ai_prompt_version": "document-ai:2026-05-17",
"normalization_version": "base:2026-05-10",
"started_at": "2026-05-10T01:00:04.000000Z",
"completed_at": "2026-05-10T01:00:18.000000Z",
"created_at": "2026-05-10T01:00:03.000000Z"
},
"created_at": "2026-05-10T01:00:00.000000Z",
"updated_at": "2026-05-10T01:00:18.000000Z"
}
}
}
{
"id": "5fb23752-663d-47a2-9f0d-7fd4b5b4c9bd",
"type": "webhook.test",
"created_at": "2026-05-10T01:05:00.000000Z",
"data": {
"test": true,
"account_id": 42,
"endpoint": {
"id": 9,
"name": "Production receiver"
},
"triggered_by_user_id": 17
}
}
Payload fields
Use these tables as the receiver contract. The nested document, preview, extraction, and extraction-run objects are the same objects returned by the API endpoints.
Top-level fields
| Field | Type | Description |
|---|---|---|
id | UUID string | Delivery ID. Same value as X-Delivery. |
type | String | Event type. Same value as X-Event. |
created_at | Date-time string | Time the payload was created for this delivery or replay. |
data | Object | Event-specific payload data. |
Document event data
| Field | Type | Description |
|---|---|---|
data.document | Document object | Current document state, including metadata, previews, extraction fields when completed, and latest_extraction_run. Every document field is defined in Response objects. |
data.document.extractions | Object or null | Normalized extraction fields for completed documents. Every field key is defined in Extraction fields. |
data.document.latest_extraction_run | Extraction run or null | Latest extraction run metadata, including version fields and failure/blocking details. |
Test event data
| Field | Type | Description |
|---|---|---|
data.test | Boolean | Always true for manual test deliveries. |
data.account_id | Integer | Account ID that owns the webhook endpoint. |
data.endpoint.id | Integer | Webhook endpoint ID. |
data.endpoint.name | String | Webhook endpoint name from the app. |
data.triggered_by_user_id | Integer or null | User ID that sent the test delivery. |
Receiver behavior
Your receiver should acknowledge only after it has verified the signature and safely recorded the delivery ID. Long-running work should be queued in your own system.
- Return any
2xxresponse when the delivery has been accepted. - Return
4xxfor permanent receiver-side rejection, such as an invalid signature. - Return
5xxor time out only when exdata should retry delivery. - Deduplicate by
X-Deliveryor the payloadid. - Use
modeondata.documentto keep test documents out of production automation.