webhook-patterns
Webhook processing patterns — signature verification, idempotency, retry handling, and queue integration
$ npx -y skills add notque/vexjoy-agent --agent claude-codeHow 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.mddescription: 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
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*
Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.
Repo: notque/vexjoy-agent
Other agents on vexjoy-agent.
- ansible-automation-engineer
Ansible automation: playbooks, roles, collections, Molecule testing, Vault security.
Open agent - modules
**Scope**: Module selection patterns, builtin vs command/shell decisions, collection modules, and version-specific module changes **Version range**: ansible-core 2.14+ / Ansible Collections (community.general 7.0+) **Generated**: 2026-04-04 — verify against current Ansible
Open agent - testing
**Scope**: Molecule test scenarios, ansible-lint rules, idempotency validation, and check-mode patterns **Version range**: Molecule 6.0+ / ansible-lint 6.0+ / ansible-core 2.14+ **Generated**: 2026-04-04 — verify against current Molecule and ansible-lint documentation
Open agent - base-instructions
Universal operational rules injected by /do at agent dispatch. Domain-specific rules live in each agent's .md file.
Open agent - communication-patterns
**Scope**: Failure modes in agent output style — over-reporting, self-congratulation, verbose narration, and hedging. Covers what to detect and how to fix each. **Version range**: all versions **Generated**: 2026-05-11
Open agent - combat-effects-upgrade
Zero-dependency combat visual upgrades: CSS particle replacement, Framer Motion combat juice, CSS 3D card transforms.
Open agent

