Webhooks
Mandatum's agentic endpoints are asynchronous: POST /api/v1/skills/run returns a run id immediately and the work happens in the background. There are two ways to learn the outcome — poll GET /api/v1/runs/{id}, or subscribe to a webhook and be told.
Webhooks follow the Standard Webhooks v1 specification, so any reference implementation of that spec verifies our deliveries without custom code.
Events #
| Event | Fires when | data |
|---|---|---|
run.completed |
A workflow run reaches completed |
run_id, matter_id, workflow_slug, status, output_id |
run.failed |
A workflow run reaches failed |
run_id, matter_id, workflow_slug, status, error |
document.parsed |
(not yet emitted — see below) | document_id, … |
document.parsed is accepted at subscription time and its signature path is live, but nothing emits it today: POST /api/v1/documents parses inline and returns the tree in the same response, and the corpus ingestion pipeline is shared-corpus (no organisation to fan out to). It will start firing with the first asynchronous per-organisation document pipeline. Subscribing now is safe and forward-compatible.
Setup #
Registering an endpoint requires an API key with the webhooks:write scope.
curl -X POST https://mandatum.intergentech.ai/api/v1/webhooks \
-H "Authorization: Bearer $MANDATUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/mandatum",
"events": ["run.completed", "run.failed"],
"description": "production receiver"
}'{
"id": "3f1c…",
"url": "https://hooks.example.com/mandatum",
"events": ["run.completed", "run.failed"],
"description": "production receiver",
"active": true,
"created_at": "2026-08-27T12:00:00.000Z",
"secret_last_four": "wxyz",
"secret": "whsec_9s1Xk…"
}secret is returned once, here, and never again. Store it in your secret manager before you close the response. Mandatum keeps only an AES-256-GCM ciphertext of it plus the last four characters; there is no read path that can return it a second time. If you lose it, rotate.
URL requirements, enforced at registration:
httpsonly- no embedded credentials (
https://user:pass@…) and no fragment - the host must not be a loopback, private (RFC 1918), link-local, or IPv6 unique-local address — the same SSRF guard used for OAuth discovery
- at most 2048 characters, and at most 20 endpoints per organisation
The host is re-resolved and re-checked immediately before every delivery, so a DNS record repointed at a private address after registration is still refused.
The request we send #
POST /mandatum HTTP/1.1
Content-Type: application/json
User-Agent: Mandatum-Webhooks/1.0
webhook-id: msg_2f9c1b7d4e8a4f0b9c3d5e6f70819a2b
webhook-timestamp: 1787832000
webhook-signature: v1,eFO3aFvNsTSi1Lf4LnUO9T0CiPGIS+JLzinATGM5+84={"type":"run.completed","timestamp":"2026-08-27T12:00:00.000Z","data":{"run_id":"8a2b…","matter_id":"1c4d…","workflow_slug":"gap-analysis","status":"completed","output_id":"9e7f…"}}The example above is self-consistent: with the secret
whsec_MfKQ9r8mVhE3xJ2pT7yZaB5cD1nG6sQwLuXoY4iRkPc= it verifies with the snippet
below, so you can use it as a fixture while wiring up your receiver. Note that the
signature covers the raw body exactly as sent — the single line above, not a
re-indented copy of it.
| Header | Meaning |
|---|---|
webhook-id |
Unique id for this delivery. Stable across every retry — deduplicate on it. |
webhook-timestamp |
Unix seconds at which this attempt was signed. Refreshed per attempt. |
webhook-signature |
One or more space-separated v1,<base64> signatures. |
The envelope's timestamp is the event's logical time and does not change between attempts; webhook-timestamp is the transport clock and does.
Respond 2xx as soon as you have durably accepted the event. Do the work afterwards — we time out at 10 seconds.
Verifying a delivery #
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, base64-encoded and prefixed with v1,. The key is the bytes of your secret after the whsec_ prefix, base64-decoded — the prefix itself is a display marker, not key material.
Verify against the raw request body. Parsing the JSON and re-serialising it changes bytes and will fail.
import { createHmac, timingSafeEqual } from "crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyMandatumWebhook(
secret: string, // whsec_…
rawBody: string, // the exact bytes received
headers: {
"webhook-id"?: string | null;
"webhook-timestamp"?: string | null;
"webhook-signature"?: string | null;
},
): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) return false;
// Replay defence: reject anything older (or newer) than five minutes.
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isInteger(Number(timestamp)) || Math.abs(age) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected =
"v1," +
createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`, "utf8").digest("base64");
// During rotation the header carries several space-separated signatures;
// any one matching is a pass.
return signatureHeader.split(" ").some((candidate) => {
const a = Buffer.from(candidate, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
});
}Our own verifier implements this scheme and is covered by tests against the sender: a valid signature verifies, and a tampered body, the wrong secret, or a ten-minute-old timestamp all fail.
Rejecting a stale webhook-timestamp is required, not optional — without it a captured request can be replayed against you indefinitely.
Retries #
Non-2xx responses and transport failures are retried up to 8 attempts over roughly 21 hours:
| Attempt | Sent after the previous failure |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
| 8 | 12 hours |
After the eighth attempt the delivery is marked failed and left in the history for replay.
Two responses are special:
2xx— delivered, terminal.410 Gone— we deactivate the endpoint and stop sending to it entirely. Use this to retire a receiver for good.
Every other 4xx (including 401 and 429) is retried; transient proxy errors are far more common than genuinely permanent client errors, and the spec only singles out 410.
Inspect the history at any time with webhooks:read:
curl https://mandatum.intergentech.ai/api/v1/webhooks/$ENDPOINT_ID/deliveries \
-H "Authorization: Bearer $MANDATUM_API_KEY"{
"endpoint_id": "3f1c…",
"deliveries": [
{
"id": "7b2e…",
"event_id": "msg_2f9c…",
"event_type": "run.completed",
"status": "failed",
"attempts": 8,
"last_status_code": 500,
"last_error": "receiver responded 500 (retries exhausted)",
"next_attempt_at": null,
"delivered_at": null,
"created_at": "2026-08-27T12:00:00.000Z"
}
]
}Replaying a delivery #
curl -X POST \
https://mandatum.intergentech.ai/api/v1/webhooks/$ENDPOINT_ID/deliveries/$DELIVERY_ID/replay \
-H "Authorization: Bearer $MANDATUM_API_KEY"Replay re-sends the same delivery, which means the same webhook-id reaches you again. That is deliberate: a receiver that deduplicates on webhook-id treats a replay of an event it already processed as a no-op, while a receiver that genuinely lost the event processes it. The attempt counter and backoff schedule reset.
Rotating a secret #
curl -X POST https://mandatum.intergentech.ai/api/v1/webhooks/$ENDPOINT_ID/rotate \
-H "Authorization: Bearer $MANDATUM_API_KEY"{
"id": "3f1c…",
"secret": "whsec_bQ7v…",
"secret_last_four": "4321",
"rotated_at": "2026-08-27T12:00:00.000Z",
"previous_secret_valid_until": "2026-08-28T12:00:00.000Z"
}Rotation is overlapping, not atomic. For 24 hours both the old and the new secret sign every delivery, and webhook-signature carries two space-separated values:
webhook-signature: v1,<signed with the new secret> v1,<signed with the old secret>A verifier that checks any signature in the header — including the snippet above and every Standard Webhooks reference library — keeps working throughout. Deploy the new secret at your leisure within the window; after it closes only the new signature is attached.
Managing endpoints #
| Method | Path | Scope |
|---|---|---|
POST |
/api/v1/webhooks |
webhooks:write |
GET |
/api/v1/webhooks |
webhooks:read |
DELETE |
/api/v1/webhooks/{id} |
webhooks:write |
POST |
/api/v1/webhooks/{id}/rotate |
webhooks:write |
GET |
/api/v1/webhooks/{id}/deliveries |
webhooks:read |
POST |
/api/v1/webhooks/{id}/deliveries/{deliveryId}/replay |
webhooks:write |
GET /api/v1/webhooks returns secret_last_four and never any part of the secret itself. Endpoints, deliveries, and runs are all scoped to the calling key's organisation; an id belonging to another organisation returns 404, identical to an id that does not exist.
Deleting an endpoint cascades to its delivery history.