📘 Public beta · Endpoints are stable; OpenAPI specs and SDKs ship monthly. See changelog →
Getting started
Webhooks

Webhooks

Webhooks are how Quantum Elixir tells your system that something happened — a document finished extracting, an alert was raised, a workflow execution completed.

Every product emits its own event set. The delivery mechanism, signing scheme, retry policy, and verification flow are identical across all seven products. Learn it once.

Anatomy of a delivery

We POST JSON to your URL with these headers:

POST /your-webhook-endpoint HTTP/1.1
Content-Type:        application/json
X-Quantum-Event:     document.extracted
X-Quantum-Delivery:  whd_01HXY...
X-Quantum-Signature: sha256=8b2c4d7e...
X-Quantum-Timestamp: 1716552000
X-Quantum-Attempt:   1
User-Agent:          quantum-elixir/webhook

Product-specific header prefix

Older products still use a product-prefixed header (e.g. X-BankStatement-Signature, X-Identity-Signature, X-QEDI-Signature). We are migrating all products to the unified X-Quantum-* prefix; the legacy prefix will keep working for at least 12 months after each product migrates. Always check the per-product webhook page for the current header name.

Body shape:

{
  "id": "evt_01HXY...",
  "event": "document.extracted",
  "occurredAt": "2026-05-24T08:14:22.481Z",
  "orgId": "org_01HXY...",
  "data": {
    "documentId": "doc_01HXY...",
    "type": "ktp",
    "fields": { ... }
  }
}

Verifying the signature

The signature header is sha256=<hex> where <hex> is HMAC-SHA256(webhook_secret, raw_request_body).

import crypto from 'crypto';

function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
  const [scheme, sent] = signatureHeader.split('=');
  if (scheme !== 'sha256') return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(sent, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

// Express example:
app.post('/webhooks/quantum', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verify(
    req.body.toString('utf8'),
    req.header('x-quantum-signature') ?? '',
    process.env.QE_WEBHOOK_SECRET!,
  );
  if (!ok) return res.status(401).send('bad signature');
  const event = JSON.parse(req.body.toString('utf8'));
  // ... process event ...
  res.status(200).send('ok');
});

Always verify before parsing

Treat every unverified webhook as hostile. We've seen integrations skip verification "to debug faster" — and then get owned by anyone who guesses the URL.

Retry policy

If your endpoint returns anything other than 2xx within 10 seconds, we retry:

AttemptDelay
1immediate
210 sec
330 sec
42 min
510 min
61 hour

After 6 failed attempts, the delivery is dropped and surfaces on the webhook's delivery log in your dashboard. We do not retry after a successful 2xx — but the same event may be redelivered as part of a backfill or a different subscription.

Idempotency

The id field on every event is stable across retries. Use it as your idempotency key:

async function processEvent(event: { id: string; ... }) {
  if (await db.processedEvents.has(event.id)) return; // already handled
  await db.processedEvents.insert(event.id);
  // ... do the actual work ...
}

Registering a subscription

POST/api/webhooks
Auth · API keyScope · webhooks:manage
{
  "url": "https://your-app.example.com/webhooks/quantum",
  "events": ["document.extracted", "document.failed"],
  "active": true
}

The response includes the signing secret exactly once. Store it; we can't show it again.

{
  "data": {
    "id": "whk_01HXY...",
    "url": "...",
    "events": ["document.extracted", "document.failed"],
    "secret": "qe_whsec_a8f3c92e..."
  }
}

Per-product event lists