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.

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 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. Both extraction and thumbnail processing use the normal document lifecycle events. Branch on data.document.processing_mode before deciding which completed result to read.

EventWhen it is sentTypical use
document.queuedA document was accepted and its requested work was queued.Mark a local upload as accepted.
document.processingThe document entered a processing stage such as thumbnailing, analysis, or extraction.Update UI state without polling.
document.completedThe requested processing finished successfully.Read extraction fields for extraction mode, or download the thumbnail for thumbnail mode.
document.failedThe requested processing failed, including a thumbnail generation or video decoding failure.Inspect processing_error and open a retry or support path.
document.blockedExtraction 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.testManual 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.

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",
      "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"
    }
  }
}
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"
    }
  }
}
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, metadata, and completed results. Every document field is defined in Response objects.
data.document.extractionsObject, empty array, or nullNormalized 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_runExtraction run or nullLatest 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

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. Long-running work should be queued 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 reading extraction fields and downloading the thumbnail.