Incoming webhooks from Stripe, GitHub, or any provider need three things: signature verification, idempotency protection, and fast responses. Get any of these wrong and you have security holes or double-processed payments. Claude Code generates the complete safe implementation.
CLAUDE.md for Webhook Handling
## Webhook Receiving Rules
### Security (required)
- Verify signatures on ALL incoming webhooks (reject without valid signature)
- Stripe: verify stripe-signature header with Webhook Secret
- GitHub: verify X-Hub-Signature-256 with HMAC-SHA256
- CRITICAL: verify against rawBody (not parsed JSON — parsing changes the bytes)
### Idempotency (required)
- All webhook handlers must be idempotent (safe to receive same event twice)
- Idempotency key: {provider}-{eventId} in DB processed events table
- Already-processed events: return 200 immediately, skip processing
### Response timing
- Return 200 within 5 seconds (providers retry if no response)
- Heavy processing goes to BullMQ queue BEFORE returning
### Retry safety
- Idempotency table prevents double-processing on retries
- Let sending provider handle retry logic
Generating the Stripe Webhook Handler
Generate a secure Stripe webhook endpoint.
Events to handle:
- payment_intent.succeeded → mark order as completed
- payment_intent.payment_failed → mark order as failed
- customer.subscription.deleted → deactivate subscription
Requirements:
- Verify stripe-signature header
- Use rawBody for verification (skip JSON parser for this route)
- Record processed events in DB for idempotency
- Queue heavy processing via BullMQ, return 200 immediately
Generate:
- src/webhooks/stripeWebhook.ts
- src/webhooks/handlers/paymentHandler.ts
Generated:
// src/webhooks/stripeWebhook.ts
import Stripe from 'stripe';
import express, { Router } from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const stripeWebhookRouter = Router();
// IMPORTANT: Use express.raw() here — not express.json()
// Stripe signature verification requires the raw request body
stripeWebhookRouter.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
logger.warn({ err }, 'Stripe webhook signature failed');
return res.status(400).send('Signature verification failed');
}
// Idempotency check
const alreadyProcessed = await prisma.webhookEvent.findUnique({
where: { id: `stripe-${event.id}` },
});
if (alreadyProcessed) {
logger.info({ eventId: event.id }, 'Already processed, skipping');
return res.status(200).json({ received: true });
}
// Record as processed (before processing to prevent race conditions)
await prisma.webhookEvent.create({
data: {
id: `stripe-${event.id}`,
type: event.type,
processedAt: new Date(),
},
});
// Return 200 immediately — don't block on processing
res.status(200).json({ received: true });
// Queue for async processing
await webhookQueue.add(event.type, event);
}
);
Payment Handler
// src/webhooks/handlers/paymentHandler.ts
export async function handlePaymentIntentSucceeded(event: Stripe.Event): Promise<void> {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
logger.warn({ paymentIntentId: paymentIntent.id }, 'No orderId in metadata');
return;
}
await prisma.order.update({
where: { id: orderId },
data: {
status: 'completed',
paidAt: new Date(),
paymentIntentId: paymentIntent.id,
},
});
await emailQueue.add('order-confirmation', { orderId });
logger.info({ orderId }, 'Order completed via webhook');
}
GitHub Webhook Verification
Generate GitHub webhook HMAC-SHA256 signature verification.
Requirements:
- Verify X-Hub-Signature-256 header
- Timing-safe comparison (prevent timing attacks)
- Handle events: push, pull_request
- Use timing-safe comparison for the signature
Save to: src/webhooks/githubWebhook.ts
import crypto from 'crypto';
function verifyGitHubSignature(body: Buffer, signature: string): boolean {
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
hmac.update(body);
const expected = `sha256=${hmac.digest('hex')}`;
// Timing-safe comparison prevents timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
githubWebhookRouter.post(
'/webhooks/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256'] as string;
if (!signature || !verifyGitHubSignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
const event = req.headers['x-github-event'] as string;
const payload = JSON.parse(req.body.toString());
// Return fast, process async
res.status(200).json({ received: true });
await githubQueue.add(event, payload);
}
);
Webhook Idempotency Table (Prisma Schema)
model WebhookEvent {
id String @id // "{provider}-{eventId}"
type String
processedAt DateTime
@@index([processedAt])
}
Clean up old events after 30 days with a cron job.
Summary
Design webhook handling with Claude Code:
- CLAUDE.md — Signature verification mandatory, idempotency mandatory, 5-second response rule
- Raw body verification — Parse AFTER verifying, never before
- Idempotency table — Prevent double-processing on retries
- Return 200 fast — Queue heavy work, don't block the response
Security Pack (¥1,480) includes /security-check for webhook security — missing signature verification, absent idempotency, timing attack vulnerabilities.
Myouga (@myougatheaxo) — Claude Code engineer focused on payment and integration security.
Top comments (0)