Skip to content

webhook-patterns

Webhook processing patterns — signature verification, idempotency, retry handling, and queue integration

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

Webhook processing patterns — signature verification, idempotency, retry handling, and queue integration

Agent definition

webhook-patterns.md
description: Webhook processing patterns — signature verification, idempotency, retry handling, and queue integration

Webhook Patterns for Node.js APIs

> **Scope**: Receiving and processing webhooks from Stripe, GitHub, and generic HTTP POST webhooks. Signature verification, idempotency keys, retry-safe handlers, and queue offloading. > **Version range**: Node.js 18+, Express 4.x/5.x > **Generated**: 2026-04-08

---

Overview

Two failure phases: verification (signature, replay prevention) and processing (idempotency, error handling). Correct architecture: separate acknowledgment (fast 200) from processing (durable queue).

---

Pattern Table

| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | Raw body middleware before JSON parse | Express 4+ | Stripe/GitHub signature verification | After `express.json()` — body already consumed | | Idempotency key deduplication | Always | Retry-prone events (payments, emails) | Fire-and-forget notifications | | Queue offloading (BullMQ) | BullMQ 3+ | Processing > 2 seconds | Sub-100ms handlers | | `rawBody` middleware | Express 4+ | HMAC signature verification | When body is already parsed |

---

Correct Patterns

Raw Body Preservation for Signature Verification

Stripe and GitHub sign the raw body. Once `express.json()` parses it, original bytes are gone and signature check fails.

import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';

// MUST register raw body middleware BEFORE express.json() for webhook routes
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
app.use('/webhooks/github', express.raw({ type: 'application/json' }));

// All other routes get JSON parsing
app.use(express.json());

// Stripe webhook handler
app.post('/webhooks/stripe', (req, res) => {
  const sig = req.headers['stripe-signature'] as string;
  const rawBody = req.body as Buffer; // Buffer, not object

  if (!verifyStripeSignature(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)) {
    res.status(400).json({ error: 'Invalid signature' });
    return;
  }

  const event = JSON.parse(rawBody.toString('utf-8'));
  // Acknowledge immediately, process asynchronously
  res.status(200).json({ received: true });
  processWebhookEvent(event).catch(console.error);
});

**Why**: After `express.json()`, `req.body` is a parsed object. Re-serializing changes whitespace/ordering, breaking the HMAC.

---

Idempotency with Redis

Store processed event IDs to prevent duplicate processing.

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

async function processIdempotent(
  eventId: string,
  handler: () => Promise<void>
): Promise<{ processed: boolean; duplicate: boolean }> {
  const key = `webhook:processed:${eventId}`;
  const ttl = 24 * 60 * 60; // 24 hours — longer than retry window

  // SET NX: only set if key doesn't exist (atomic)
  const acquired = await redis.set(key, '1', { NX: true, EX: ttl });

  if (!acquired) {
    // Already processed — safe to return 200 without re-processing
    return { processed: false, duplicate: true };
  }

  try {
    await handler();
    return { processed: true, duplicate: false };
  } catch (err) {
    // Delete key so retry can attempt processing again
    await redis.del(key);
    throw err;
  }
}

// Usage in webhook handler:
app.post('/webhooks/stripe', async (req, res) => {
  const event = parseStripeEvent(req.body, req.headers['stripe-signature'] as string);

  res.status(200).json({ received: true }); // ACK first

  const { duplicate } = await processIdempotent(event.id, async () => {
    await handleStripeEvent(event);
  });

  if (duplicate) {
    console.log(`[info] duplicate event ${event.id} — skipped`);
  }
});

**Why**: Without idempotency, Stripe retries cause duplicate fulfillment. Redis `SET NX` is atomic: no two workers claim the same event.

---

Queue Offloading for Slow Handlers

For handlers > 2 seconds: respond 200 immediately, push to BullMQ.

import { Queue } from 'bullmq';

const webhookQueue = new Queue('webhook-events', {
  connection: { url: process.env.REDIS_URL },
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: 100, // Keep last 100 for debugging
    removeOnFail: 1000,
  },
});

app.post('/webhooks/stripe', async (req, res) => {
  // Verify signature synchronously (fast)
  const sig = req.headers['stripe-signature'] as string;
  if (!verifyStripeSignature(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!)) {
    res.status(400).json({ error: 'Invalid signature' });
    return;
  }

  const event = JSON.parse((req.body as Buffer).toString('utf-8'));

  // Push to queue — returns in < 5ms
  await webhookQueue.add(event.type, event, {
    jobId: event.id, // Deduplication: BullMQ skips duplicate jobIds
  });

  res.status(200).json({ received: true }); // Within Stripe's 30s timeout
});

**Why**: Stripe requires 200 within 30s. DB/email/API calls can exceed this. Queue offloading guarantees fast ack. BullMQ `jobId` deduplicates retries.

---

Pattern Catalog

Preserve Raw Body for Signature Verification

**Detection**:

grep -rn 'express\.json()' --include="*.ts" src/
# Check if webhook routes are registered after app.use(express.json())
grep -rn 'app\.post.*webhook' --include="*.ts" src/ -B20 | grep 'express\.json'

**Signal**:

app.use(express.json()); // Parses ALL bodies first

app.post('/webhooks/stripe', (req, res) => {
  // req.body is now a JS object, not the original bytes
  const sig = req.headers['stripe-signature'];
  stripe.webhooks.constructEvent(req.body, sig, secret); // Always fails!
});

**Why this matters**: `express.json()` consumes the stream. `stripe.webhooks.constructEvent()` requires raw bytes to recompute HMAC — always throws signature mismatch.

**Preferred action*

Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked