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
| Event | Fired when |
|---|---|
run.completed | A run finished discovery successfully. |
run.failed | A run failed. |
run.conflict | A run could not proceed due to a conflicting base. |
generation.published | An apply published a new generation. |
apply.failed | An apply failed to build/publish. |
Register an endpoint (admin)
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.
Safe retries (recommended)
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
secretis replayed (within the idempotency retention window), so a retry never silently loses the secret. - Same key + different payload →
409with codeidempotency_conflict. - A create with the same key already in flight →
409with codeidempotency_in_progress. - A second active webhook for the same
url+ event set →409with codewebhook_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/jsonx-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>") // hexVerify 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 "", 200Retries & 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.