Stripe Webhook Handling: Signature Verification, Idempotency, and Event Routing
Build production-grade Stripe webhook handling: HMAC signature verification, idempotent event processing, retry-safe handlers, event routing architecture, and testing strategies in TypeScript.
Stripe webhooks are the backbone of subscription billing. When a payment succeeds, fails, or a subscription renews, Stripe fires a webhook β and your handler must process it exactly once, survive retries, and not deadlock under concurrent delivery. Getting this wrong means double-charging customers, missing churn signals, or granting access after payment fails.
This post covers production-grade webhook handling: HMAC signature verification, the idempotency pattern that prevents double-processing, typed event routing, and a test strategy that doesn't require ngrok.
How Stripe Webhooks Work
Stripe servers ββPOSTβββΊ /api/webhooks/stripe
β
βββ 1. Verify signature (reject forgeries)
βββ 2. Check idempotency (skip duplicates)
βββ 3. Route to handler by event type
βββ 4. Process business logic
βββ 5. Return 200 (or Stripe retries for 72 hours)
Stripe retry schedule on non-2xx:
Attempt 1: Immediate
Attempt 2: ~5 minutes
Attempt 3: ~30 minutes
Attempts 4-18: Exponentially increasing up to 72 hours
Critical rule: Your handler must be idempotent. Stripe guarantees at-least-once delivery β the same event may arrive multiple times.
1. Endpoint Setup with Raw Body
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { handleStripeEvent } from '../../../../services/billing/webhook-router';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-28.acacia',
});
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
// Stripe requires the raw body for signature verification
// Next.js App Router: req.text() gives us the raw string
const rawBody = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
}
let event: Stripe.Event;
try {
// constructEvent verifies HMAC-SHA256 signature + timestamp (prevents replay attacks)
event = stripe.webhooks.constructEvent(rawBody, signature, WEBHOOK_SECRET);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
// Return 400 β do NOT return 200 for invalid signatures
return NextResponse.json(
{ error: `Webhook signature invalid: ${err.message}` },
{ status: 400 }
);
}
try {
await handleStripeEvent(event);
return NextResponse.json({ received: true });
} catch (err: any) {
// Log but return 200 to avoid infinite Stripe retries for non-recoverable errors
// For recoverable errors, throw so Stripe retries
console.error(`Webhook handler failed for ${event.type}:`, err);
if (err.retryable === false) {
// Non-recoverable: log and acknowledge
return NextResponse.json({ received: true, warning: 'Handler failed but acknowledged' });
}
// Recoverable: return 500 so Stripe retries
return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
}
}
// Disable body parsing β we need raw bytes for HMAC verification
export const config = {
api: { bodyParser: false },
};
Express / Fastify Equivalent
// Express: use express.raw() for webhook routes specifically
import express from 'express';
const app = express();
// β
Raw body for webhook route
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
webhookHandler
);
// β
JSON body for all other routes
app.use(express.json());
π³ Fintech That Passes Compliance β Not Just Demos
Payment integrations, KYC/AML flows, trading APIs, and regulatory compliance β we build fintech that survives real audits, not just product demos.
- PCI DSS, PSD2, FCA, GDPR-aware architecture
- Stripe, Plaid, Rapyd, OpenBanking integrations
- Real-time transaction monitoring and fraud flags
- UK/EU/US compliance requirements mapped from day one
2. Idempotency: Process Each Event Exactly Once
-- Track processed webhook events
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- Stripe event ID (e.g., evt_xxx)
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
result JSONB, -- Store result for debugging
error TEXT, -- Store error if processing failed
INDEX idx_webhook_events_type (event_type, processed_at DESC)
);
// src/services/billing/webhook-idempotency.ts
import { db } from '../../lib/db';
export type ProcessingResult =
| { status: 'processed'; result?: object }
| { status: 'skipped'; reason: 'duplicate' }
| { status: 'failed'; error: string };
export async function processOnce(
eventId: string,
eventType: string,
handler: () => Promise<object | void>
): Promise<ProcessingResult> {
// Check if already processed
const existing = await db.webhookEvent.findUnique({
where: { id: eventId },
select: { id: true, result: true, error: true },
});
if (existing) {
console.log(`Skipping duplicate webhook: ${eventId} (${eventType})`);
return { status: 'skipped', reason: 'duplicate' };
}
// Mark as processing (optimistic lock β prevents concurrent duplicates)
try {
await db.webhookEvent.create({
data: { id: eventId, eventType },
});
} catch (err: any) {
// Unique constraint violation = another process beat us to it
if (err.code === 'P2002') {
return { status: 'skipped', reason: 'duplicate' };
}
throw err;
}
// Execute handler
try {
const result = await handler();
await db.webhookEvent.update({
where: { id: eventId },
data: { result: (result ?? {}) as object },
});
return { status: 'processed', result: result ?? undefined };
} catch (err: any) {
await db.webhookEvent.update({
where: { id: eventId },
data: { error: err.message },
});
throw err;
}
}
3. Typed Event Router
// src/services/billing/webhook-router.ts
import Stripe from 'stripe';
import { processOnce } from './webhook-idempotency';
import { handleCheckoutSessionCompleted } from './handlers/checkout';
import { handleInvoicePaymentSucceeded, handleInvoicePaymentFailed } from './handlers/invoice';
import { handleSubscriptionUpdated, handleSubscriptionDeleted } from './handlers/subscription';
import { handlePaymentIntentSucceeded, handlePaymentIntentFailed } from './handlers/payment';
// Type-safe event handler map
type EventHandler<T extends Stripe.Event['type']> = (
event: Stripe.Event & { type: T; data: { object: Extract<Stripe.Event['data']['object'], { object: string }> } }
) => Promise<void>;
const EVENT_HANDLERS: Partial<Record<Stripe.Event['type'], (event: Stripe.Event) => Promise<void>>> = {
'checkout.session.completed': handleCheckoutSessionCompleted,
'invoice.payment_succeeded': handleInvoicePaymentSucceeded,
'invoice.payment_failed': handleInvoicePaymentFailed,
'customer.subscription.updated': handleSubscriptionUpdated,
'customer.subscription.deleted': handleSubscriptionDeleted,
'payment_intent.succeeded': handlePaymentIntentSucceeded,
'payment_intent.payment_failed': handlePaymentIntentFailed,
};
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
const handler = EVENT_HANDLERS[event.type];
if (!handler) {
// Not an error β we just don't handle this event type
console.info(`Unhandled Stripe event type: ${event.type}`);
return;
}
await processOnce(event.id, event.type, () => handler(event));
}
π¦ Trading Systems, Payment Rails, and Financial APIs
From algorithmic trading platforms to neobank backends β Viprasol has built the full spectrum of fintech. Senior engineers, no junior handoffs, verified track record.
- MT4/MT5 EA development for prop firms and hedge funds
- Custom payment gateway and wallet systems
- Regulatory reporting automation (MiFID, EMIR)
- Free fintech architecture consultation
4. Handler Implementations
Checkout Session Completed (New Subscription)
// src/services/billing/handlers/checkout.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';
import { stripe } from '../../../lib/stripe';
export async function handleCheckoutSessionCompleted(
event: Stripe.Event
): Promise<void> {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== 'subscription') return;
if (!session.subscription || !session.customer) return;
// Expand subscription to get plan details
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
{ expand: ['items.data.price.product'] }
);
const priceItem = subscription.items.data[0];
const price = priceItem.price;
const product = price.product as Stripe.Product;
// Find account by Stripe customer ID or metadata
const accountId =
session.metadata?.accountId ??
(await db.account.findFirst({
where: { stripeCustomerId: session.customer as string },
select: { id: true },
}))?.id;
if (!accountId) {
console.error(`No account found for Stripe customer: ${session.customer}`);
return;
}
await db.$transaction([
db.subscription.upsert({
where: { accountId },
update: {
stripeSubscriptionId: subscription.id,
stripePriceId: price.id,
stripeItemId: priceItem.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date(),
},
create: {
accountId,
stripeSubscriptionId: subscription.id,
stripeCustomerId: session.customer as string,
stripePriceId: price.id,
stripeItemId: priceItem.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
}),
db.account.update({
where: { id: accountId },
data: {
plan: product.metadata.plan ?? 'pro',
stripeCustomerId: session.customer as string,
},
}),
]);
console.info(`Subscription activated for account ${accountId}: ${product.name}`);
}
Invoice Payment Failed (Dunning)
// src/services/billing/handlers/invoice.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';
import { emailService } from '../../../lib/email';
export async function handleInvoicePaymentFailed(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription || !invoice.customer_email) return;
const subscription = await db.subscription.findFirst({
where: { stripeSubscriptionId: invoice.subscription as string },
include: { account: { include: { owner: true } } },
});
if (!subscription) {
console.error(`No subscription found for Stripe ID: ${invoice.subscription}`);
return;
}
const attemptCount = invoice.attempt_count;
const nextAttempt = invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null;
// Update subscription status
await db.subscription.update({
where: { id: subscription.id },
data: { status: 'past_due' },
});
// Send dunning email based on attempt count
const emailTemplate =
attemptCount === 1 ? 'payment-failed-first' :
attemptCount === 2 ? 'payment-failed-second' :
'payment-failed-final';
await emailService.send({
to: invoice.customer_email,
template: emailTemplate,
data: {
customerName: subscription.account.owner.name,
amount: (invoice.amount_due / 100).toFixed(2),
currency: invoice.currency.toUpperCase(),
nextAttempt: nextAttempt?.toLocaleDateString() ?? 'no further attempts',
updatePaymentUrl: `${process.env.APP_URL}/billing/payment-method`,
},
});
console.info(
`Payment failed (attempt ${attemptCount}) for subscription ${subscription.id}`
);
}
export async function handleInvoicePaymentSucceeded(
event: Stripe.Event
): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) return;
const subscription = await db.subscription.findFirst({
where: { stripeSubscriptionId: invoice.subscription as string },
});
if (!subscription) return;
// Reset to active status (may have been past_due)
await db.subscription.update({
where: { id: subscription.id },
data: {
status: 'active',
// Update period end from invoice
currentPeriodEnd: new Date((invoice.lines.data[0]?.period.end ?? 0) * 1000),
},
});
console.info(`Payment succeeded for subscription ${subscription.id}`);
}
Subscription Updated (Plan Change / Cancellation Scheduled)
// src/services/billing/handlers/subscription.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';
export async function handleSubscriptionUpdated(event: Stripe.Event): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription>;
const dbSub = await db.subscription.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!dbSub) {
console.error(`Subscription not found in DB: ${subscription.id}`);
return;
}
// Build update payload
const updates: Record<string, unknown> = {
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date(),
};
// Track plan change
if (subscription.items.data[0]?.price.id !== dbSub.stripePriceId) {
updates.stripePriceId = subscription.items.data[0].price.id;
updates.stripeItemId = subscription.items.data[0].id;
console.info(
`Plan changed for subscription ${subscription.id}: ` +
`${dbSub.stripePriceId} β ${subscription.items.data[0].price.id}`
);
}
await db.subscription.update({
where: { id: dbSub.id },
data: updates,
});
}
export async function handleSubscriptionDeleted(event: Stripe.Event): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
await db.subscription.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
status: 'canceled',
canceledAt: new Date(),
},
});
// Downgrade account to free tier
const sub = await db.subscription.findFirst({
where: { stripeSubscriptionId: subscription.id },
select: { accountId: true },
});
if (sub) {
await db.account.update({
where: { id: sub.accountId },
data: { plan: 'free' },
});
}
console.info(`Subscription canceled: ${subscription.id}`);
}
5. Testing Without ngrok
// src/services/billing/__tests__/webhook-router.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Stripe from 'stripe';
import { handleStripeEvent } from '../webhook-router';
import { db } from '../../../lib/db';
vi.mock('../../../lib/db', () => ({
db: {
webhookEvent: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
subscription: { findFirst: vi.fn(), update: vi.fn(), upsert: vi.fn(), updateMany: vi.fn() },
account: { findFirst: vi.fn(), update: vi.fn() },
$transaction: vi.fn((fns: any[]) => Promise.all(fns)),
},
}));
vi.mock('../../../lib/email', () => ({
emailService: { send: vi.fn().mockResolvedValue({ messageId: 'msg-123' }) },
}));
// Helper: build a mock Stripe event
function makeMockEvent<T extends Stripe.Event.Type>(
type: T,
object: object,
previousAttributes?: object
): Stripe.Event {
return {
id: `evt_test_${Date.now()}`,
type,
object: 'event',
api_version: '2025-10-28.acacia',
created: Math.floor(Date.now() / 1000),
livemode: false,
pending_webhooks: 1,
request: null,
data: {
object: object as any,
previous_attributes: previousAttributes,
},
} as Stripe.Event;
}
describe('handleStripeEvent', () => {
beforeEach(() => {
vi.mocked(db.webhookEvent.findUnique).mockResolvedValue(null); // Not duplicate
vi.mocked(db.webhookEvent.create).mockResolvedValue({} as any);
vi.mocked(db.webhookEvent.update).mockResolvedValue({} as any);
});
it('processes invoice.payment_failed and sends dunning email', async () => {
const { emailService } = await import('../../../lib/email');
vi.mocked(db.subscription.findFirst).mockResolvedValue({
id: 'sub-db-1',
stripeSubscriptionId: 'sub_stripe_1',
account: {
owner: { name: 'Test User', email: 'test@example.com' },
},
} as any);
vi.mocked(db.subscription.update).mockResolvedValue({} as any);
const event = makeMockEvent('invoice.payment_failed', {
id: 'in_test_1',
subscription: 'sub_stripe_1',
customer_email: 'test@example.com',
amount_due: 9900,
currency: 'usd',
attempt_count: 1,
next_payment_attempt: Math.floor(Date.now() / 1000) + 86400,
});
await handleStripeEvent(event);
expect(emailService.send).toHaveBeenCalledWith(
expect.objectContaining({ template: 'payment-failed-first' })
);
expect(db.subscription.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'past_due' } })
);
});
it('skips duplicate events', async () => {
vi.mocked(db.webhookEvent.findUnique).mockResolvedValue({
id: 'evt_existing',
result: {},
error: null,
} as any);
const event = makeMockEvent('invoice.payment_succeeded', {
subscription: 'sub_stripe_1',
});
await handleStripeEvent(event);
// No DB mutations for subscription should happen
expect(db.subscription.update).not.toHaveBeenCalled();
});
it('ignores unhandled event types gracefully', async () => {
const event = makeMockEvent('balance.available', { object: 'balance' });
await expect(handleStripeEvent(event)).resolves.toBeUndefined();
});
});
Stripe CLI for Local Testing
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login and forward webhooks to local dev server
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events for manual testing
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated
# Test with custom payload
stripe trigger invoice.payment_failed \
--override invoice:customer_email=test@example.com \
--override invoice:amount_due=9900
Cost Reference: Webhook Infrastructure
| Setup | Monthly Cost | Reliability |
|---|---|---|
| Single endpoint, no queue | $0 extra | Fragile β any downtime = lost events |
| Webhook idempotency table only | $0 extra | Good β survives retries |
| + SQS/Redis queue buffer | $5β20/mo | Excellent β decouples processing |
| + Dead letter queue + alerting | $10β30/mo | Production-ready |
| Managed webhook platform (Svix) | $99β499/mo | Best DX, highest reliability |
See Also
- Stripe Billing Engineering: Subscription Lifecycle and Proration
- Stripe Connect Marketplace: Split Payments and Payouts
- SaaS Dunning Management: Recovering Failed Payments
- SaaS Revenue Recognition: MRR, ARR, and ASC 606
- Background Jobs Architecture with BullMQ
Working With Viprasol
Building a SaaS product on Stripe and need webhook handling that survives duplicate delivery, retries, and race conditions? We implement production-grade webhook pipelines with signature verification, idempotent handlers, typed event routers, and full test coverage β so your billing logic is reliable from day one.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building Fintech Solutions?
Payment integrations, trading systems, compliance β we build fintech that passes audits.
Free consultation β’ No commitment β’ Response within 24 hours
Building fintech or trading infrastructure?
Viprasol delivers custom trading software β MT4/MT5 EAs, TradingView indicators, backtesting frameworks, and real-time execution systems. Trusted by traders and prop firms worldwide.