# How to Configure Webhooks with HMAC Signing and Exponential Backoff in OmniRoute

> Configure OmniRoute webhooks with HMAC signing and exponential backoff. Store a secret for secure signing and trigger automatic delivery with retries.

- Repository: [Diego Rodrigues de Sa e Souza/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- Tags: how-to-guide
- Published: 2026-08-03

---

**Store a secret in the webhook record to enable HMAC-SHA256 signing, then call `notifyWebhookEvent()` to trigger automatic delivery with exponential-backoff retries across three attempts.**

OmniRoute ships with a built-in webhook subsystem that signs payloads, retries failures, and protects downstream services from overload. All dispatch logic lives in [[`src/lib/webhookDispatcher.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts) according to the `diegosouzapw/OmniRoute` source code.

## How Webhook Delivery Works in OmniRoute

The dispatcher supports four webhook types:

- **`custom`** — generic HTTP endpoints with full HMAC signing support
- **`slack`** — Slack-compatible payload formatting, no HMAC
- **`discord`** — Discord-compatible formatting, no HMAC  
- **`telegram`** — Telegram Bot API formatting, no HMAC

Only the `custom` type uses the signed-payload flow described below.

## Setting Up HMAC-Signed Webhooks

### Step 1: Create the Webhook Record

Insert a row into the webhooks table (schema in [[`src/lib/db/webhooks.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhooks.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhooks.ts)):

| Field | Required | Value |
|-------|----------|-------|
| `url` | Yes | Target HTTPS endpoint |
| `events` | Yes | Array like `["model.completed"]` or `["*"]` for all |
| `kind` | Yes | `"custom"` (for HMAC signing) |
| `secret` | No* | Random string for HMAC-SHA256 |
| `enabled` | Yes | `true` |

*Required only if you want HMAC signing; omit for unsigned delivery.

```sql
-- Example insertion (adapt to your ORM)
INSERT INTO webhooks (url, events, kind, secret, enabled)
VALUES (
  'https://api.example.com/omniroute-events',
  '["model.completed", "quota.exhausted"]',
  'custom',
  'whsec_32_random_bytes_of_entropy_here',
  true
);

```

### Step 2: Verify Signature on Your Endpoint

OmniRoute sends these headers on every `custom` webhook delivery:

```http
Content-Type: application/json
User-Agent: OmniRoute-Webhook/1.0
X-Webhook-Event: model.completed
X-Webhook-Timestamp: 2024-01-15T09:30:00.000Z
X-Webhook-Signature: sha256=a3f5...

```

The `X-Webhook-Signature` header contains `sha256=` followed by the hex-encoded HMAC of the raw JSON body. Compute the signature in your handler:

```python
import hmac
import hashlib

def verify_omniroute_signature(payload_bytes: bytes, secret: str, signature_header: str) -> bool:
    expected_sig = hmac.new(
        secret.encode('utf-8'),
        payload_bytes,
        hashlib.sha256
    ).hexdigest()
    # Header format: "sha256=<hex>"

    received_sig = signature_header.removeprefix("sha256=")
    return hmac.compare_digest(expected_sig, received_sig)

```

The signing implementation in OmniRoute (lines 20-22 of [[`src/lib/webhookDispatcher.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts)):

```typescript
function signPayload(body: string, secret: string): string {
  const sig = crypto.createHmac("sha256", secret).update(body).digest("hex");
  return `sha256=${sig}`;
}

```

## Exponential Backoff Retry Behavior

When you configure webhooks with HMAC signing and exponential backoff in OmniRoute, failed deliveries automatically retry with increasing delays.

**Retry triggers:**

- HTTP status ≥ 500 (server errors)
- Network timeouts or connection failures

**Retry schedule (default 3 retries):**

| Attempt | Delay |
|---------|-------|
| 1 | immediate |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 (final) | 8 seconds |

The retry loop in `deliverWebhook` (lines 71-129) implements this logic:

```typescript
// Simplified from src/lib/webhookDispatcher.ts lines 71-129
for (let attempt = 0; attempt <= maxRetries; attempt++) {
  try {
    const res = await fetch(url, { 
      method: "POST", 
      headers, 
      body, 
      signal // 10s abort timeout
    });
    
    if (res.ok || res.status < 500) {
      return { success: res.ok, status: res.status };
    }
    
    // Server error → retry with backoff if attempts remain
    if (attempt < maxRetries) {
      const delayMs = Math.pow(2, attempt) * 1000;
      await new Promise(r => setTimeout(r, delayMs));
    }
  } catch (error) {
    if (attempt === maxRetries) {
      return { success: false, status: 0, error: error.message };
    }
    const delayMs = Math.pow(2, attempt) * 1000;
    await new Promise(r => setTimeout(r, delayMs));
  }
}

```

Every request includes a 10-second abort signal via `AbortController`. You can override `maxRetries` per call when using `deliverWebhook` directly, though the public API defaults to 3.

## Triggering Webhook Events

### Fire-and-Forget (Recommended for Hot Paths)

Use `notifyWebhookEvent` (lines 137-144) to dispatch without blocking:

```typescript
import { notifyWebhookEvent } from "@/lib/webhookDispatcher";

// After a model completes
notifyWebhookEvent("model.completed", {
  modelId: "gpt-4",
  requestId: "abc123",
  durationMs: 245,
  tokensUsed: 892
});

// When quota threshold hit
notifyWebhookEvent("quota.exhausted", {
  accountId: "acct_42",
  used: 999,
  limit: 1000,
  resetAt: "2024-01-16T00:00:00Z"
});

```

This function is non-blocking. OmniRoute will:

1. Build the `WebhookPayload` with `event`, `timestamp`, and `data`
2. Query enabled webhooks listening to this event
3. Route `custom` webhooks through `deliverWebhook` (signed + retried)
4. Route `slack`/`discord`/`telegram` through `deliverRaw` (unsigned, no retry)

### Awaitable Delivery (Tests, CLI Tools)

Use `dispatchEvent` for synchronous result handling:

```typescript
import { dispatchEvent } from "@/lib/webhookDispatcher";

const results = await dispatchEvent("model.completed", {
  modelId: "claude-3-opus",
  requestId: "xyz789"
});

// Results array contains success/failure per webhook
for (const r of results) {
  console.log(r.webhookId, r.success, r.status, r.error);
}

```

## Automatic Protection Against Flaky Endpoints

OmniRoute monitors delivery health via the `webhookDeliveries` table (managed in [[`src/lib/db/webhookDeliveries.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhookDeliveries.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhookDeliveries.ts)). After each dispatch batch, the system calls `disableWebhooksWithHighFailures(10)` — any webhook with 10+ failures gets automatically disabled.

Check delivery history:

```typescript
import { getWebhookDeliveries } from "@/lib/db/webhookDeliveries";

const recent = await getWebhookDeliveries(webhookId, { limit: 20 });
// recent[].status, recent[].latencyMs, recent[].error

```

## Integration-Specific Payloads (Unsigned)

For Slack, Discord, and Telegram webhooks, OmniRoute bypasses HMAC and uses tailored formatters:

| Integration | Formatter Location | Notes |
|-------------|-------------------|-------|
| Slack | [[`src/lib/webhooks/integrations/slack.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/slack.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/slack.ts) | Block Kit message format |
| Discord | [[`src/lib/webhooks/integrations/discord.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/discord.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/discord.ts) | Embeds for rich formatting |
| Telegram | [[`src/lib/webhooks/integrations/telegram.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/telegram.ts)](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhooks/integrations/telegram.ts) | Bot API; decrypts token from `metadata_encrypted` |

These use `deliverRaw` (lines 39-68) which has no retry logic — integrations are expected to handle their own reliability.

## Summary

- **Enable HMAC signing** by setting a `secret` on `kind: "custom"` webhooks — OmniRoute adds `X-Webhook-Signature` automatically
- **Exponential backoff** retries run 3 times by default with delays of 2ⁿ seconds
- **Trigger events** via `notifyWebhookEvent()` for fire-and-forget or `dispatchEvent()` for awaitable results
- **Automatic disabling** protects the system after 10 delivery failures per webhook
- **Source files:** Core logic in [`src/lib/webhookDispatcher.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts), database layer in [`src/lib/db/webhooks.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhooks.ts) and [`src/lib/db/webhookDeliveries.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/db/webhookDeliveries.ts)

## Frequently Asked Questions

### How do I verify the HMAC signature in my webhook handler?

Extract the `X-Webhook-Signature` header, remove the `sha256=` prefix, and compute `HMAC-SHA256(body, secret)` using a constant-time comparison. The payload is the raw request body bytes before any parsing — do not prettify or re-serialize the JSON.

### Can I change the number of retry attempts?

The internal `deliverWebhook` function accepts a `maxRetries` parameter, but the public APIs (`notifyWebhookEvent`, `dispatchEvent`) use the default of 3. To customize, import `deliverWebhook` directly from [`src/lib/webhookDispatcher.ts`](https://github.com/diegosouzapw/OmniRoute/blob/main/src/lib/webhookDispatcher.ts) and call it with your preferred retry count.

### Why don't Slack/Discord/Telegram webhooks use HMAC signing?

These integrations use platform-specific authentication mechanisms (OAuth tokens, bot tokens) stored in `metadata_encrypted`. The `deliverRaw` helper handles these without HMAC since the receiving platform validates the token separately. Use `kind: "custom"` when you need signature verification on your own endpoint.

### What happens if all retries fail?

The final attempt returns `{ success: false, status: 0 }` (network error) or reflects the last HTTP status received. The failure is recorded in `webhookDeliveries`, counting toward the 10-failure threshold for automatic disabling. No further automatic action occurs — implement external alerting if critical webhooks fail persistently.