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 TypeTrigger
gate.firedThe intent anomaly gate flagged a transaction (a behavioral shift against the session baseline).
kya.zone.redAn 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.criticalAn 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.terminateSession termination is recommended because the agent is in the CRITICAL zone.
authorization.declineA transaction was declined.
step_up.createdA transaction needs human confirmation before it can proceed.
trust.promotionAn 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_suspectedFraud 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!
One-time secret

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:

EventFields in data
authorization.decline, step_up.createdBase fields plus reason_codes and reason_detail.
gate.firedBase fields plus anomaly_score (0 to 1).
kya.zone.red, kya.zone.criticalBase fields plus cognitive_limit_multiplier (0.50 or 0.25) and zone ("RED" or "CRITICAL").
session.terminateBase fields plus cognitive_limit_multiplier and reason.
trust.promotionNo 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 });
});
Important

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.

BehaviorDetails
AttemptsUp to 5 attempts per event (the first try plus 4 retries), with exponential backoff and jitter: about 1s, 2s, 4s and 8s between tries.
Timeout10 seconds per attempt, 5 of them to connect. Acknowledge quickly and process asynchronously.
SuccessAny 2xx response. The failure counter resets and last_status_code and last_delivery_at are recorded.
RetriedNetwork errors, timeouts, HTTP 408, 423, 425 and 429, and any 5xx.
Not retriedAny other 4xx, for example 400, 401, 404 or 410. The event fails immediately.
Auto-disableAfter 10 consecutive failed events the webhook is set to active: false. An event counts once, after its retries are exhausted.
Re-enableSend PATCH /api/v1/webhooks/{id} with {"active": true} once your endpoint is healthy. This resets the failure counter.
OrderingNot guaranteed. Use timestamp and your own state, not arrival order.
At least once, not exactly once

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",...}

Next steps