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

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) 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)):

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.

-- 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:

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:

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)):

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:

// 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

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

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:

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)). After each dispatch batch, the system calls disableWebhooksWithHighFailures(10) — any webhook with 10+ failures gets automatically disabled.

Check delivery history:

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) Block Kit message format
Discord [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) 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, database layer in src/lib/db/webhooks.ts and 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →