Webhooks
Signed HTTPS event push — for monitoring, alerts, and downstream integrations
Push internal events (fallback triggered, budget exhausted, agent completed, error bursts) to your endpoint. Every event is signed, auto-retried, and replayable for 24h. Wire fallback / budget / error events into PagerDuty / Slack.
Three-step setup
Register an endpoint in console
Settings → Webhooks → New endpoint. Provide your URL (HTTPS required) + select events → receive a signing_secret (whsec_…).
Verify the signature on your server
Use signing_secret to verify the SkyAI-Signature header (HMAC-SHA256) on every event. Reject mismatches with 401.
ACK 200 within 10 seconds
Your endpoint must respond 2xx within 10s. Defer slow work (DB writes, email) to async jobs. Anything else is treated as failed and retried.
Event catalog
| Event | Trigger | Recommended sink |
|---|---|---|
router.fallback_triggered | Primary failed; fallback served. | Slack #ops |
router.budget_exceeded | Request rejected by budget cap. | Datadog counter + Slack |
router.error_burst | Tenant error rate > 5% over 5-min window. | PagerDuty |
router.policy_changed | Policy edited and published in console. | Audit log (immutable) |
agent.run_completed | Agent run reaches terminal state. | Your downstream workflow |
cache.evicted | Cache hit rate sustained below threshold. | Datadog |
billing.threshold_crossed | Monthly usage hit a configured threshold (50/80/100%). | Slack #finance |
incident.created | SkyAIApp incident opened (mirrors status page). | PagerDuty |
Full payload schemas live in the console's Event Explorer with JSON-Schema + historical samples.
Payload shape
POST /your-endpoint
Content-Type: application/json
SkyAI-Event-Id: evt_01JFGYWXYZ123
SkyAI-Event-Type: router.fallback_triggered
SkyAI-Signature: t=1715680800,v1=abcdef0123...
SkyAI-Idempotency-Key: evt_01JFGYWXYZ123
{
"id": "evt_01JFGYWXYZ123",
"type": "router.fallback_triggered",
"created_at": "2026-05-14T10:30:00.123Z",
"api_version": "v1",
"data": {
"trace_id": "tr_01JFGYZ7K8M2N3P4Q5R6S7T8U9",
"primary_model": "gpt-5.5-pro",
"fallback_model": "claude-opus-4.7",
"reason": "upstream_5xx", // upstream_5xx | timeout | budget | safety
"primary_latency_ms": 5230,
"metadata": { "tenant": "acme-corp", "workflow": "trip-planner" }
}
}id is the unique event identifier — use it as the idempotency key in your handler. The SkyAI-Idempotency-Key header carries the same value for easy de-dup table writes.
Signature verification (mandatory)
Signature format: t=<unix-timestamp>,v1=<hex-hmac-sha256>. HMAC is computed over '<timestamp>.<raw-body>'. Verify three things: (1) timestamp within 5 min (replay protection), (2) timing-safe comparison (timing-attack protection), (3) HMAC the raw body — never re-serialize JSON.
// TypeScript / Node — Express handler
import express from "express";
import crypto from "crypto";
const app = express();
// IMPORTANT: capture raw body for HMAC. JSON parse AFTER verification.
app.post(
"/skyai/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("SkyAI-Signature") || "";
const secret = process.env.SKYAIAPP_WEBHOOK_SECRET!;
// 1. Parse "t=...,v1=..."
const parts = Object.fromEntries(
sig.split(",").map((p) => p.split("=") as [string, string]),
);
const ts = Number(parts.t);
const provided = parts.v1;
// 2. Replay protection — reject anything older than 5 min.
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).send("stale or missing timestamp");
}
// 3. HMAC over "<ts>.<raw-body>".
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.body}`)
.digest("hex");
// 4. Timing-safe comparison.
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
// 5. Idempotency — store event id; ignore repeats.
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadySeen(event.id)) return res.status(200).send("duplicate");
await markSeen(event.id, /* ttl */ 24 * 60 * 60);
// 6. ACK fast; defer real work.
queue.push(event);
return res.status(200).send("ok");
},
);# Python / FastAPI
import hmac, hashlib, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/skyai/webhook")
async def webhook(request: Request):
raw = await request.body()
sig_header = request.headers.get("SkyAI-Signature", "")
secret = os.environ["SKYAIAPP_WEBHOOK_SECRET"]
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts = int(parts.get("t", 0))
provided = parts.get("v1", "")
if not ts or abs(time.time() - ts) > 300:
raise HTTPException(401, "stale or missing timestamp")
expected = hmac.new(
secret.encode(),
f"{ts}.{raw.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, provided):
raise HTTPException(401, "bad signature")
event = json.loads(raw)
if await already_seen(event["id"]):
return {"status": "duplicate"}
await mark_seen(event["id"], ttl_seconds=86400)
await queue.put(event)
return {"status": "ok"}Retry policy
- Success: endpoint returns 2xx within 10s.
- Retry on failure: 5xx / timeout / connect-fail. Up to 5 attempts, exponential backoff (1m → 5m → 15m → 1h → 4h).
- No retry on 4xx: your rejection is final (except 408/429).
- After exhaustion: event is flagged failed_delivery; replay it manually from the console (see below).
- Ordering: delivery order is not guaranteed. Always sort on created_at server-side.
Replay & test
Console → Webhooks → Deliveries shows the last 24h of attempts. Each row exposes request/response, signature, HTTP code — and a one-click replay.
# CLI replay (useful in CI / local dev)
skyai webhooks replay evt_01JFGYWXYZ123 --to https://localhost:3000/skyai/webhook
# Or send a synthetic event of any type
skyai webhooks send router.fallback_triggered --tenant acme-corp --to https://localhost:3000/...For local dev, point the endpoint at https://requestbin.com or https://webhook.site to inspect payload shapes before writing your handler.
Common anti-patterns
Sending email / calling third-party APIs synchronously inside the handler.
Why it's bad: Anything > 10s counts as failure → retry → duplicate emails.
Parsing JSON before verifying the signature.
Why it's bad: Re-serializing mutates the byte stream — HMAC will fail. Always HMAC the raw body.
Using === (or string equality) to compare signatures.
Why it's bad: Vulnerable to timing attacks. Use crypto.timingSafeEqual / hmac.compare_digest.
Assuming events arrive in created_at order.
Why it's bad: Retries shuffle the order. Always sort by created_at on your end.
No idempotency check in the handler.
Why it's bad: One retry = one duplicate processing. Use event.id as the dedup key.
See also
API Reference
Full endpoint reference where events originate
Error handling
Error codes that fire router.error_burst
Security guide
Secret rotation + network segmentation
Was this page helpful?
Let us know how we can improve