Skip to content

Recipe: Webhook Delivery

A pattern for reliably delivering webhooks to external services with aggressive retries, idempotency, and HMAC signing.

The job

import hashlib
import hmac
import json
import aiohttp
from soniq import Soniq
from soniq.job import JobContext

eq = Soniq(database_url="postgresql://localhost/myapp")


@eq.job(queue="webhooks", max_retries=5, retry_delay=[1, 5, 30, 120, 600])
async def deliver_webhook(
    url: str,
    event: str,
    payload: dict,
    event_id: str,
    secret: str,
    ctx: JobContext,
):
    # Idempotency: check if this event was already delivered
    if await was_delivered(event_id):
        return

    body = json.dumps(payload).encode()
    signature = sign_payload(body, secret)

    headers = {
        "Content-Type": "application/json",
        "X-Webhook-Event": event,
        "X-Webhook-Delivery": event_id,
        "X-Webhook-Signature": signature,
        "User-Agent": "MyApp-Webhook/1.0",
    }

    async with aiohttp.ClientSession() as session:
        async with session.post(url, data=body, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
            if resp.status >= 400:
                raise RuntimeError(f"Webhook delivery failed: HTTP {resp.status}")

    await mark_delivered(event_id)

Signing payloads

Sign every payload with HMAC-SHA256 so receivers can verify authenticity:

def sign_payload(body: bytes, secret: str) -> str:
    digest = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256,
    ).hexdigest()
    return f"sha256={digest}"

The receiver verifies with the same logic:

def verify_signature(payload_bytes: bytes, secret: str, signature: str) -> bool:
    expected = sign_payload(payload_bytes, secret)
    return hmac.compare_digest(expected, signature)

Enqueuing

When a business event occurs, enqueue the webhook delivery:

import uuid

async def notify_subscribers(event: str, data: dict):
    subscribers = await get_webhook_subscribers(event)
    event_id_base = str(uuid.uuid4())

    for sub in subscribers:
        await eq.enqueue(
            deliver_webhook,
            url=sub.url,
            event=event,
            payload={"event": event, "data": data},
            event_id=f"{event_id_base}-{sub.id}",
            secret=sub.secret,
        )

Each subscriber gets their own job with a unique event_id. If one subscriber's endpoint is down, it doesn't block deliveries to others.

Why this works

Aggressive retry schedule. retry_delay=[1, 5, 30, 120, 600] retries at 1 second, 5 seconds, 30 seconds, 2 minutes, and 10 minutes. Most transient failures recover within the first few retries. The longer tail gives endpoints time to come back from extended outages.

Idempotency via event_id. The event_id uniquely identifies each delivery attempt. If a worker crashes after the HTTP request succeeds but before marking the job done, the retry checks was_delivered(event_id) and skips the duplicate. Implement this with a simple database table:

async def was_delivered(event_id: str) -> bool:
    return await db.fetchval(
        "SELECT EXISTS(SELECT 1 FROM webhook_deliveries WHERE event_id = $1)",
        event_id,
    )

async def mark_delivered(event_id: str):
    await db.execute(
        "INSERT INTO webhook_deliveries (event_id) VALUES ($1) ON CONFLICT DO NOTHING",
        event_id,
    )

Dedicated queue. Webhook delivery is I/O-bound (waiting on external HTTP responses) and failure-prone. A separate "webhooks" queue means slow or failing endpoints don't starve your other jobs.

10-second HTTP timeout. Don't let a hanging endpoint tie up a worker slot forever. 10 seconds is generous for a webhook receiver. If they can't respond in time, retry later.

Payload format

Follow a consistent structure for all webhook payloads:

{
  "event": "order.created",
  "data": {
    "order_id": "ord_abc123",
    "total": 99.99,
    "currency": "USD",
    "created_at": "2026-03-28T14:30:00Z"
  }
}

Headers

Include these headers with every delivery so receivers can route, verify, and deduplicate:

Header Description
Content-Type application/json
X-Webhook-Event Event type (e.g. order.created)
X-Webhook-Delivery Unique delivery ID for deduplication
X-Webhook-Signature HMAC-SHA256 signature (sha256=...)
User-Agent Your app identifier

Transactional enqueue

For critical events, enqueue the webhook inside the same transaction as the business data:

await eq.ensure_initialized()
async with eq.backend.acquire() as conn:
    async with conn.transaction():
        order_id = await conn.fetchval("INSERT INTO orders (...) RETURNING id", ...)
        await eq.enqueue(
            deliver_webhook,
            connection=conn,
            url=subscriber.url,
            event="order.created",
            payload={"event": "order.created", "data": {"order_id": order_id}},
            event_id=f"order-created-{order_id}",
            secret=subscriber.secret,
        )

If the order INSERT fails, the webhook job is never created.