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/webhookProduct-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:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 10 sec |
| 3 | 30 sec |
| 4 | 2 min |
| 5 | 10 min |
| 6 | 1 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
/api/webhooks{
"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..."
}
}