Webhooks
Adjudon webhooks let your systems react to AI decisions in real time — a flagged trace lands on Slack, an opened incident pages the on-call, an SLO-breaching review-queue depth fires PagerDuty. This page is the integration overview. The companion pages Webhook Events Catalog and Signature Verification cover the per-event payload schemas and the verification recipe in full.
How delivery works
A delivery is one HTTP POST from api.adjudon.com to your
configured endpoint URL. The body is one JSON object; three headers
identify the event and authenticate it; the dispatcher runs PII
scrubbing on the payload before signing and before transport. Your
endpoint should return 2xx within a reasonable timeout; anything
else triggers the retry queue.
The five Adjudon event names today (per
backend/models/Webhook.js):
| Event | Fires when |
|---|---|
trace.created | A new DecisionTrace is ingested |
alert.triggered | An alert rule matches a trace |
agent.created | A new agent is registered |
setting.updated | An organisation-level setting changes |
* | Subscribe to all events on one endpoint |
Per-event payload schemas live on Webhook Events Catalog.
Adding an endpoint
Create a webhook through POST /api/v1/webhooks (admin or owner
role required, plan-gate webhooks). The URL must be https://
— plain HTTP is rejected at validation. Private and reserved
addresses are blocked at create time and re-checked via DNS
resolution at delivery time, so DNS-rebinding attacks against
localhost, RFC 1918 ranges, and the AWS metadata IP cannot tunnel
through a public-looking hostname.
curl -X POST https://api.adjudon.com/api/v1/webhooks \
-H "Authorization: Bearer $ADJUDON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/adjudon",
"events": ["trace.created", "alert.triggered"],
"type": "custom"
}'
The full configuration endpoint surface (list / create / patch / delete / test) is documented at Webhook Config API.
Receiving an event
Adjudon sends three headers on every delivery:
| Header | Value | Purpose |
|---|---|---|
x-adjudon-event | trace.created | The event name |
x-adjudon-signature | sha256=<hex> | HMAC-SHA256 of the body, hex-encoded |
Content-Type | application/json | Standard |
The body is one JSON object:
{
"event": "trace.created",
"timestamp": "2026-05-06T10:14:22.317Z",
"data": {
"traceId": "trace_aBcD1234",
"agentId": "underwriter-v1",
"status": "approved",
"confidenceScore": 0.83
}
}
A minimal Node handler:
import express from "express";
import crypto from "node:crypto";
const app = express();
// IMPORTANT: read the raw body for HMAC verification.
// If you use express.json() before this route, the raw bytes are
// already consumed — verification will fail.
app.post(
"/adjudon",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-adjudon-signature"]?.replace(/^sha256=/, "");
const expected = crypto
.createHmac("sha256", process.env.ADJUDON_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
// CONSTANT-TIME comparison — never use ===
const sigBuf = Buffer.from(signature || "", "hex");
const expBuf = Buffer.from(expected, "hex");
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.event / event.data ...
res.status(200).end();
}
);
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/adjudon")
async def adjudon_webhook(request: Request):
raw = await request.body() # raw bytes, not parsed JSON
sig = request.headers.get("x-adjudon-signature", "").replace("sha256=", "")
expected = hmac.new(
os.environ["ADJUDON_WEBHOOK_SECRET"].encode(),
raw,
hashlib.sha256,
).hexdigest()
# CONSTANT-TIME compare
if not hmac.compare_digest(sig, expected):
raise HTTPException(401)
# ... parse raw and handle ...
return {"ok": True}
@PostMapping(value = "/adjudon", consumes = "application/json")
public ResponseEntity<String> adjudonWebhook(
@RequestBody byte[] raw, // RAW bytes, not deserialized JSON
@RequestHeader("x-adjudon-signature") String sigHeader) throws Exception {
String sig = sigHeader.replace("sha256=", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
System.getenv("ADJUDON_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8),
"HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(raw));
// CONSTANT-TIME compare — MessageDigest.isEqual is timing-safe
if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) {
return ResponseEntity.status(401).build();
}
// ... parse raw and handle ...
return ResponseEntity.ok().build();
}
Manual signature verification has one rule that takes precedence
over readability: use a constant-time comparison
(crypto.timingSafeEqual / hmac.compare_digest), never === or
==. A non-constant-time string compare leaks the byte position
of the first mismatch via timing differences and lets an attacker
forge a signature in a few thousand requests. See
Signature Verification for the full recipe
in Java and .NET as well.
Retries
Failed deliveries (anything outside 2xx, plus connection errors
and timeouts) are persisted to the MongoDB-backed retry queue and
re-attempted on an exponential schedule:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate (initial delivery) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
After five retries (six total attempts) the delivery is abandoned.
Retry state survives server restarts and multi-instance deployments
(persisted in WebhookRetry). A poller picks up due retries every
30 seconds.
Testing
POST /api/v1/webhooks/:id/test fires a synthetic event to the
configured URL. Use it after creating an endpoint to confirm the
signature path, the 2xx return, and any infrastructure between
your edge and your handler. The full surface is at
Webhook Config API.
Troubleshooting
401from your handler. The signature did not validate. Check that you are reading the raw body (not the JSON-parsed body) and that the secret matches what the dashboard shows.- Repeated retries with no
2xx. Your handler is timing out or returning an error. The dashboard's per-webhook activity panel shows the response code on each attempt. - DNS-rebinding attack hardening. A public hostname that
resolves to a private IP (
127.x,10.x,192.168.x, AWS169.254.169.254) is rejected at delivery time, not just at create time. If your endpoint sits behind a private-network bridge, expose it via a public proxy. - PII scrubbing. The dispatcher runs PII scrubbing (email / IBAN / credit-card / SSN / phone) on the payload before signing. Your handler receives the scrubbed body; the raw input is not transported.
See also
- Webhook Events Catalog — per-event payload schemas
- Signature Verification — manual + SDK verification in Node, Python, Java, .NET
- Webhook Config API — the CRUD endpoints for managing webhooks
- Error Codes — the failure
envelope and
RATE_LIMIT_EXCEEDEDshape - Routing flagged decisions to Slack — cookbook recipe