Webhook Routing Patterns: Verify → Enqueue → ACK (with Node.js and Python)

By Chris Moen • Published 2026-03-02

Learn the essential webhook routing patterns and implement a verify → enqueue → ACK flow. Includes Node.js and Python examples, secure signature checks, idempotent handlers, and clear retry/ACK rules.

Breyta workflow automation

Quick answer: webhook routing patterns

The most reliable webhook routing setups use a lightweight HTTP router that verifies the request, extracts an event type, and enqueues work for background processing. Proven routing patterns include:

  • Path-based routing: POST /webhooks/github, /webhooks/stripe
  • Header-based routing: dispatch using headers such as X-Event-Type or X-Topic
  • Payload-based routing: dispatch using payload.type or payload.event fields
  • Topic or tenant label: use a subscription/topic label to segment handlers
  • Single endpoint vs provider-specific endpoints: one router that branches internally, or one endpoint per provider
  • Fetch-before-process: verify and ACK quickly, then fetch full objects from the provider API before heavy work (watch rate limits)

Combine these with a verify → enqueue → ACK flow, idempotent handlers, and explicit retry/backoff to achieve durable delivery.

How to automate webhook routing

Automate webhook routing by verifying, routing, and enqueueing events, then acknowledging fast. Use idempotent handlers and retries to keep delivery reliable. Secure each step and log what you process. For a complementary walkthrough focused on data pipelines, see how to automate webhook routing for real-time BI pipelines.

A webhook is an HTTP callback that sends an event to your server when something happens in another system.

Why automate webhook routing?

Automation removes manual wiring and fragile logic. It gives you consistent verification, routing, and error handling. It also makes scaling and observability easier.

How a webhook router works

A router receives an HTTP request, verifies it, extracts an event type, and dispatches it to a handler. It acks fast and hands work to a background worker. If enqueue fails, it returns an error to trigger a retry upstream.

Core pattern:

  • Verify → enqueue → ACK
  • Route by event type to a handler map
  • Process out of band in a worker

Webhook routing patterns you can use

Pick one clear rule and keep it stable. The router should map event metadata to handlers.

  • Path-based: POST /webhooks/github → GitHub router. POST /webhooks/stripe → Stripe router.
  • Header-based: Use X-Event-Type or X-Topic headers for dispatch.
  • Payload-based: Use payload.type or payload.event to route.
  • Topic label: Use a named subscription label for routing and to separate subscriptions to the same topic.
  • Fetch-before-process: After verification, fetch the authoritative resource from the provider API before handling. This yields consistency but requires rate-limit awareness.

Secure and acknowledge webhooks

Verify the signature before any processing. Do not include sensitive data in payloads. Return a 2xx only after a successful enqueue.

Security checklist:

  • Validate source IP or allowlist if offered
  • Verify HMAC or signed payloads
  • Use HTTPS only
  • Reject stale timestamps
  • Limit body size and parse safely

ACK rules:

  • Return 2xx after enqueue succeeds
  • Return 4xx on bad signatures
  • Return 5xx on transient enqueue errors

Retries and idempotency

Expect duplicates and out-of-order delivery. Design consumers to be idempotent. Use exponential backoff with jitter for internal retries.

Concrete steps:

  • Store processed event IDs with a status
  • Skip work if event ID is already done
  • Use transactional updates where you can
  • Use a dead-letter queue for exhausted retries

Node.js example: verify, route, and enqueue

This example shows Express with HMAC verification, routing by payload.type, quick ACK, and an in-memory queue. Replace the queue with a durable one in production.

// package.json deps: express const express = require("express"); const crypto = require("crypto");

const app = express();

// Capture raw body for signature verification app.use("/webhooks/:provider", express.raw({ type: "/", limit: "1mb" }));

// In-memory queue and idempotency store (use a DB in production) const jobs = []; const processed = new Set();

const SECRET = process.env.WEBHOOK_SECRET || "replace-me";

// Timing-safe compare function safeEqual(a, b) { const ba = Buffer.from(a); const bb = Buffer.from(b); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); }

// Handlers map const handlers = { "invoice.paid": async (event) => { // Do work, e.g., mark invoice as paid console.log("Handled invoice.paid", event.id); }, "user.created": async (event) => { // Do work, e.g., create profile console.log("Handled user.created", event.id); }, };

function enqueue(job) { jobs.push(job); }

// Background worker with basic retry + jitter async function worker() { while (true) { const job = jobs.shift(); if (!job) { await new Promise((r) => setTimeout(r, 100)); // idle wait continue; } const { event, attempt = 1 } = job; const maxAttempts = 5;

if (processed.has(event.id)) { console.log("Skip duplicate", event.id); continue; }

const handler = handlers[event.type]; if (!handler) { console.warn("No handler for", event.type); processed.add(event.id); continue; }

