docs/frameworks/WEBHOOKS.md
Source of truth:
src/lib/webhookDispatcher.ts,src/lib/db/webhooks.ts,src/app/api/webhooks/Last updated: 2026-06-28 — v3.8.40
OmniRoute can fire HTTP webhooks on platform events. Use them to integrate with Slack, PagerDuty, Datadog, internal alerting services, or any HTTP receiver.
The dispatcher signs each delivery with HMAC-SHA256, retries on transient failures, tracks delivery health per webhook, and auto-disables endpoints that keep failing.
The WebhookEvent type (src/lib/webhookDispatcher.ts) currently models:
| Event | Fires when |
|---|---|
request.completed | A proxied request completes successfully |
request.failed | A proxied request fails after all retries/fallback |
provider.error | A provider returns an error eligible for circuit-breaking |
provider.recovered | A previously failing provider returns to a healthy state |
quota.exceeded | An API key crosses a budget/quota threshold |
combo.switched | A combo strategy switches its primary target |
test.ping | Synthetic event used by the test endpoint |
Subscriptions accept the literal "*" to receive every event. Unknown event
names in events are ignored at dispatch time.
Note: the dispatcher API is wired, but production call sites for some of the non-
test.pingevents are still landing. Checkgrep dispatchEventto see which paths currently invoke the dispatcher in your release.
Caller (handler, service, monitor)
dispatchEvent(event, data) [src/lib/webhookDispatcher.ts]
-> getEnabledWebhooks() [src/lib/db/webhooks.ts]
-> filter by webhook.events
-> for each match (in parallel):
deliverWebhook(url, payload, secret)
build payload { event, timestamp, data }
sign body with HMAC-SHA256 (if secret present)
POST with 10s timeout
retry up to 3 times on 5xx / network error
recordWebhookDelivery(id, status, success)
-> disableWebhooksWithHighFailures(10)
Dispatch is fire-and-forget for the caller: Promise.allSettled swallows
per-webhook errors so one bad receiver cannot block the others.
When a webhook has a secret, OmniRoute signs the JSON body and sends:
Content-Type: application/json
User-Agent: OmniRoute-Webhook/1.0
X-Webhook-Event: <event>
X-Webhook-Timestamp: <ISO-8601>
X-Webhook-Signature: sha256=<hex HMAC-SHA256(secret, body)>
Header names use the
X-Webhook-*prefix (notX-OmniRoute-*). The signature value issha256=<hex>— verify the full prefix.
If createWebhook is called without a secret, the DB module generates one
(whsec_<48 hex>) so all webhooks are signed by default.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, signature: string, secret: string) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
Always verify against the raw request body, before any JSON parsing.
deliverWebhook(url, payload, secret, maxRetries = 3):
AbortController).success = res.ok.2^attempt * 1000 ms (1s, 2s, 4s).maxRetries, the delivery is recorded as failed.last_triggered_at, last_status, and either resets
or increments failure_count.disableWebhooksWithHighFailures(10) after each fan-out,
so any webhook with failure_count >= 10 is automatically disabled.Table webhooks (migration 011_webhooks.sql):
| Column | Type | Notes |
|---|---|---|
id | TEXT PK | UUID |
url | TEXT | Destination URL |
events | TEXT | JSON array; default ["*"] |
secret | TEXT | HMAC secret (auto-generated if not given) |
enabled | INT | 0/1; defaults to 1 |
description | TEXT | Optional human label |
created_at | TEXT | datetime('now') |
last_triggered_at | TEXT | Updated on every delivery attempt |
last_status | INT | HTTP status of the last attempt (0 = network) |
failure_count | INT | Resets to 0 on success, +1 on failure |
There is no separate webhook_deliveries table in the current schema —
delivery history is aggregated on the webhooks row. If you need full audit
history, consume request.completed / audit style events from a downstream
log store.
All endpoints require management auth (requireManagementAuth).
| Endpoint | Method | Description |
|---|---|---|
/api/webhooks | GET | List webhooks (secrets masked) |
/api/webhooks | POST | Create webhook |
/api/webhooks/[id] | GET | Webhook detail (full secret) |
/api/webhooks/[id] | PUT | Update fields |
/api/webhooks/[id] | DELETE | Remove |
/api/webhooks/[id]/test | POST | Fire a test.ping (no retries) |
GET /api/webhooks masks the secret to <first 10 chars>... to avoid leaking
on listing pages. Use the [id] GET when you actually need the secret.
curl -X POST http://localhost:20128/api/webhooks \
-H "Cookie: auth_token=..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.slack.com/services/...",
"secret": "whsec_my_shared_secret",
"events": ["quota.exceeded", "provider.error"],
"description": "Slack alerts"
}'
If secret is omitted, the server generates a whsec_<hex> secret and returns
it in the response.
curl -X POST http://localhost:20128/api/webhooks/<id>/test \
-H "Cookie: auth_token=..."
Returns { delivered, status, error }. No retries are attempted — useful for
quickly validating that the receiver accepts the payload and signature.
The dashboard page at /dashboard/webhooks (see
src/app/(dashboard)/dashboard/webhooks/page.tsx) provides:
enabled,
failure_count, and last_status{
"event": "request.completed",
"timestamp": "2026-05-13T20:30:00.123Z",
"data": {
"trace_id": "...",
"api_key_id": "...",
"provider": "openai",
"model": "gpt-5",
"status": 200,
"tokens_in": 142,
"tokens_out": 350,
"cost_usd": 0.0042
}
}
{
"event": "provider.error",
"timestamp": "2026-05-13T20:31:00.000Z",
"data": {
"provider": "anthropic",
"status": 503,
"consecutive_failures": 5,
"circuit_state": "open"
}
}
{
"event": "test.ping",
"timestamp": "2026-05-13T20:32:00.000Z",
"data": {
"message": "Test webhook delivery from OmniRoute",
"webhookId": "<uuid>"
}
}
Field shapes for non-test.ping events are defined by the call sites that emit
them; treat the data object as forward-compatible (add fields, don't depend on
absence).
failure_count."*" will
add cost on receivers you do not control.failure_count — endpoints are auto-disabled at 10 consecutive
failures; reset by calling PUT /api/webhooks/[id] with enabled: true
after fixing the receiver.PUT a new secret, deploy the new value
to the receiver, and confirm via the test endpoint.provider.error / provider.recoveredsrc/lib/webhookDispatcher.ts, src/lib/db/webhooks.ts