How We Handled Stripe Webhooks with Zero Duplicate Charges

At-least-once webhook delivery and nervous user double-clicks will corrupt your financial ledger unless your payment pipeline enforces strict cryptographic and database idempotency.

VW
Viery Wahyu•Author
•10 min read

Nothing wakes a software architect up faster than an urgent message from customer support reporting that a client was just charged twice for a high-value booking or that a merchant ledger recorded double payouts for a single order. When you build payment pipelines in a local sandbox, every network call resolves in twenty milliseconds and every webhook arrives in polite, sequential order. Production networks operate under far more chaotic rules. Customers tap the checkout button three times while riding through a cellular dead zone, load balancers drop acknowledgment packets mid-flight, and Stripe retries webhook deliveries aggressively whenever your server takes slightly too long to respond. Achieving zero duplicate charges and zero duplicate order fulfillments across our platforms required redesigning both our outbound payment requests and our inbound webhook receivers around strict mathematical idempotency.

Stopping Duplicate Charges at the Outbound API Boundary

When engineers talk about duplicate payment bugs, they often conflate two separate architectural failure modes. The first failure happens before a webhook is ever emitted, right when your backend server calls Stripe to initialize a charge or a PaymentIntent. If a customer on a laggy hotel Wi-Fi connection clicks the pay button twice within two hundred milliseconds, two concurrent HTTP requests hit your backend. Even worse, if developers generate a random crypto.randomUUID() string on the fly inside the route handler and pass that to Stripe as the idempotency key, you are not actually protecting the transaction at all because both requests carry completely different random keys.

Preventing duplicate card charges requires deriving a deterministic idempotency key from the immutable business context of the transaction itself. Whenever our checkout engine initializes a Stripe PaymentIntent, we compute a cryptographic hash combining the internal order identifier, the customer identifier, and the exact cent amount of the current cart revision.

typescript
1import Stripe from "stripe"
2import { createHash } from "node:crypto"
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
5 apiVersion: "2025-02-24.acacia",
6})
7
8export async function createResilientPaymentIntent(params: {
9 orderId: string
10 customerId: string
11 amountCents: number
12 currency: string
13 cartVersion: number
14}) {
15 // Derive a deterministic key tied to the exact business state, never a random UUID
16 const rawKeyMaterial = `pi_create_${params.orderId}_v${params.cartVersion}_${params.amountCents}`
17 const deterministicIdempotencyKey = createHash("sha256")
18 .update(rawKeyMaterial)
19 .digest("hex")
20
21 return await stripe.paymentIntents.create(
22 {
23 amount: params.amountCents,
24 currency: params.currency.toLowerCase(),
25 customer: params.customerId,
26 metadata: {
27 orderId: params.orderId,
28 cartVersion: String(params.cartVersion),
29 },
30 },
31 {
32 idempotencyKey: deterministicIdempotencyKey,
33 }
34 )
35}

Binding the idempotencyKey to orderId and cartVersion guarantees that even if a nervous user mashes the submit button five times or our container orchestrator retries a timed-out outbound socket, Stripe intercepts the identical SHA-256 key at its edge, locks the first execution in memory, and safely returns the exact same PaymentIntent object to all subsequent calls for the next twenty-four hours without touching the customer's credit card twice. If the user legitimately goes back and adds an extra passenger or item to their booking, cartVersion increments, cleanly generating a fresh deterministic key for the updated amount.

Why Check-Then-Act Logic Fails Under Concurrent Webhook Retries

Securing the outbound charge creation solves half the puzzle, but the nastiest financial bugs happen when Stripe calls your server back via webhooks. Stripe guarantees at-least-once delivery rather than exactly-once delivery. If your webhook endpoint experiences a brief database connection spike and fails to return a 2xx status code within the timeout window, or if a reverse proxy drops the response header on the way back, Stripe marks the delivery as failed and schedules an automatic retry even though your server already finished processing the payload. Furthermore, listening to multiple overlapping event types—such as both checkout.session.completed and payment_intent.succeeded—can cause two separate webhook deliveries for the same transaction to land on two different container replicas within three milliseconds of each other.

Most teams attempt to guard against duplicate webhooks using a naive check-then-act pattern in application code. The handler queries the database to check if the order status is still PENDING, runs the fulfillment logic, and finally updates the status to PAID. Under concurrent load, that pattern is a textbook race condition. When two webhook deliveries hit two parallel Node.js containers simultaneously, both containers execute the initial SELECT query before either container commits its UPDATE, both see PENDING, and both proceed to credit the merchant's wallet balance and dispatch duplicate confirmation vouchers.

Enforcing Atomic Event Locks in PostgreSQL With Drizzle ORM

Eliminating that race condition permanently requires moving deduplication out of application-level if statements and pushing it down into an atomic PostgreSQL constraint inside a single database transaction. We created a dedicated stripe_webhook_events ledger table where the primary key is the globally unique event.id (evt_...) provided by Stripe, alongside a secondary unique constraint guarding the business transition of the target order_id.

