Skip to content

Webhooks

Webhooks let your backend receive a small, signed notification when an async extraction job reaches a terminal state, instead of polling for it. A webhook is a notification channel, not a bulk result transport: the body is a compact summary, and you still fetch the full payload from the result endpoint.

For the request, status, and result mechanics, see Asynchronous Extraction. All extraction endpoints authenticate with Authorization: api-key <environment-api-key> (see Authentication & Errors).

V1 supports:

  1. multiple webhook endpoints per environment
  2. two scope types: scoped (selected templates) and global
  3. notifications for terminal states only:
    1. extraction.job.completed
    2. extraction.job.failed
  4. filter-driven delivery, grouped by matched endpoint ownership

V1 does not support:

  1. per-request callback URLs
  2. selecting a webhook endpoint directly in the extraction request
  3. webhook delivery on sync extraction
  4. full extraction payloads inside the webhook body

To receive webhooks, create an async job with deliveryMode=webhook:

{
"templateName": "invoice",
"deliveryMode": "webhook"
}

If deliveryMode is omitted, delivery defaults to poll. Webhook delivery is available only on the canonical async route POST /api/v1/extraction-jobs, not on sync or legacy async extraction.

Webhook endpoints are configured in the product UI, per environment. There is no public API for managing webhook configuration: you create, edit, and validate webhook endpoints in the app, and the app checks that each target URL is valid and reachable when you save it.

To start using webhooks:

  1. Open the app and go to your extraction webhook settings.
  2. Add a webhook endpoint pointing at the receiver URL on your backend.
  3. Choose its scope (see below) and save. The app validates the URL on save.
  4. Securely store the signing secret the app issues for that webhook.
  5. Submit async jobs with deliveryMode=webhook.

Once a webhook is active, delivery works automatically; you do not configure anything per request.

  1. Scope: each webhook is either

    1. scoped: it owns selected templates, or
    2. global: it covers every template not already claimed by a scoped webhook.

    An environment may have many active scoped webhooks and at most one active global webhook.

  2. Signing secret: issued when a webhook is created or rotated. Your receiver uses it to verify authenticity. Store the full secret on the backend only.

  3. Outbound auth (optional): the app can attach an auth header to every delivery (None, Authorization: Bearer <token>, or X-API-Key: <token>).

  4. Subscribed events: extraction.job.completed and extraction.job.failed.

You do not send a webhookId in extraction requests. Routing is derived from template ownership.

For templateName requests:

  1. the API resolves the requested templates first
  2. if a requested template does not exist, the request fails as a template error
  3. a scoped webhook that owns the template wins
  4. otherwise the active global webhook is used if one exists
  5. if no route matches, or one request would resolve to more than one endpoint, job creation fails

For filterName requests:

  1. admission validates that all extractable templates in the filter are covered by active webhook configuration
  2. completed deliveries are grouped by matched endpoint ownership, so one filter job may emit one completed webhook per matched endpoint
  3. other stays in the parent result and does not emit a webhook in V1
{
"id": "delivery_id",
"type": "extraction.job.completed",
"createdAt": "2026-03-16T09:23:09.152Z",
"job": {
"id": "job_id",
"status": "completed",
"resultUrl": "https://api.example.com/api/v1/extraction-jobs/job_id/result"
}
}

For filter-driven jobs, a completed payload may also include grouped document summaries for the endpoint:

{
"routing": {
"webhookId": "webhook_id",
"scopeType": "scoped",
"templates": [
{ "id": "template_invoice", "name": "invoice" },
{ "id": "template_bank_statement", "name": "bank statement" }
]
},
"documents": [
{
"documentName": "Invoice",
"templateId": "template_invoice",
"templateName": "invoice",
"sourcePages": [1]
},
{
"documentName": "Bank Statement",
"templateId": "template_bank_statement",
"templateName": "bank statement",
"sourcePages": [2, 3]
}
]
}
{
"id": "delivery_id",
"type": "extraction.job.failed",
"createdAt": "2026-03-16T09:23:09.152Z",
"job": {
"id": "job_id",
"status": "failed",
"statusUrl": "https://api.example.com/api/v1/extraction-jobs/job_id"
}
}

Clients should expect these headers:

  1. X-Optica-Event
  2. X-Optica-Delivery
  3. X-Optica-Timestamp
  4. X-Optica-Signature
  5. Content-Type: application/json
  6. optional outbound auth header if configured on that webhook endpoint:
    1. Authorization: Bearer <token>
    2. X-API-Key: <token>

Consistency rules:

  1. body id must match X-Optica-Delivery
  2. body type must match X-Optica-Event
  3. body job.id must match the async extraction job id

The signing secret is used by the receiver to verify webhook authenticity.

The signed content is:

${timestamp}.${rawBody}

Verification steps:

  1. capture the raw request body exactly as received
  2. read X-Optica-Timestamp
  3. read X-Optica-Signature
  4. compute HMAC_SHA256(secret, timestamp + "." + rawBody)
  5. compare it to the signature after removing the v1= prefix

Important:

  1. do not parse and re-serialize the JSON before verification
  2. verify against the raw body bytes
  3. store the secret on the backend only

Example local verification command:

Terminal window
printf '%s' "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET"

A production receiver should:

  1. verify the signature first
  2. optionally reject stale timestamps
  3. optionally enforce the configured bearer or API key header at the edge or proxy
  4. dedupe on the webhook delivery id
  5. return 2xx quickly
  6. process asynchronously
  7. for completed, fetch the full extraction result from job.resultUrl

These webhook-specific errors are returned at job creation. For job status and result errors (JOB_NOT_FOUND, RESULT_NOT_READY, REVIEW_REQUIRED, JOB_FAILED, RESULT_EXPIRED, RATE_LIMIT), see Asynchronous Extraction.

  1. 400 INVALID_DELIVERY_MODE deliveryMode was not a supported value
  2. 400 WEBHOOK_ASYNC_ONLY webhook delivery was requested on sync extraction
  3. 400 LEGACY_ASYNC_POLL_ONLY webhook delivery was requested on the legacy async endpoint
  4. 400 FILTER_HAS_NO_EXTRACTABLE_TEMPLATES the filter contains no extractable templates
  5. 400 WEBHOOK_NOT_CONFIGURED no active webhook route exists
  6. 400 WEBHOOK_NOT_CONFIGURED_FOR_TEMPLATE a request template is not covered by a scoped or global webhook
  7. 400 WEBHOOK_TEMPLATE_ROUTING_CONFLICT one request resolved to more than one webhook endpoint
  1. configure scoped webhooks in the app for templates that need dedicated routing
  2. optionally configure one global webhook for all remaining templates
  3. submit async extraction through POST /api/v1/extraction-jobs
  4. use deliveryMode=webhook for event-driven completion
  5. treat the webhook as a signed summary notification
  6. fetch the real payload from the result endpoint
  7. keep polling and status handling as a fallback
  8. when using filterName, expect grouped completed deliveries per matched endpoint

This keeps webhook payloads small, retries cheap, and receiver behavior predictable, with full results fetched only when needed.