Webhook Handling with Claude Code: Signature Verification, Idempotency, and Retry Safety
Published: (March 11, 2026 at 12:51 AM EDT)
4 min read
Source: Dev.to
Source: Dev.to
Webhook Receiving Rules
Security (required)
- Verify signatures on all incoming webhooks (reject without a valid signature).
- Stripe: verify
stripe-signatureheader with the webhook secret. - GitHub: verify
X-Hub-Signature-256with HMAC‑SHA256. - CRITICAL: verify against the raw request body (not parsed JSON — parsing changes the bytes).
Idempotency (required)
- All webhook handlers must be idempotent (safe to receive the same event twice).
- Idempotency key format:
{provider}-{eventId}stored in aWebhookEventtable. - Already‑processed events: return
200immediately and skip further processing.
Response timing
- Return
200within 5 seconds (providers retry if no response). - Heavy processing should be delegated to a queue (e.g., BullMQ) before returning.
Retry safety
- The idempotency table prevents double‑processing on retries.
- Let the sending provider handle its own retry logic.
Stripe Webhook Implementation
Endpoint (src/webhooks/stripeWebhook.ts)
import Stripe from 'stripe';
import express, { Router } from 'express';
import { prisma } from '../prisma'; // adjust import as needed
import { webhookQueue } from '../queues'; // BullMQ queue
import { logger } from '../logger';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const stripeWebhookRouter = Router();
// IMPORTANT: Use express.raw() — 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);
}
);
Handlers (src/webhooks/handlers/paymentHandler.ts)
import Stripe from 'stripe';
import { prisma } from '../../prisma';
import { emailQueue } from '../../queues';
import { logger } from '../../logger';
export async function handlePaymentIntentSucceeded(
event: Stripe.Event
): Promise {
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');
}
export async function handlePaymentIntentFailed(
event: Stripe.Event
): Promise {
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: 'failed',
failedAt: new Date(),
},
});
logger.info({ orderId }, 'Order marked as failed via webhook');
}
export async function handleSubscriptionDeleted(
event: Stripe.Event
): Promise {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await prisma.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: { active: false, cancelledAt: new Date() },
});
logger.info(
{ customerId },
'Subscription deactivated via webhook'
);
}
GitHub Webhook Implementation (src/webhooks/githubWebhook.ts)
import crypto from 'crypto';
import express, { Router } from 'express';
import { githubQueue } from '../queues';
import { logger } from '../logger';
const router = Router();
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
}
}
router.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)) {
logger.warn('Invalid GitHub webhook 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);
}
);
export default router;
Webhook Event Model (Prisma)
model WebhookEvent {
id String @id // "{provider}-{eventId}"
type String
processedAt DateTime
@@index([processedAt])
}
Maintenance
- Clean up old events after 30 days with a scheduled cron job.
For more details on webhook security best practices, see the guide at .