260 lines
9.3 KiB
Markdown
260 lines
9.3 KiB
Markdown
---
|
|
title: "Webhooks"
|
|
version: 3.8.40
|
|
lastUpdated: 2026-06-28
|
|
---
|
|
|
|
# Webhooks
|
|
|
|
> **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.
|
|
|
|
## 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`
|