Building a Retry-Safe Stripe Webhook in TypeScript
A Stripe webhook can pass every happy-path test and still fail in production. The difficult cases are not parsing JSON or switching on an event type. They are duplicate delivery, partial failure, retries, and side effects that cross service boundaries.
Consider a booking flow that connects Stripe, a calendar provider, and a database:
- a customer pays for a booking;
- the application confirms a temporary calendar hold;
- the application marks the booking as billed;
- a failed payment releases the hold.
If the handler confirms the hold and then crashes before updating the database, Stripe will retry the event. If the second attempt repeats the calendar call, the system may send duplicate notifications or mutate the booking twice. If the handler returns 200 after only part of the work succeeds, Stripe will not retry and the systems remain inconsistent.
This tutorial develops a small TypeScript boundary that makes those failure modes explicit. The goal is not a universal webhook framework. It is a pattern you can adapt to a Next.js route, a queue consumer, or another server runtime.
The reliability contract
Before writing code, define the behavior the handler must guarantee:
- verify the Stripe signature against the unmodified request body;
- ignore event types the application does not use;
- reject supported events that lack required correlation metadata;
- process a completed event at most once;
- make downstream side effects idempotent as well;
- record failures and rethrow them so the delivery can be retried;
- complete related database state changes atomically.
Stripe’s webhook documentation calls out two details that drive this design. Signature verification requires the raw request body, and webhook endpoints can receive duplicate events. Stripe recommends recording processed event IDs and skipping those already handled. See the official guidance on receiving webhook events and resolving signature errors.
Step 1: Reduce Stripe events to a small domain type
Do not pass the entire Stripe object graph into business logic. Convert only the event types and fields that the application supports:
export type SupportedStripeEvent =
| {
id: string;
type: "payment_intent.succeeded";
paymentIntentId: string;
bookingId: string;
calBookingId: string;
amountReceived: number;
}
| {
id: string;
type: "payment_intent.payment_failed";
paymentIntentId: string;
bookingId: string;
calBookingId: string;
};
This discriminated union has two useful properties. First, an unsupported event cannot accidentally reach the booking workflow. Second, TypeScript requires the success branch to handle amountReceived, while the failure branch cannot pretend that field exists.
The booking identifiers live in PaymentIntent metadata. Stripe documents metadata as a way to associate application objects with a PaymentIntent and pass those identifiers to downstream fulfillment processes through events. The metadata use-case guide shows this pattern.
Treat missing metadata as an error rather than guessing or performing an unscoped lookup:
const bookingId = intent.metadata.booking_id;
const calBookingId = intent.metadata.cal_booking_id;
if (!bookingId || !calBookingId) {
throw new Error(`Missing booking metadata on ${intent.id}`);
}
Step 2: Keep infrastructure behind narrow interfaces
The workflow needs two capabilities: durable event state and calendar side effects. Express them as interfaces so the orchestration can be tested without a real database or calendar account:
export interface BookingPaymentRepository {
claimEvent(event: SupportedStripeEvent): Promise<{
alreadyCompleted: boolean;
}>;
completeSucceededPayment(input: {
eventId: string;
bookingId: string;
paymentIntentId: string;
calBookingId: string;
amountReceived: number;
}): Promise<void>;
completeFailedPayment(input: {
eventId: string;
bookingId: string;
paymentIntentId: string;
}): Promise<void>;
recordFailure(eventId: string, message: string): Promise<void>;
}
export interface CalendarGateway {
confirmHold(calBookingId: string, idempotencyKey: string): Promise<void>;
releaseHold(calBookingId: string, idempotencyKey: string): Promise<void>;
}
The interfaces encode an important distinction: claiming an event is separate from completing it. An event that previously failed must be eligible for another attempt; an event already marked complete must return without repeating side effects.
Step 3: Make the event ID the shared idempotency key
The central orchestration is intentionally small:
export async function processStripeEvent(
event: SupportedStripeEvent,
dependencies: {
repository: BookingPaymentRepository;
calendar: CalendarGateway;
},
): Promise<"processed" | "duplicate"> {
const claim = await dependencies.repository.claimEvent(event);
if (claim.alreadyCompleted) return "duplicate";
try {
if (event.type === "payment_intent.succeeded") {
await dependencies.calendar.confirmHold(event.calBookingId, event.id);
await dependencies.repository.completeSucceededPayment({
eventId: event.id,
bookingId: event.bookingId,
paymentIntentId: event.paymentIntentId,
calBookingId: event.calBookingId,
amountReceived: event.amountReceived,
});
} else {
await dependencies.calendar.releaseHold(event.calBookingId, event.id);
await dependencies.repository.completeFailedPayment({
eventId: event.id,
bookingId: event.bookingId,
paymentIntentId: event.paymentIntentId,
});
}
return "processed";
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown webhook error";
await dependencies.repository.recordFailure(event.id, message);
throw error;
}
}
The Stripe event ID protects the database boundary, but that alone is not enough. A retry can occur after the calendar provider has accepted a request but before the application has stored completion. Passing the same event ID to confirmHold or releaseHold lets the calendar adapter suppress that repeated operation. If the downstream API exposes native idempotency keys, forward it. Otherwise, store the key next to the side-effect record in your own adapter.
Idempotency needs to cover every non-transactional boundary. A unique constraint on stripe_event_id cannot undo a duplicated email, calendar mutation, or message sent before the transaction commits.
Step 4: Verify the signature before parsing
In a Next.js route handler, read the body as text and give the exact string to Stripe’s SDK:
export async function POST(request: Request): Promise<Response> {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing Stripe signature", { status: 400 });
}
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid Stripe signature", { status: 400 });
}
const supported = toSupportedEvent(event);
if (!supported) {
return Response.json({ received: true, ignored: true });
}
await processStripeEvent(supported, { repository, calendar });
return Response.json({ received: true });
}
Calling request.json() first is a common mistake. Even if parsing and re-serializing produces equivalent JSON, whitespace or key order can change. The signature protects the original bytes, not a semantically equivalent object.
Step 5: Use the database as the source of truth
A practical event table needs at least:
create table stripe_webhook_events (
event_id text primary key,
event_type text not null,
status text not null check (status in ('processing', 'completed', 'failed')),
attempt_count integer not null default 1,
last_error text,
completed_at timestamptz,
updated_at timestamptz not null default now()
);
claimEvent should insert a new row or lock and inspect the existing one. It should return alreadyCompleted: true only for a completed row. A failed row should increment attempt_count, move back to processing, and allow the retry.
For a successful payment, the repository should mark the booking billed and the event completed in one transaction. For a failed payment, it should store the failure state and complete the event together. This prevents a database crash from leaving one table updated while the event table incorrectly reports success.
Be careful with permanently stuck processing rows. A worker can die after claiming an event. Production code typically adds a lease timestamp or allows a processing row older than a threshold to be reclaimed.
Step 6: Test failures, not only branches
The most valuable tests describe delivery semantics:
it("does not repeat side effects for a completed event", async () => {
const deps = dependencies(true);
await expect(processStripeEvent(succeeded, deps)).resolves.toBe("duplicate");
expect(deps.calendar.confirmHold).not.toHaveBeenCalled();
});
it("records a retryable failure and rethrows it", async () => {
const deps = dependencies();
vi.mocked(deps.calendar.confirmHold).mockRejectedValue(
new Error("Calendar timeout"),
);
await expect(processStripeEvent(succeeded, deps)).rejects.toThrow(
"Calendar timeout",
);
expect(deps.repository.recordFailure).toHaveBeenCalledWith(
"evt_success",
"Calendar timeout",
);
});
Add integration tests for the repository’s unique constraint and transaction behavior. At the route level, test an invalid signature, a missing metadata field, and an ignored event type. Finally, replay the same signed fixture twice and assert that the calendar adapter sees one logical operation.
Stripe recommends handling webhook work asynchronously and returning a successful response quickly. For a larger workflow, the HTTP route can verify and durably enqueue the event, then return 2xx; a worker applies the same claim-and-complete pattern. The reliability contract remains the same even when the processing boundary moves to a queue.
What this pattern prevents
The completed design makes four failure modes observable and recoverable:
- Duplicate delivery: the completed event returns before side effects.
- Calendar timeout: the error is recorded and rethrown for retry.
- Crash after a downstream call: the same event ID makes the downstream retry idempotent.
- Partial database update: related state changes commit together.
The broader lesson is that webhook reliability is not a property of one endpoint. It is a property of the entire chain from signature verification through every side effect and durable state transition. Once each boundary has an idempotency key and a recorded completion state, retries stop being exceptional behavior and become part of the normal design.
Enjoy Reading This Article?
Here are some more articles you might like to read next: