Minerva API

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

EventSubscribableFires
extraction.completedyesOnce, when a session finishes with at least one successfully extracted document.
extraction.failedyesOnce, when a session finishes with no successful documents.
extraction.document.completedyesPer document that finished extraction successfully.
extraction.document.failedyesPer document that failed extraction.
document.uploadedyesPer document stored (status "uploaded") — ready to process, not yet parsed.
document.processedyesPer document that finished preprocessing and is ready to extract.
document.processing_failedyesPer document whose preprocessing failed.
webhook.testnoOn 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"
    }
  }
}
FieldTypeSentNotes
idstringalwaysEvent id. Dedup on this.
typeenumalways
createdstringalways
dataobjectalways
data.sessionIdstring or nullwhen present
data.documentIdstringwhen presentPresent on document-scoped events only.
data.jobIdstringwhen presentSigned handle for GET /v1/jobs/{jobId}. Present when one could be resolved — this is the intended way to read the actual outcome.
data.referenceobjectwhen presentThe 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

HeaderMeaning
X-Signaturet=<unix>,v1=<hex> — verify before trusting
X-Webhook-IdEvent id, same as the body id. Dedup on it.
X-Webhook-Attempt1-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:

verify.ts
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.

On this page