Beta

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

1

Register an endpoint in console

Settings → Webhooks → New endpoint. Provide your URL (HTTPS required) + select events → receive a signing_secret (whsec_…).

2

Verify the signature on your server

Use signing_secret to verify the SkyAI-Signature header (HMAC-SHA256) on every event. Reject mismatches with 401.

3

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

EventTriggerRecommended sink
router.fallback_triggeredPrimary failed; fallback served.Slack #ops
router.budget_exceededRequest rejected by budget cap.Datadog counter + Slack
router.error_burstTenant error rate > 5% over 5-min window.PagerDuty
router.policy_changedPolicy edited and published in console.Audit log (immutable)
agent.run_completedAgent run reaches terminal state.Your downstream workflow
cache.evictedCache hit rate sustained below threshold.Datadog
billing.threshold_crossedMonthly usage hit a configured threshold (50/80/100%).Slack #finance
incident.createdSkyAIApp 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

Tip: Webhooks graduate from Beta to Stable at public beta — signature format and payload schemas freeze then.Changelog

Was this page helpful?

Let us know how we can improve

Webhooks | SkyAIApp Docs — SkyAIApp