Webhooks
Signed event delivery for extraction and document lifecycle events.
Webhooks push extraction events to a URL you register instead of making you poll. Register an endpoint with POST /webhook-endpoints, subscribe it to the events you need, and store the signing secret the response returns — it is shown once.
Events
| Event | Subscribable | Fires |
|---|---|---|
extraction.completed | yes | Once, when a session finishes with at least one successfully extracted document. |
extraction.failed | yes | Once, when a session finishes with no successful documents. |
extraction.document.completed | yes | Per document that finished extraction successfully. |
extraction.document.failed | yes | Per document that failed extraction. |
document.uploaded | yes | Per document stored (status "uploaded") — ready to process, not yet parsed. |
document.processed | yes | Per document that finished preprocessing and is ready to extract. |
document.processing_failed | yes | Per document whose preprocessing failed. |
webhook.test | no | On demand, via POST /webhook-endpoints/{id}/test. Not subscribable: any endpoint can receive one. |
Payload
Payloads are deliberately thin: ids and a signed jobId, never state. On receipt, call GET /jobs/{jobId} for the current outcome. Delivery is at-least-once and unordered, so dedup on id and treat the API as the source of truth.
{
"id": "3f2b8c1e-5d4a-4e6b-9c7d-1a2b3c4d5e6f",
"type": "extraction.completed",
"created": "2026-09-11T10:20:00Z",
"data": {
"sessionId": "3f2b8c1e-5d4a-4e6b-9c7d-1a2b3c4d5e6f",
"documentId": "3f2b8c1e-5d4a-4e6b-9c7d-1a2b3c4d5e6f",
"jobId": "v1.<signed job handle>",
"reference": {
"batch": "q3-2026"
}
}
}
| Field | Type | Sent | Notes |
|---|---|---|---|
id | string | always | Event id. Dedup on this. |
type | enum | always | |
created | string | always | |
data | object | always | |
data.sessionId | string or null | when present | |
data.documentId | string | when present | Present on document-scoped events only. |
data.jobId | string | when present | Signed handle for GET /v1/jobs/{jobId}. Present when one could be resolved — this is the intended way to read the actual outcome. |
data.reference | object | when present | The caller correlation object, read live from the entity at delivery time. Present only when set. Never put personal data or secrets here — it leaves your system in this payload. |
document.* events precede any session, so their sessionId is null. A webhook.test delivery carries no ids.
Prove it works first
Before wiring real events, send a signed test delivery to one endpoint:
curl -X POST https://api.minerva-platform.ai/v1/webhook-endpoints/{id}/test \
-H "Authorization: Bearer mn_live_..."It arrives with type: "webhook.test" and no ids in data. Any endpoint can receive one, which is the general rule: treat a type you do not recognise as acknowledge-and-ignore, never as an error.
Headers
| Header | Meaning |
|---|---|
X-Signature | t=<unix>,v1=<hex> — verify before trusting |
X-Webhook-Id | Event id, same as the body id. Dedup on it. |
X-Webhook-Attempt | 1-based attempt counter. |
Verifying the signature
Compute HMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex and compare in constant time against a v1 entry. Use the raw bytes — re-serialising the JSON changes them. Reject if t is more than 300 seconds from now. During a secret rotation the header carries two v1= entries; accept if either matches.
These samples are executed in CI against the real signer:
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody must be the exact bytes received — parse only after verifying.
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts: Record<string, string> = Object.create(null)
const v1: string[] = []
for (const kv of header.split(',')) {
const [k, v] = kv.trim().split('=')
if (k === 'v1' && v) v1.push(v)
else parts[k] = v
}
// t must be a unix timestamp in seconds; reject anything else.
if (!/^\d+$/.test(parts.t ?? '')) return false
// Reject anything more than five minutes from now — this is what stops replay.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
// Constant-time, and accept EITHER v1 — during a secret rotation there are two.
return v1.some(
sig =>
sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
)
}Retries
A non-2xx or a timeout is retried with exponential backoff over a bounded window; after exhaustion the endpoint may be auto-disabled. Answer 2xx quickly and do the work asynchronously. The delivery log at GET /webhook-endpoints/{id}/deliveries shows every attempt, and a failed one can be re-queued.