Skip to main content

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):

EventFires when
trace.createdA new DecisionTrace is ingested
alert.triggeredAn alert rule matches a trace
agent.createdA new agent is registered
setting.updatedAn 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 — create a webhook
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:

HeaderValuePurpose
x-adjudon-eventtrace.createdThe event name
x-adjudon-signaturesha256=<hex>HMAC-SHA256 of the body, hex-encoded
Content-Typeapplication/jsonStandard

The body is one JSON object:

Sample delivery body
{
"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:

Node — Express 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();
}
);
Python — FastAPI handler
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}
Java — Spring Boot handler
@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:

AttemptDelay after previous
1immediate (initial delivery)
21 minute
35 minutes
430 minutes
52 hours
68 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

  • 401 from 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, AWS 169.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