| name | webhook-patterns |
| description | Webhook patterns for receiving, verifying (HMAC), and idempotently processing third-party events. Covers Stripe, GitHub, and generic webhook patterns, delivery guarantees, retry handling, and testing. |
Webhook Patterns Skill
When to Activate
- Receiving events from Stripe, GitHub, Twilio, or any third-party service
- Building your own webhook system to notify customers
- Handling duplicate webhook deliveries correctly
- Testing webhooks locally without exposing ports
- Implementing HMAC signature verification with timing-safe comparison to prevent spoofed events
- Designing the
webhook_events database table with idempotency keys and retry tracking
- Setting up exponential-backoff retry queues (e.g. BullMQ) for outbound webhook deliveries
Core Principles
- Verify every webhook โ HMAC signature before any processing
- Respond fast, process async โ Return 200 immediately, queue the work
- Idempotency โ The same event can arrive 2-3 times. Handle it safely
- Log everything โ Webhook events are your audit trail
Pattern 1: Receiving Webhooks (Stripe example)
import Stripe from 'stripe';
import { queue } from '../jobs/queue';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, webhookSecret);
} catch (err) {
console.warn('Invalid webhook signature', { err });
return res.status(400).json({ error: 'Invalid signature' });
}
const alreadyProcessed = await db.query.webhookEvents.findFirst({
where: eq(webhookEvents.externalId, event.id),
});
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
await db.insert(webhookEvents).values({
externalId: event.id,
provider: 'stripe',
type: event.type,
payload: event,
status: 'pending',
receivedAt: new Date(),
});
res.status(200).json({ received: true });
await queue.add('process-stripe-event', { eventId: event.id, type: event.type });
}
);
Pattern 2: Idempotent Event Processing
async function processStripeEvent(eventId: string) {
const record = await db.query.webhookEvents.findFirst({
where: eq(webhookEvents.externalId, eventId),
});
if (!record || record.status === 'processed') return;
try {
const event = record.payload as Stripe.Event;
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
:
(event.. .);
;
:
.(, { : event. });
}
db
.(webhookEvents)
.({ : , : () })
.((webhookEvents., eventId));
} (err) {
db
.(webhookEvents)
.({
: ,
: (err),
: sql,
})
.((webhookEvents., eventId));
err;
}
}
() {
orderId = paymentIntent..;
[updated] = db
.(orders)
.({ : , : () })
.(
(
(orders., orderId),
(orders., )
)
)
.();
(!updated) {
;
}
(updated);
}
Pattern 3: Generic HMAC Verification
import crypto from 'crypto';
interface WebhookConfig {
secret: string;
headerName: string;
algorithm: string;
prefix?: string;
}
function verifyWebhookSignature(
body: Buffer,
header: string | undefined,
config: WebhookConfig
): boolean {
if (!header) return false;
const signature = config.prefix
? header.replace(config.prefix, '')
: header;
const expected = crypto
.createHmac(config.algorithm, config.secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.(expected, )
);
}
app.(, express.({ : }), {
valid = (req., req.[] , {
: process..!,
: ,
: ,
: ,
});
(!valid) res.().();
});
Pattern 4: Sending Webhooks (your own webhook system)
interface WebhookSubscription {
id: string;
customerId: string;
url: string;
secret: string;
events: string[];
}
async function deliverWebhook(
subscription: WebhookSubscription,
event: { type: string; data: unknown }
): Promise<void> {
const payload = JSON.stringify({ event: event.type, data: event.data, timestamp: Date.now() });
const signature = crypto
.createHmac('sha256', subscription.secret)
.update(payload)
.digest('hex');
const response = await fetch(subscription.url, {
method: 'POST',
headers: {
'Content-Type': ,
: ,
: event.,
: crypto.(),
},
: payload,
: .(),
});
(!response.) {
();
}
}
() {
webhookQueue.(
,
{ : subscription., event },
{
: ,
: { : , : },
: { : * * },
: ,
}
);
}
Local Testing with ngrok / Stripe CLI
stripe listen --forward-to localhost:3000/webhooks/stripe
ngrok http 3000
stripe events resend evt_xxx --forward-to localhost:3000/webhooks/stripe
Webhook Events Table
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
CREATE INDEX ON webhook_events (provider, type, status);
CREATE INDEX ON webhook_events (received_at);
Checklist