try { await handler(event); processed.add(event.id); } catch (err) { console.error("Handler failed", event.id, err.message); if (attempt enqueue({ event, attempt: attempt + 1 }), backoff + jitter); } else { console.error("Dead-letter", event.id); // Push to a DLQ here } } } } worker(); // start the worker loop

app.post("/webhooks/:provider", async (req, res) => { const provider = req.params.provider;

// Verify signature const sig = req.get("X-Signature") || ""; // Example header. Adjust for your provider. const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); if (!safeEqual(expected, sig)) { return res.status(400).send("Bad signature"); }

// Parse JSON payload after verifying let payload; try { payload = JSON.parse(req.body.toString("utf8")); } catch { return res.status(400).send("Bad JSON"); }

// Extract event metadata const event = { id: payload.id || req.get("X-Event-Id") || "", type: payload.type || req.get("X-Event-Type") || "", data: payload.data || payload, provider, receivedAt: Date.now(), };

if (!event.id || !event.type) { return res.status(422).send("Missing event id or type"); }

// Enqueue then ACK try { enqueue({ event, attempt: 1 }); return res.status(202).send("Accepted"); } catch { return res.status(503).send("Enqueue failed"); } });

app.listen(3000, () => { console.log("Webhook server on :3000"); }); Notes:

  • Use a durable queue such as SQS, RabbitMQ, or a job library with Redis.
  • Persist processed IDs in a database table to survive restarts.

Python example with FastAPI

This example verifies HMAC, routes by payload["type"], enqueues with asyncio tasks, and handles idempotency in memory.

pip install fastapi uvicorn

import hmac import hashlib import json import asyncio from fastapi import FastAPI, Request, Header, HTTPException

app = FastAPI() SECRET = b"replace-me"

processed = set() queue = asyncio.Queue()

handlers = { "invoice.paid": lambda event: print("Handled invoice.paid", event["id"]), "user.created": lambda event: print("Handled user.created", event["id"]), }

def safe_equal(a: bytes, b: bytes) -> bool: return hmac.compare_digest(a, b)

async def worker(): while True: event, attempt = await queue.get() try: if event["id"] in processed: print("Skip duplicate", event["id"]) continue handler = handlers.get(event["type"]) if not handler: print("No handler for", event["type"]) processed.add(event["id"]) continue

Replace with your async work

handler(event) processed.add(event["id"]) except Exception as e: attempt += 1 if attempt Tip: Some ecosystems offer routers. For GitHub in Python you can use gidgethub.routing to dispatch events by type with decorators.

pip install gidgethub

from gidgethub import sansio from gidgethub.routing import Router

router = Router()

@router.register("issues", action="opened") async def on_issue_opened(event, gh, args, *kwargs):

Handle new issue

...

In your request handler:

event = sansio.Event.from_http(headers, body, secret=SECRET)

await router.dispatch(event, gh=gh_client)

Test webhooks locally

Expose your local port and capture requests. Send real test events from the provider.

  • Use ngrok to expose localhost
  • Use webhook.site to get a temporary URL and inspect payloads
  • Use your provider’s test send feature if available

Logging and monitoring

Log every delivery with IDs, types, and status. Track failures and retries. Add metrics for enqueue time, handler time, and error rates. Store bodies only if safe and allowed.

Suggested fields:

  • event_id, provider, type
  • signature_valid, enqueue_ok
  • handler_status, attempt, duration_ms

Common pitfalls to avoid

  • Parsing JSON before verifying the signature
  • Doing heavy work before ACK
  • Not handling duplicate deliveries
  • Returning 2xx on failed enqueue
  • Logging secrets or full payloads without need

FAQ

What is webhook routing?

It is the process of dispatching incoming webhook events to the right handler. Routing uses headers, paths, or payload fields like type or topic.

Should I process the event before I ACK?

No. Verify and enqueue first. Then ACK fast. Do the heavy work in a background worker.

How do I prevent duplicate processing?

Store processed event IDs. Check before work. Design handlers to be idempotent so repeats do not change results.

How do I secure incoming webhooks?

Use HTTPS. Verify signed payloads or HMAC headers. Reject bad timestamps and large bodies. Never trust source fields without checks.

What if I receive an event I do not handle?

Log it with the event type and ID. Optionally store it for analysis. Return 2xx if your policy allows ignoring unknown events, or 4xx if you want the sender to retry to a different endpoint.

Where Breyta fits

Breyta is a workflow and agent orchestration platform for coding agents. It is the workflow layer around the coding agent you already use, built for multi-step automations, long-running jobs, approval-heavy flows, and agent orchestration. Breyta provides deterministic runtime behavior, explicit approvals and waits, versioned releases, and clear run history. You can orchestrate local agents and VM-backed agents over SSH. These properties map well to webhook-driven automations where you want reliable, auditable runs triggered by external events.