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 | Public HTTPS URL in your system that accepts webhook POST requests. URLs with credentials and destinations resolving to private or special-use addresses are rejected. |
| 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. |
| Delivery guarantee | At least once. The same delivery ID can be sent more than once if delivery was accepted but acknowledgement could not be committed. |
| Retries | Failed or ambiguous deliveries are retried with the same delivery ID. A manual replay creates a new delivery ID and timestamp. |
Signature headers
Verify X-Signature and the timestamp 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 | Signed event type, such as document.completed. |
X-Delivery | UUID string | Signed delivery ID. Use this for receiver idempotency. |
X-Timestamp | Unix timestamp | Signed delivery timestamp. Reject requests outside a short tolerance window; the examples use five minutes. |
X-Signature | String | Lowercase 64-character HMAC-SHA256 signature that authenticates the timestamp, delivery ID, event, and exact request body. |
User-Agent | String | Webhook client user agent, currently exdata-webhooks/1.0. |
Build the signed content as four UTF-8 parts separated by a single newline, with no trailing newline:
{X-Timestamp}
{X-Delivery}
{X-Event}
{exact raw request body}
Compute HMAC-SHA256 with the endpoint secret and hex-encode the digest as 64 lowercase characters. Compare the result to X-Signature. Use the request body bytes exactly as received; parsing and re-serializing JSON can change those bytes and invalidate the signature.
Signature test vector
Use this fake request as an offline HMAC calculation check to confirm that your implementation builds and signs the content correctly. Its timestamp is intentionally fixed, so a live receiver must reject it as stale. Do not add a trailing newline to the signed content.
| Input | Value |
|---|---|
| Secret | whsec_test_vector |
X-Timestamp | 1770000000 |
X-Delivery | 1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071 |
X-Event | document.completed |
| Exact raw body | {"id":"1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071"} |
1770000000
1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071
document.completed
{"id":"1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071"}
34183dde0ec71cae737c5dab81d6a407ad584ad87b86c03772bc448fc53da893
If your result matches the expected signature, your field order, newline handling, raw-body handling, and hexadecimal encoding match the webhook protocol.
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 event = req.header("X-Event") ?? "";
const delivery = req.header("X-Delivery") ?? "";
const timestamp = req.header("X-Timestamp") ?? "";
const signature = req.header("X-Signature") ?? "";
const issuedAt = Number(timestamp);
if (!Number.isSafeInteger(issuedAt) || Math.abs(Date.now() / 1000 - issuedAt) > 300) {
return res.sendStatus(401);
}
const signed = [timestamp, delivery, event, payload].join("\n");
const expected = crypto
.createHmac("sha256", process.env.EXDATA_WEBHOOK_SECRET)
.update(signed)
.digest("hex");
const valid = /^[a-f0-9]{64}$/.test(signature)
&& signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.sendStatus(401);
}
let body;
try {
body = JSON.parse(payload);
} catch {
return res.sendStatus(400);
}
if (body.id !== delivery || body.type !== event) {
return res.sendStatus(400);
}
// Insert delivery into a table with a UNIQUE constraint. If it already
// exists, acknowledge the retry without repeating downstream side effects.
return res.status(202).json({ accepted: true, event, delivery });
});
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/exdata")
def exdata_webhook():
payload = request.get_data()
event = request.headers.get("X-Event", "")
delivery = request.headers.get("X-Delivery", "")
timestamp = request.headers.get("X-Timestamp", "")
signature = request.headers.get("X-Signature", "")
try:
issued_at = int(timestamp)
except ValueError:
abort(401)
if abs(time.time() - issued_at) > 300:
abort(401)
signed = b"\n".join([
timestamp.encode(),
delivery.encode(),
event.encode(),
payload,
])
expected = hmac.new(
os.environ["EXDATA_WEBHOOK_SECRET"].encode(),
signed,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
body = request.get_json(silent=True)
if not isinstance(body, dict) or body.get("id") != delivery or body.get("type") != event:
abort(400)
# Insert delivery with a UNIQUE constraint before starting async work.
return {"accepted": True, "event": event, "delivery": delivery}, 202
$payload = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$event = $_SERVER['HTTP_X_EVENT'] ?? '';
$delivery = $_SERVER['HTTP_X_DELIVERY'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Stale timestamp');
}
$signed = implode("\n", [$timestamp, $delivery, $event, $payload]);
$expected = hash_hmac('sha256', $signed, $webhookSecret);
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$body = json_decode($payload, true);
if (! is_array($body)
|| ($body['id'] ?? null) !== $delivery
|| ($body['type'] ?? null) !== $event) {
http_response_code(400);
exit('Header and payload mismatch');
}
// Insert $delivery with a UNIQUE constraint before starting async work.
http_response_code(202);
Event types
Subscribe only to the events your integration needs. Both extraction and thumbnail processing use the normal document lifecycle events. Branch on data.document.processing_mode before deciding which completed result to read.
| Event | When it is sent | Typical use |
|---|---|---|
document.queued | A document was accepted and its requested work was queued. | Mark a local upload as accepted. |
document.processing | The document entered a processing stage such as thumbnailing, analysis, or extraction. | Update UI state without polling. |
document.completed | The requested processing finished successfully. | Read extraction fields for extraction mode, or download the thumbnail for thumbnail mode. |
document.failed | The requested processing failed, including a thumbnail generation or video decoding failure. | Inspect processing_error and open a retry or support path. |
document.blocked | Extraction was intentionally not queued, commonly because the account lacked credits. Thumbnail mode does not use extraction credit blocking. | Notify operators and resolve account state before retrying extraction. |
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",
"processing_mode": "extraction",
"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-07-28.1",
"extractor_version": "document:2026-07-28.1",
"ai_prompt_version": "document-ai:2026-07-28.1",
"normalization_version": "base:2026-07-28.1",
"quality": {
"status": "normal",
"degraded": false,
"reasons": [],
"ai": {
"status": "completed",
"model": "gpt-5.4-mini-2026-03-17",
"reasoning_effort": "low",
"service_tier": "default",
"max_output_tokens": 8192,
"input_mode": "attachment",
"text_purpose": null,
"detail": "high",
"preflight_unavailable": false,
"low_detail_document_text_cross_checked": null,
"document_text_truncated": false,
"email_text_truncated": false,
"deterministic_supplementation": false,
"no_usable_text": false,
"conflict_fields": [],
"rejected_fields": []
},
"pdf": {
"page_count": 1,
"page_count_known": true,
"native_text_available": true,
"native_text_truncated": false,
"ocr_attempted_pages": 0,
"ocr_successful_pages": 0,
"ocr_page_limit_reached": false,
"ocr_page_limit": 200,
"pages_beyond_ocr_limit": 0,
"failed_pages": [],
"skipped_pages": [],
"empty_pages": [],
"blank_pages": [],
"conflict_pages": [],
"unreliable_pages": [],
"limited_pages": []
},
"email": null,
"structured": null
},
"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": "1d9d0f2b-4eb8-4c94-a636-7a71f8b6a072",
"type": "document.completed",
"created_at": "2026-05-10T01:02:09.000000Z",
"data": {
"document": {
"id": 124,
"mode": "live",
"processing_mode": "thumbnail",
"status": "completed",
"processing_stage": "completed",
"processing_error": null,
"filename": "inspection-video.mp4",
"file_format": "mp4",
"ai_processing": false,
"thumbnail": "https://www.exdata.app/api/v1/documents/124/thumbnail",
"previews": [],
"extractions": [],
"latest_extraction_run": null,
"created_at": "2026-05-10T01:02:00.000000Z",
"updated_at": "2026-05-10T01:02:08.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 processing_mode, metadata, and completed results. Every document field is defined in Response objects. |
data.document.extractions | Object, empty array, or null | Normalized extraction fields for completed extraction-mode documents. Completed thumbnail-only documents return an empty array. Every extraction key is defined in Extraction fields. |
data.document.latest_extraction_run | Extraction run or null | Latest extraction run metadata, including version fields, failure/blocking details, AI execution settings, and quality degradation signals. The nested quality field can be null when no AI, PDF, email-attachment, or structured-invoice quality metadata applies. For EML and MSG files, email quality reports bounded parsing and single-primary-attachment selection, including skipped, failed, or multiple candidates. Structured quality reports parser/profile support, arithmetic validation, source linkage, and critical conflict field names without exposing duplicate values. A normal quality status means no known degradation was recorded, not guaranteed correctness. The extraction run is always null for thumbnail mode. |
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. - Any non-
2xxresponse or timeout is treated as a failed delivery and may be retried. Use4xxand5xxaccurately for diagnostics, but do not rely on4xxto suppress retries. - Reject requests with a missing or invalid
X-Timestamp, and use a short clock-skew tolerance such as five minutes. - Deduplicate by
X-Deliveryor the payloadid. Enforce a unique database constraint and record the ID before starting side effects. - Use
modeondata.documentto keep test documents out of production automation. - Use
processing_modeto choose between reading extraction fields and downloading the thumbnail.