Spaces:
Runtime error
Runtime error
| title: "Webhooks" | |
| version: 3.8.2 | |
| lastUpdated: 2026-05-13 | |
| # Webhooks | |
| > **Source of truth:** `src/lib/webhookDispatcher.ts`, `src/lib/db/webhooks.ts`, `src/app/api/webhooks/` | |
| > **Last updated:** 2026-05-13 β v3.8.0 | |
| 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. | |
| ## Supported Events | |
| 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.ping` events are still landing. Check `grep dispatchEvent` to see | |
| > which paths currently invoke the dispatcher in your release. | |
| ## Architecture | |
| ``` | |
| 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. | |
| ## HMAC Signing | |
| 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 (not `X-OmniRoute-*`). The signature | |
| > value is `sha256=<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. | |
| ### Verifying on the receiver | |
| ```typescript | |
| 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. | |
| ## Retry & Failure Policy | |
| `deliverWebhook(url, payload, secret, maxRetries = 3)`: | |
| - 10 second timeout per attempt (`AbortController`). | |
| - HTTP 2xx counts as success. | |
| - HTTP 3xx/4xx counts as a non-retryable final status β recorded as delivered | |
| with `success = res.ok`. | |
| - HTTP 5xx and network errors are retried with exponential backoff: | |
| `2^attempt * 1000 ms` (1s, 2s, 4s). | |
| - After `maxRetries`, the delivery is recorded as failed. | |
| - Each delivery updates `last_triggered_at`, `last_status`, and either resets | |
| or increments `failure_count`. | |
| - The dispatcher calls `disableWebhooksWithHighFailures(10)` after each fan-out, | |
| so any webhook with `failure_count >= 10` is automatically disabled. | |
| ## Database | |
| 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. | |
| ## REST API | |
| 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. | |
| ### Create webhook | |
| ```bash | |
| 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. | |
| ### Test webhook | |
| ```bash | |
| 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. | |
| ## Dashboard | |
| The dashboard page at `/dashboard/webhooks` (see | |
| `src/app/(dashboard)/dashboard/webhooks/page.tsx`) provides: | |
| - Create/edit webhooks with an event picker | |
| - Status indicator (active / inactive / errored) based on `enabled`, | |
| `failure_count`, and `last_status` | |
| - One-click test delivery | |
| - Manual enable/disable toggle | |
| ## Payload Examples | |
| ### request.completed | |
| ```json | |
| { | |
| "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 | |
| } | |
| } | |
| ``` | |
| ### provider.error | |
| ```json | |
| { | |
| "event": "provider.error", | |
| "timestamp": "2026-05-13T20:31:00.000Z", | |
| "data": { | |
| "provider": "anthropic", | |
| "status": 503, | |
| "consecutive_failures": 5, | |
| "circuit_state": "open" | |
| } | |
| } | |
| ``` | |
| ### test.ping | |
| ```json | |
| { | |
| "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). | |
| ## Best Practices | |
| - **Verify the signature on every delivery** against the raw body β prevents | |
| spoofed POSTs from anyone who guesses your webhook URL. | |
| - **Respond 2xx within ~5 seconds** β the dispatcher times out at 10 s. Slow | |
| receivers will eat retries and inflate `failure_count`. | |
| - **Make handlers idempotent** β retries and at-least-once delivery semantics | |
| mean duplicates are possible. | |
| - **Subscribe minimally** β list only events you actually consume; `"*"` will | |
| add cost on receivers you do not control. | |
| - **Watch `failure_count`** β endpoints are auto-disabled at 10 consecutive | |
| failures; reset by calling `PUT /api/webhooks/[id]` with `enabled: true` | |
| after fixing the receiver. | |
| - **Rotate secrets periodically** β `PUT` a new `secret`, deploy the new value | |
| to the receiver, and confirm via the test endpoint. | |
| ## See Also | |
| - [API_REFERENCE.md](../reference/API_REFERENCE.md) β full management API surface | |
| - [RESILIENCE_GUIDE.md](../architecture/RESILIENCE_GUIDE.md) β circuit breaker / cooldown | |
| semantics that drive `provider.error` / `provider.recovered` | |
| - Source: `src/lib/webhookDispatcher.ts`, `src/lib/db/webhooks.ts` | |