API v1 documentation
Webhooks
Receive signed document lifecycle events from exdata.
Webhook delivery
Configure webhook endpoints in the app to receive document lifecycle and accepted profile-handoff 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 and accepted profile-handoff 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. Extraction, analysis, validation, and thumbnail processing use the normal document lifecycle events. Branch on data.document.processing_mode before deciding which completed result to read. The selected data.document.processing_lane is visible in every document lifecycle payload; batch is available to eligible accounts, uses lower-priority capacity, and keeps the same events and payload shape as standard. If batch is unavailable for the account, the upload returns 422 with code=validation_failed and a processing_lane validation error before lifecycle events begin. Specialized profiles additionally use extraction.handoff.accepted as the downstream automation boundary.
| Event | When it is sent | Typical use |
|---|---|---|
document.queued | A document was accepted for processing. | Mark a local upload as accepted. |
document.processing | The document entered a processing stage such as thumbnailing, analysis, validation, or extraction. | Update UI state without polling. |
document.completed | The requested processing finished successfully. A live extraction, analysis, or validation run reports its final credit_cost × started 10-page bands usage; the generic rate is one. | Read extraction fields for extraction mode, analysis fields for analysis mode, quality.structured for validation mode, or download the thumbnail for thumbnail mode. Record credits_charged for usage reconciliation. |
document.failed | The requested processing failed, including a thumbnail generation or video decoding failure. | A live extraction returns credits only when neither a usable result nor retrievable extracted fields remain. Inspect credits_charged, latest_extraction_run.error_code and processing_error. For unsupported_structured_invoice, inspect quality.structured.validation_errors and any quality.structured.validation_rule_ids, then correct the invalid XML or convert an unknown profile before resubmitting it. Use the documented e-invoice capability list to distinguish invalid XML or unsupported profiles from supported CII, Factur-X/ZUGFeRD, XRechnung, and UBL invoices before choosing correction, conversion, or contacting support. |
document.blocked | Extraction stopped because an account condition was not satisfied. This includes page_band_insufficient_credits discovered asynchronously after 201; credits for that run are returned. Thumbnail mode does not use extraction credit blocking. | Inspect blocked_reason and credits_charged, notify your team, and resolve the account condition before retrying extraction. |
extraction.handoff.accepted | A specialized profile result reaches both effective_outcome=accepted and handoff_status=accepted, either directly by machine validation or through an audited review decision. | Consume the recorded data.profile_result snapshot. The accepted handoff is terminal, so no later review decision can mutate this result. |
webhook.test | Manual test delivery sent from the app. | Validate receiver URL, signature verification, and idempotency storage. |
Payload shape
Document lifecycle events include the current document resource under data.document. The accepted handoff event instead includes a recorded, profile-neutral result under data.profile_result; it is not a new REST resource or endpoint.
{
"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",
"processing_lane": "batch",
"pages": null,
"status": "completed",
"processing_stage": "completed",
"processing_error": null,
"blocked_reason": null,
"scanner_status": "clean",
"scanner_provider": "default",
"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,
"credits_charged": 1,
"extraction_schema_version": "2026-08-28.1",
"extractor_version": "document:2026-08-28.1",
"ai_prompt_version": "document-ai:2026-07-28.1",
"normalization_version": "base:2026-08-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": [],
"recovered_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"
}
}
}
The following accepted-handoff example is a focused excerpt. The live event contains the complete versioned effective_payload and validation snapshot documented for the selected profile; unchanged required payload properties are omitted here only to keep the webhook envelope visible.
{
"id": "1d9d0f2b-4eb8-4c94-a636-7a71f8b6a073",
"type": "extraction.handoff.accepted",
"created_at": "2026-05-10T01:03:11.000000Z",
"data": {
"profile_result": {
"id": 789,
"account_id": 42,
"document_id": 123,
"extraction_run_id": 456,
"profile": "de.energy.supply-invoice",
"profile_version": "2.0.1",
"schema_version": "2.0.0",
"machine_outcome": "needs_review",
"effective_outcome": "accepted",
"handoff_status": "accepted",
"acceptance_source": "human_review",
"reviewed_at": "2026-05-10T01:03:10.000000Z",
"lock_version": 1,
"etag": "\"extraction-result-789-v1\"",
"review_reasons": [],
"handoff_review_reasons": [],
"warnings": [],
"effective_payload": {
"document": { "kind": "annual_invoice", "document_number": "RE-EXAMPLE-2025-10" },
"supply_points": [{ "ref": "supply_point_1", "commodity": "electricity", "measurements": [{ "ref": "measurement_1", "kind": "billed_energy", "quantity": "18432.000", "unit": "kWh" }] }],
"settlements": [{ "ref": "settlement_1", "balance_kind": "amount_due", "balance_amount": "418.27", "payment_due_date": "2025-03-03" }],
"charge_lines": [{ "ref": "charge_line_1", "category": "energy_consumption", "net_amount": "1345.54", "currency": "EUR" }]
},
"validation_summary": { "valid": true, "reviewed": true },
"source_components": [
{ "ref": "primary_invoice", "kind": "primary_invoice", "physical_pages": [1, 2], "precedence": 1 }
],
"evidence": [],
"created_at": "2026-05-10T01:02:58.000000Z",
"updated_at": "2026-05-10T01:03:10.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, processing_lane, metadata, and completed results. Every document field is defined in Response objects. |
data.document.is_e_invoice | Boolean | True only when a supported structured e-invoice was accepted after validation. Recognition alone does not set this flag. |
data.document.extractions | Object, empty array, or null | Normalized extraction fields for completed extraction-mode documents. Completed thumbnail-only, analysis, and validation documents return an empty array. Every extraction key is defined in Extraction fields; each selected value can include winning-value provenance. |
data.document.latest_extraction_run | Extraction run or null | Latest extraction run metadata, including version fields, failure/blocking details, winning-value provenance_summary, AI execution settings, and quality signals. Validation-mode clients read quality.structured.assessment, not the general quality.status, for the objective validation and PDF/XML-linkage result. New structured snapshots also report versioned detection and validation states, exact scope, persisted validator/ruleset metadata, and bounded message-free findings. Technical validator unavailability remains available on a terminal error. The contract makes no legal-validity or complete PDF/A/container-conformance claim, and missing versioned fields on older runs are unknown. The extraction run is always null for thumbnail mode. |
Accepted profile handoff data
extraction.handoff.accepted is persisted only for active same-account endpoints subscribed to that event. The first accepted snapshot is retained at most once per endpoint and extraction run. Delivery retries can send the same recorded body and delivery ID more than once, so normal receiver idempotency still applies.
| Field | Type | Description |
|---|---|---|
data.profile_result.id | Integer | Accepted profile result ID. |
account_id, document_id, extraction_run_id | Integer | Durable same-account identity of the accepted result and exact run. |
profile, profile_version, schema_version | String | Versioned profile contract for interpreting the payload. |
machine_outcome | String | Immutable machine result before any review. |
effective_outcome, handoff_status | accepted | Both are always accepted for this event. |
acceptance_source | machine or human_review | How the terminal accepted state was reached. |
reviewed_at | Date-time or null | Latest review time; null for machine acceptance. |
lock_version, etag | Integer and string | Recorded optimistic-concurrency identity at acceptance. |
review_reasons, handoff_review_reasons, warnings | Array | Final profile validation reasons and warnings. |
effective_payload | Profile-defined object | Accepted normalized payload for downstream automation. |
validation_summary | Profile-defined object | Final validation state used for acceptance. |
source_components, evidence | Array | Recorded provenance retained with the accepted result. |
created_at, updated_at | Date-time or null | Profile-result timestamps captured in the snapshot. |
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. Handle long-running work asynchronously 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 extraction fields, analysis output,quality.structuredvalidation, and the thumbnail. - Branch on
type: document lifecycle events carrydata.document, whileextraction.handoff.acceptedcarriesdata.profile_result. - Treat an accepted handoff as terminal for that extraction run. Store its recorded result identity and use the profile/schema versions before mapping
effective_payload.