Webhooks
Get called when a pipeline stage starts or finishes instead of polling. Every delivery is signed with HMAC-SHA256 over the raw body and retried with exponential backoff.
How a webhook fires
A webhook is a workspace-level endpoint definition. It receives events in two ways:
- Stage bindings. A pipeline stage can name an entry webhook, called when the stage starts (
<stage>.entered), and an exit webhook, called when it finishes (<stage>.exited). In Studio these are set in the Webhooks section of a stage inspector. - Event subscriptions. A webhook's
eventslist opts it in to the job events in the catalogue below. These go only to webhooks that the job's pipeline references in a stage binding; a webhook with noeventsreceives its stage-binding events only.
A job captures its pipeline's webhooks when it is submitted, so changing a binding affects jobs submitted afterwards.
Create a webhook
webhook:writeWebhooks can also be created in Studio under Settings → Integrations → Webhooks.
| Parameter | Type | Description |
|---|---|---|
name (required) | string | Label shown in the webhook list and the stage picker. Unique within the workspace — a duplicate returns 409 WEBHOOK_NAME_CONFLICT. |
url (required) | string | HTTPS endpoint. Private, loopback and link-local addresses are rejected. |
auth_type (required) | string | How IDPForge authenticates to your endpoint: NONE, API_KEY or BASIC. |
auth | object | For API_KEY: api_key, plus header_name (default X-API-Key). For BASIC: username and password. |
events | string[] | Job events to receive in addition to stage bindings, for example ["job.failed"]. |
headers | object | Static headers sent with every delivery. Names starting with X-IDPForge- are reserved and rejected. |
description | string | Free text. |
The 201 Created response includes hmac_secret, the signing secret for this webhook. It is returned once; later reads show ***. Store it with the rest of your secrets. POST /v1/webhooks/{id}/rotate issues a new secret, also returned once.
curl -X POST https://api.us.idpforge.cloud/v1/webhooks \
-H "X-API-Key: $IDP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "AP team — pipeline events",
"url": "https://hooks.acme.com/ap-events",
"auth_type": "API_KEY",
"auth": { "header_name": "X-API-Key", "api_key": "..." },
"events": ["job.failed", "job.review_required"]
}'Event catalogue
Stage events
Delivered to the webhook bound to that stage's entry or exit.
| Event | Fires when |
|---|---|
source.exited | The file is validated and stored, and the job is queued. |
initialization.entered / initialization.exited | Initialization — validation, DPI normalisation, rasterisation — starts / finishes. |
parse.entered / parse.exited | Parse starts / finishes for a document. |
split_classify.entered / split_classify.exited | Split and classify starts / finishes for a document; on exit, child documents exist and are routed. |
extract.entered / extract.exited | Extraction starts / finishes for a document. |
post_processing.entered / post_processing.exited | Post-processing starts / finishes. Fires only when the stage is enabled. |
destination.entered / destination.exited | Result delivery starts / completes. destination.exited also goes to webhooks that list it in events, so it works as a job-complete signal. |
Parse, split and classify, extract and post-processing fire once per document and set document_id. Studio offers entry and exit bindings on the initialization, parse, split_classify, extract and post_processing stages. A binding for source.entered or the edit stage is accepted but never fires.
Job events
Delivered to webhooks that list the event in events.
| Event | Fires when |
|---|---|
job.failed | The job reached failed. |
job.completed | The job finished as completed_with_errors — at least one stage failed, but a partial result exists. A clean finish is signalled by destination.exited. |
job.review_required | A document in the job was parked for human review. |
job.blocked | A failure that needs an operator parked the job. |
error_group.opened | A blocked job started a new error group in the error monitor. |
error_group.escalated | Automatic retries for an error group ran out and the group became blocked. |
error_group.resolved | A re-run resolved every job in an error group. |
Job events carry an empty data object — read the job with GET /v1/jobs/{job_id} for the details.
Payload
Each delivery is a POST with a JSON body. Fields with no value are omitted.
| Field | Type | Description |
|---|---|---|
id | string | Delivery id — the same value as the X-IDPForge-Delivery-Id header. Use it to deduplicate. |
type | string | Event type, for example extract.exited. |
timestamp | integer | When the event occurred, in Unix epoch seconds (UTC). |
job_id | string | The job. Always present. |
document_id | string | The document, on per-document stage events. |
stage | string | The stage, on stage events. |
transition | string | entered or exited, on stage events. |
result_url | string | Where to fetch the stage's output, on *.exited events once output exists. |
data | object | On *.exited events, data.job is a snapshot of the job in the GET /v1/jobs/{job_id} shape, without result. Otherwise empty. |
The data.job snapshot above is abridged.
{
"id": "0191f3a4-1b2c-7d3e-8f40-5a6b7c8d9e0f",
"type": "extract.exited",
"timestamp": 1789107238,
"job_id": "0191f3a2-7c4e-7b1d-9a55-3e0c8f2d6b41",
"document_id": "0191f3a3-9e8d-7c6b-a5f4-3e2d1c0b9a87",
"stage": "extract",
"transition": "exited",
"data": {
"job": {
"job_id": "0191f3a2-7c4e-7b1d-9a55-3e0c8f2d6b41",
"status": "processing",
"execution_mode": "async",
"priority": "standard"
}
}
}Verifying the signature
Every delivery carries these headers, plus the credential for your auth_type and any static headers:
| Header | Value |
|---|---|
X-IDPForge-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's signing secret. |
X-IDPForge-Delivery-Id | The delivery id, equal to the payload's id. Unchanged across retries. |
User-Agent | IDPForge-Webhook/1.0 |
The signature covers the raw body only — there is no timestamp header and nothing else is signed. Use the signing secret exactly as it was returned; do not base64-decode it. Compute the HMAC over the bytes you received and compare in constant time.
Parse the JSON after verifying. Re-serializing before you check the signature will fail on key order and whitespace.
The body's timestamp is covered by the signature, but a retry resends the original body, so a late retry carries the original time. Deduplicate on the delivery id rather than rejecting deliveries by age.
import hashlib
import hmac
def verify(secret: str, signature_header: str | None, raw_body: bytes) -> bool:
if not signature_header:
return False
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret: string, signatureHeader: string | null, rawBody: Buffer): boolean {
if (!signatureHeader) return false;
const expected = Buffer.from(
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"),
);
const received = Buffer.from(signatureHeader);
return received.length === expected.length && timingSafeEqual(expected, received);
}Retries
A delivery succeeds on any 2xx response. Anything else is retried: 3xx (redirects are not followed), 4xx, 5xx, a connection failure, or no response within 30 seconds (10 seconds to connect).
A delivery gets up to 10 attempts. The wait before each retry doubles, from 4 seconds to about 17 minutes, so a delivery that keeps failing is abandoned roughly 35 minutes after the first attempt. Every retry resends the same body, signature and delivery id. Return 2xx as soon as you have stored the event, and do the slow work afterwards.