Webhooks
Receive real-time event notifications when important things happen: gate firings, trust zone changes, authorization decisions, and more.
Overview
Webhooks deliver event notifications to your server via HTTP POST requests. Each delivery includes a cryptographic signature so you can verify the payload originated from Mandate Labs. Delivery is at least once: failed deliveries are retried, so your handler must de-duplicate on the envelope id.
Common use cases include alerting on declined transactions, monitoring agent trust degradation, triggering human review workflows on step-up decisions, and logging all authorization activity to your own systems.
Event types
Subscribe to specific event types or use * to receive all events.
| Event Type | Trigger |
|---|---|
gate.fired | The intent anomaly gate flagged a transaction (a behavioral shift against the session baseline). |
kya.zone.red | An authorization by an agent whose trust zone is RED (behavioral score 0.20 to 0.40); its per-transaction limit is halved. Fires on each such authorization. |
kya.zone.critical | An authorization by an agent whose trust zone is CRITICAL (behavioral score below 0.20); its per-transaction limit drops to 25%. Fires on each such authorization. |
session.terminate | Session termination is recommended because the agent is in the CRITICAL zone. |
authorization.decline | A transaction was declined. |
step_up.created | A transaction needs human confirmation before it can proceed. |
trust.promotion | An agent was promoted to a higher trust level (REGISTERED to VERIFIED, or VERIFIED to TRUSTED). |
fraud.triage.suspected_created, fraud.triage.resolved, fraud.pattern.agto_suspected | Fraud triage events, delivered only when fraud triage is enabled for your account. See the Webhooks reference. |
* | Wildcard: every event type, including future additions. |
Legacy aliases cts.red / cts.critical remain accepted for 12 months under the deprecation policy. A webhook registered under a legacy alias receives the event under that name.
Setting up
Register a webhook endpoint and receive a one-time signing secret. Store the secret securely; it cannot be retrieved again.
curl -X POST https://api.mandatelabs.ai/api/v1/webhooks \ -H "X-API-Key: mdt_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/mandate-ai", "event_types": ["gate.fired", "kya.zone.red", "kya.zone.critical", "authorization.decline"], "description": "Production alerting" }' # Response: { "webhook": { "id": "wh_…", … }, "signing_secret": "whsec_…" } # The signing_secret is shown only once — save it securely.
import httpx resp = httpx.post( "https://api.mandatelabs.ai/api/v1/webhooks", headers={"X-API-Key": "mdt_live_…"}, json={ "url": "https://your-app.com/webhooks/mandate-ai", "event_types": ["gate.fired", "kya.zone.red", "kya.zone.critical", "authorization.decline"], "description": "Production alerting", }, ) data = resp.json() print(f"ID: {data['webhook']['id']}") print(f"Secret: {data['signing_secret']}") # whsec_... — save securely!
const resp = await fetch("https://api.mandatelabs.ai/api/v1/webhooks", { method: "POST", headers: { "X-API-Key": "mdt_live_…", "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://your-app.com/webhooks/mandate-ai", event_types: ["gate.fired", "kya.zone.red", "kya.zone.critical", "authorization.decline"], description: "Production alerting", }), }); const data = await resp.json(); console.log(`ID: ${data.webhook.id}`); console.log(`Secret: ${data.signing_secret}`); // whsec_... — save securely!
The signing_secret (format: whsec_*) is only returned at creation time. If you lose it, delete the webhook and create a new one.
Payload format
Every delivery is an HTTP POST with a JSON body in the same envelope: a unique id, the event type, the timestamp it was sent, and event-specific fields under data.
{
"id": "evt_4f9c1e8a7b6d4f2c9e1a3b5c7d9f0a2b",
"event": "authorization.decline",
"timestamp": "2026-06-10T22:41:07.512938+00:00",
"data": {
"authorization_id": "auth_1781111323760_13a96cd71daa0fc5",
"agent_id": "agt_1781050696426_c442906049e6617f",
"decision": "DECLINE",
"amount": "800.00",
"currency": "USD",
"processing_time_ms": 73.44,
"reason_codes": ["AMOUNT_EXCEEDS_PER_TXN"],
"reason_detail": "Amount 800 exceeds per-txn limit 500.00"
}
}
Events from the authorization pipeline share a base set of fields in data: authorization_id, agent_id, decision, amount (a decimal string), currency and processing_time_ms. Each event adds its own fields:
| Event | Fields in data |
|---|---|
authorization.decline, step_up.created | Base fields plus reason_codes and reason_detail. |
gate.fired | Base fields plus anomaly_score (0 to 1). |
kya.zone.red, kya.zone.critical | Base fields plus cognitive_limit_multiplier (0.50 or 0.25) and zone ("RED" or "CRITICAL"). |
session.terminate | Base fields plus cognitive_limit_multiplier and reason. |
trust.promotion | No base fields: agent_id, authorization_id, new_trust_level, kya_score. |
Deliveries also carry the headers X-Mandate-Event (the event type), X-Mandate-Signature, X-Mandate-Delivery-Attempt (1 on the first try) and User-Agent: MandateAI-Webhook/1.0. Test deliveries add "test": true to data. Complete examples for every event are in the Webhooks reference.
Signature verification
Every delivery carries an X-Mandate-Signature header of the form sha256=<hex>: an HMAC-SHA256 of the raw request body, keyed with the webhook's signing secret. Verify it before you process anything, then reject envelopes whose timestamp is more than 5 minutes from your clock and de-duplicate on id. The id and timestamp are inside the signed body, so a replayed capture cannot change them.
import hmac import hashlib import json from datetime import datetime, timedelta, timezone def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: """X-Mandate-Signature is "sha256=" + hex HMAC-SHA256 of the raw request body.""" expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header or "") # Flask example from flask import Flask, request, jsonify app = Flask(__name__) WEBHOOK_SECRET = "whsec_your_secret_here" seen_ids = set() # use a durable store in production @app.route("/webhooks/mandate-ai", methods=["POST"]) def handle_webhook(): raw = request.get_data() # the raw bytes, before any JSON parsing if not verify_webhook(raw, request.headers.get("X-Mandate-Signature", ""), WEBHOOK_SECRET): return jsonify({"error": "invalid signature"}), 401 event = json.loads(raw) sent_at = datetime.fromisoformat(event["timestamp"]) if abs(datetime.now(timezone.utc) - sent_at) > timedelta(minutes=5): return jsonify({"error": "stale event"}), 400 # possible replay if event["id"] in seen_ids: return jsonify({"received": True}), 200 # duplicate delivery, already handled seen_ids.add(event["id"]) print(f"Received {event['event']} for agent {event['data'].get('agent_id')}") return jsonify({"received": True}), 200
import crypto from "crypto"; import express from "express"; function verifyWebhook(rawBody, signatureHeader, secret) { // X-Mandate-Signature is "sha256=" + hex HMAC-SHA256 of the raw request body const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } const app = express(); const WEBHOOK_SECRET = "whsec_your_secret_here"; const seenIds = new Set(); // use a durable store in production app.post("/webhooks/mandate-ai", express.raw({ type: "application/json" }), (req, res) => { if (!verifyWebhook(req.body, req.headers["x-mandate-signature"], WEBHOOK_SECRET)) { return res.status(401).json({ error: "invalid signature" }); } const event = JSON.parse(req.body.toString()); if (Math.abs(Date.now() - Date.parse(event.timestamp)) > 5 * 60 * 1000) { return res.status(400).json({ error: "stale event" }); // possible replay } if (seenIds.has(event.id)) { return res.status(200).json({ received: true }); // duplicate delivery, already handled } seenIds.add(event.id); console.log(`Received ${event.event} for agent ${event.data.agent_id}`); res.status(200).json({ received: true }); });
Compute the HMAC over the raw bytes you received, before parsing JSON: re-serializing the body can change it and break the signature. Compare with a constant-time function such as hmac.compare_digest or crypto.timingSafeEqual.
Retry policy
Mandate Labs retries transient delivery failures in the background, so a slow or failing endpoint never delays an authorization.
| Behavior | Details |
|---|---|
| Attempts | Up to 5 attempts per event (the first try plus 4 retries), with exponential backoff and jitter: about 1s, 2s, 4s and 8s between tries. |
| Timeout | 10 seconds per attempt, 5 of them to connect. Acknowledge quickly and process asynchronously. |
| Success | Any 2xx response. The failure counter resets and last_status_code and last_delivery_at are recorded. |
| Retried | Network errors, timeouts, HTTP 408, 423, 425 and 429, and any 5xx. |
| Not retried | Any other 4xx, for example 400, 401, 404 or 410. The event fails immediately. |
| Auto-disable | After 10 consecutive failed events the webhook is set to active: false. An event counts once, after its retries are exhausted. |
| Re-enable | Send PATCH /api/v1/webhooks/{id} with {"active": true} once your endpoint is healthy. This resets the failure counter. |
| Ordering | Not guaranteed. Use timestamp and your own state, not arrival order. |
Because of retries, your handler can receive the same event more than once. Every retry reuses the same envelope id and signature, so store the ids you have processed and skip repeats. In rare recovery cases an event can be sent again under a new id; if your handler must act exactly once, also key on the event type plus data.authorization_id. Retries stop after about 15 seconds, so an endpoint that is down for longer misses events: reconcile periodically against the authorization history API.
Testing webhooks
Send a test event to verify your endpoint is configured correctly. Pass the event type to simulate as the event_type query parameter (defaults to gate.fired); the dispatched payload includes "test": true so your handler can distinguish it from real events.
# Send a test ping to your webhook endpoint curl -X POST "https://api.mandatelabs.ai/api/v1/webhooks/wh_abc123/test?event_type=gate.fired" \ -H "X-API-Key: mdt_live_…" # Response: { "status": "sent", "event_type": "gate.fired", "message": "Test '…' event dispatched to …" }
import httpx # Send a test ping to your webhook endpoint resp = httpx.post( "https://api.mandatelabs.ai/api/v1/webhooks/wh_abc123/test", headers={"X-API-Key": "mdt_live_…"}, params={"event_type": "gate.fired"}, ) print(f"Test result: {resp.json()}") # {"status": "sent", "event_type": "gate.fired", ...}
// Send a test ping to your webhook endpoint const resp = await fetch( "https://api.mandatelabs.ai/api/v1/webhooks/wh_abc123/test?event_type=gate.fired", { method: "POST", headers: { "X-API-Key": "mdt_live_…" } }, ); console.log(`Test result: ${JSON.stringify(await resp.json())}`); // {"status":"sent",...}