When an incoming webhook reaches our Next.js App Router endpoint, we first read the raw unparsed request body via request.text() to verify the cryptographic stripe-signature header—because letting a framework parse and re-stringify JSON beforehand alters whitespace and breaks HMAC verification. Once the cryptographic signature passes, we open a database transaction and immediately attempt an INSERT ... ON CONFLICT DO NOTHING against the webhook ledger table before executing a single line of business logic.

typescript
1import { NextRequest, NextResponse } from "next/server"
2import Stripe from "stripe"
3import { db } from "@/db"
4import { stripeWebhookEvents, orders, merchantLedgers } from "@/db/schema"
5import { eq, and } from "drizzle-orm"
6
7const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
8 apiVersion: "2025-02-24.acacia",
9})
10
11export async function POST(request: NextRequest) {
12 const signature = request.headers.get("stripe-signature")
13 if (!signature) {
14 return new NextResponse("Missing Stripe signature header", { status: 400 })
15 }
16
17 // Must consume raw text body so cryptographic HMAC verification succeeds
18 const rawBody = await request.text()
19 let event: Stripe.Event
20
21 try {
22 event = stripe.webhooks.constructEvent(
23 rawBody,
24 signature,
25 process.env.STRIPE_WEBHOOK_SECRET!
26 )
27 } catch (err) {
28 return new NextResponse("Invalid webhook signature", { status: 400 })
29 }
30
31 if (event.type === "payment_intent.succeeded") {
32 const paymentIntent = event.data.object as Stripe.PaymentIntent
33 const orderId = paymentIntent.metadata.orderId
34
35 await db.transaction(async (tx) => {
36 // Step 1 Claim the Stripe event ID atomically via unique constraint
37 const [claimedEvent] = await tx
38 .insert(stripeWebhookEvents)
39 .values({
40 eventId: event.id,
41 eventType: event.type,
42 orderId: orderId,
43 processedAt: new Date(),
44 })
45 .onConflictDoNothing({ target: stripeWebhookEvents.eventId })
46 .returning({ eventId: stripeWebhookEvents.eventId })
47
48 // If another container already processed or locked this event, exit cleanly
49 if (!claimedEvent) {
50 return
51 }
52
53 // Step 2 Transition order state atomically only if still PENDING
54 const [updatedOrder] = await tx
55 .update(orders)
56 .set({ status: "PAID", paidAt: new Date() })
57 .where(and(eq(orders.id, orderId), eq(orders.status, "PENDING")))
58 .returning({ id: orders.id, merchantId: orders.merchantId })
59
60 if (!updatedOrder) {
61 return
62 }
63
64 // Step 3 Credit merchant ledger within the exact same ACID boundary
65 await tx.insert(merchantLedgers).values({
66 orderId: updatedOrder.id,
67 merchantId: updatedOrder.merchantId,
68 creditCents: paymentIntent.amount_received,
69 })
70 })
71 }
72
73 return NextResponse.json({ received: true }, { status: 200 })
74}

Look closely at the two-layer defense inside that database transaction. First, .onConflictDoNothing({ target: stripeWebhookEvents.eventId }) forces PostgreSQL to acquire an immediate index lock on event.id. If Stripe retries the exact same webhook payload while the first transaction is still in flight, the second transaction blocks at the database lock until the first transaction commits, sees the conflict, receives undefined for claimedEvent, and exits gracefully with a 200 OK response. Second, even if two different Stripe event types (payment_intent.succeeded and charge.succeeded) arrive with different evt_ IDs for the same order, the conditional .where(and(eq(orders.id, orderId), eq(orders.status, "PENDING"))) clause guarantees that only the first statement to acquire the row lock transitions the order to PAID and credits the merchantLedgers table. If any step inside the transaction throws an error, PostgreSQL rolls back both the ledger credit and the stripeWebhookEvents row simultaneously, allowing Stripe's next scheduled retry to cleanly re-attempt the entire unit of work.

Keeping Slow Side Effects Out of the Webhook Loop

The final lesson that cemented our payment pipeline resilience was realizing why our webhooks were timing out and triggering retries in the first place. Early in our architecture, right after marking an order as paid inside the webhook route, we synchronously rendered a PDF invoice using a headless browser, uploaded it to object storage, sent a transactional email, and called a third-party WhatsApp notification gateway. Whenever the external WhatsApp provider experienced a five-second latency hiccup, our webhook handler stalled, Stripe assumed the delivery had died, and retry storms quickly followed.

A resilient webhook handler should do nothing more than verify the cryptographic signature, commit the idempotent state transition in the database, enqueue an internal background job or transactional outbox event, and return 200 OK within fifty milliseconds. By moving PDF generation, partner API dispatches, and customer notifications to asynchronous worker queues keyed by the same unique orderId, our Stripe webhook endpoint became virtually immune to downstream third-party outages.

Designing Financial Pipelines That Let You Sleep at Night

Handling Stripe webhooks with zero duplicate charges is not about hoping that network connections stay stable or trusting that webhook payloads arrive once. It is about designing every step of the money movement lifecycle so that replaying any request ten times produces the exact same financial state as running it once. When you pair deterministic SHA-256 idempotency keys on outbound Stripe API calls with atomic ON CONFLICT database locks and conditional state transitions on inbound webhooks, duplicate charges and double payouts become mathematically impossible, letting your engineering team sleep soundly through peak holiday traffic.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →