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.

SettingDescription
Endpoint URLPublic 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 secretReveal-once secret generated when the endpoint is created. Store it in your receiver configuration.
Subscribed eventsDocument lifecycle and accepted profile-handoff events selected for the endpoint.
Delivery IDUnique per delivery and replay. Use it for receiver idempotency.
Delivery guaranteeAt least once. The same delivery ID can be sent more than once if delivery was accepted but acknowledgement could not be committed.
RetriesFailed 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.

HeaderTypeDescription
X-EventStringSigned event type, such as document.completed.
X-DeliveryUUID stringSigned delivery ID. Use this for receiver idempotency.
X-TimestampUnix timestampSigned delivery timestamp. Reject requests outside a short tolerance window; the examples use five minutes.
X-SignatureStringLowercase 64-character HMAC-SHA256 signature that authenticates the timestamp, delivery ID, event, and exact request body.
User-AgentStringWebhook 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:

Signed content
{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.

InputValue
Secretwhsec_test_vector
X-Timestamp1770000000
X-Delivery1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071
X-Eventdocument.completed
Exact raw body{"id":"1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071"}
Test vector signed content
1770000000
1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071
document.completed
{"id":"1d9d0f2b-4eb8-4c94-a636-7a71f8b6a071"}
Expected X-Signature
34183dde0ec71cae737c5dab81d6a407ad584ad87b86c03772bc448fc53da893

If your result matches the expected signature, your field order, newline handling, raw-body handling, and hexadecimal encoding match the webhook protocol.

Verify in Node.js
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 });
});

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.

EventWhen it is sentTypical use
document.queuedA document was accepted for processing.Mark a local upload as accepted.
document.processingThe document entered a processing stage such as thumbnailing, analysis, validation, or extraction.Update UI state without polling.
document.completedThe 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.failedThe 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.blockedExtraction 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.acceptedA 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.testManual 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.

document.completed
{
  "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"
    }
  }
}
document.completed: thumbnail mode
{
  "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.

extraction.handoff.accepted — focused excerpt
{
  "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"
    }
  }
}
webhook.test
{
  "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

FieldTypeDescription
idUUID stringDelivery ID. Same value as X-Delivery.
typeStringEvent type. Same value as X-Event.
created_atDate-time stringTime the payload was created for this delivery or replay.
dataObjectEvent-specific payload data.

Document event data

FieldTypeDescription
data.documentDocument objectCurrent document state, including processing_mode, processing_lane, metadata, and completed results. Every document field is defined in Response objects.
data.document.is_e_invoiceBooleanTrue only when a supported structured e-invoice was accepted after validation. Recognition alone does not set this flag.
data.document.extractionsObject, empty array, or nullNormalized 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_runExtraction run or nullLatest 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.

FieldTypeDescription
data.profile_result.idIntegerAccepted profile result ID.
account_id, document_id, extraction_run_idIntegerDurable same-account identity of the accepted result and exact run.
profile, profile_version, schema_versionStringVersioned profile contract for interpreting the payload.
machine_outcomeStringImmutable machine result before any review.
effective_outcome, handoff_statusacceptedBoth are always accepted for this event.
acceptance_sourcemachine or human_reviewHow the terminal accepted state was reached.
reviewed_atDate-time or nullLatest review time; null for machine acceptance.
lock_version, etagInteger and stringRecorded optimistic-concurrency identity at acceptance.
review_reasons, handoff_review_reasons, warningsArrayFinal profile validation reasons and warnings.
effective_payloadProfile-defined objectAccepted normalized payload for downstream automation.
validation_summaryProfile-defined objectFinal validation state used for acceptance.
source_components, evidenceArrayRecorded provenance retained with the accepted result.
created_at, updated_atDate-time or nullProfile-result timestamps captured in the snapshot.

Test event data

FieldTypeDescription
data.testBooleanAlways true for manual test deliveries.
data.account_idIntegerAccount ID that owns the webhook endpoint.
data.endpoint.idIntegerWebhook endpoint ID.
data.endpoint.nameStringWebhook endpoint name from the app.
data.triggered_by_user_idInteger or nullUser 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 2xx response when the delivery has been accepted.
  • Any non-2xx response or timeout is treated as a failed delivery and may be retried. Use 4xx and 5xx accurately for diagnostics, but do not rely on 4xx to 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-Delivery or the payload id. Enforce a unique database constraint and record the ID before starting side effects.
  • Use mode on data.document to keep test documents out of production automation.
  • Use processing_mode to choose between extraction fields, analysis output, quality.structured validation, and the thumbnail.
  • Branch on type: document lifecycle events carry data.document, while extraction.handoff.accepted carries data.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.