Skip to content

Webhooks & signature verification

Subscribe to events instead of polling. A tenant administrator registers endpoints via /api/v1/admin/webhooks; deliveries are signed with HMAC-SHA256 and retried with backoff.

Events

EventFired when
run.completedA run finished discovery successfully.
run.failedA run failed.
run.conflictA run could not proceed due to a conflicting base.
generation.publishedAn apply published a new generation.
apply.failedAn apply failed to build/publish.

Register an endpoint (admin)

Terminal window
curl -X POST "$CHATBOT_API/v1/admin/webhooks" \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f3c…unique-per-create" \
-d '{"url":"https://example.com/hooks/chatbot","events":["generation.published","run.failed","apply.failed"]}'

Response (secret shown once):

{ "webhook": { "id":"", "url":"", "events":[""], "status":"active" },
"secret": "whsec_…" }

url must be https:// (or http:// only in dev). Rotate the secret with POST /v1/admin/webhooks/{id}/rotate, pause with PATCH ({"status":"disabled"}), remove with DELETE.

Send an Idempotency-Key header so a dropped connection, double-click, or crash mid-request can be retried without creating a duplicate webhook:

  • Same key + same payload → the original webhook is returned and the one-time secret is replayed (within the idempotency retention window), so a retry never silently loses the secret.
  • Same key + different payload409 with code idempotency_conflict.
  • A create with the same key already in flight409 with code idempotency_in_progress.
  • A second active webhook for the same url + event set409 with code webhook_conflict (the same URL with a different event set is allowed).
  • No key → each request creates an independent webhook with no retry guarantee.

Disabling or rotating a webhook is unaffected by idempotency; after a webhook is disabled, the same url + event set may be registered again.

Delivery format

Each delivery is a POST with a JSON body and these headers:

Content-Type: application/json
x-chatbot-event-id: <unique id — use for idempotency>
x-chatbot-signature: t=<unix seconds>,v1=<hex hmac>

The signature is:

v1 = HMAC_SHA256(secret, "<t>.<raw request body>") // hex

Verify the signature

Always verify on the raw request body (do not re-serialize) and reject deliveries whose timestamp is outside a tolerance window (default 300 s) to stop replays. De-duplicate using x-chatbot-event-id.

Node.js (Express)

const crypto = require('crypto');
function verify(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => {
const i = kv.indexOf('='); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}));
const t = parseInt(parts.t, 10);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// IMPORTANT: capture the raw body, e.g. express.raw({ type: 'application/json' })
app.post('/hooks/chatbot', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(process.env.CHATBOT_WEBHOOK_SECRET, raw, req.get('x-chatbot-signature') || '')) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
// de-dupe on req.get('x-chatbot-event-id'); then handle event.type
res.sendStatus(200);
});

Python (Flask)

import hashlib, hmac, time
def verify(secret: str, raw: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/hooks/chatbot")
def hook():
raw = request.get_data() # raw bytes, not request.json
if not verify(os.environ["CHATBOT_WEBHOOK_SECRET"], raw, request.headers.get("x-chatbot-signature", "")):
abort(401)
event = json.loads(raw)
return "", 200

Retries & auto-disable

Failed deliveries (non-2xx / timeout) retry with backoff at roughly 1 min, 5 min, 15 min, 1 h, 3 h, 6 h (up to 6 attempts). After 5 consecutive permanent failures the endpoint is auto-disabled — re-enable it with PATCH {"status":"active"} once your receiver is healthy.

Return a 2xx quickly (do the heavy work async) so deliveries are not retried unnecessarily.