Support tickets started appearing.
Several customers reported being charged twice for the same order. At first, it looked like isolated incidents maybe a user clicking the button twice, maybe a browser quirk. The kind of thing you chalk up to user error and close with a refund.
It wasn't.
Over the following week, the pattern kept appearing. Different customers, different plans, different browsers. All the same outcome: two successful charges in Stripe, one order in the database, and a customer understandably furious about an unexpected deduction from their account.
Payment issues damage trust faster than almost any other class of software problem. A bug that crashes a dashboard is annoying. A bug that takes money from someone's bank account twice is a trust violation. Customers who experience duplicate charges do not wait around to see the fix. They open a dispute, leave a review, and tell people.
Our Environment
- Frontend: Next.js with a custom checkout flow using Stripe.js and Stripe Elements
- Backend: NestJS payments module handling PaymentIntent creation, webhook processing, and subscription management
- Payments: Stripe Payment Intents API for one-time charges, Stripe Checkout for some subscription flows
- Database: PostgreSQL via Prisma with an orders table and a payments table tracking payment state
- Webhook delivery: Stripe webhooks delivered to a dedicated NestJS endpoint
The intended payment flow: the user fills in their details and clicks Pay. The frontend calls our backend to create a PaymentIntent. The backend returns the client_secret. The frontend uses Stripe.js to confirm the payment. Stripe processes the card. On success, Stripe fires a webhook to our backend, which updates the order status to paid and sends a confirmation email.
The Problem
From the customer's perspective: they clicked pay, saw the payment processing spinner, waited a few seconds, and the page appeared to hang. They clicked pay again. Both payments went through. Two charges appeared on their card statement.
In a separate pattern: a customer on a mobile connection clicked pay, the network request timed out on the client side before a response arrived, and the app showed an error. The customer clicked pay again. The original request had actually reached our backend and succeeded. The second request also reached the backend and also succeeded. Two charges.
In a third pattern: a customer submitted the payment form and then hit the browser refresh button before the redirect happened. The form resubmitted. The frontend made a second call to our backend. A second PaymentIntent was created and confirmed. Two charges.
By the time we had identified the scope of the issue, eight customers had been double-charged across a five-day window. Every refund was processed immediately, but the damage to trust was real and the support overhead was significant.
Initial Investigation
We started in the Stripe Dashboard. Under Payments, we filtered by the affected customers' email addresses and immediately saw two successful payment_intent.succeeded events per customer within seconds of each other. Same amount. Same customer. Different PaymentIntent IDs.
- Stripe Dashboard: two successful PaymentIntents per affected customer, created within 2-8 seconds of each other
- Our payments table: one payment record per customer the second PaymentIntent was never written to the database
- Our orders table: one order per customer both charges were for the same order
- Application logs: two POST requests to /api/payments/intent hit the backend for each affected customer, milliseconds to seconds apart
- Webhook events: two payment_intent.succeeded webhooks per customer, both acknowledged by our endpoint
- Error logs: no errors on any of the affected payment flows
The pattern was clear: two requests reached the backend. The backend created two PaymentIntents. Stripe processed both. Neither request saw the other because our backend had no mechanism to detect or reject the duplicate.
First Hypotheses
- Stripe bug: ruled out immediately two distinct PaymentIntent IDs with separate creation timestamps confirmed these were two separate requests Stripe never saw as duplicates
- Customer mistake: partially true for the double-click cases, but not a sufficient explanation the application should be resilient to user actions that trigger multiple requests
- Webhook duplicate processing: ruled out the webhook events were processed correctly; the problem was two charges being created, not two order updates
- Database issue: partially relevant the database was not being checked before creating a new PaymentIntent, which would have caught duplicates
- Frontend issue: confirmed for some cases the Pay button had no submission lock, allowing multiple clicks and form resubmissions to reach the backend
- Missing idempotency keys: confirmed as the primary root cause without idempotency keys, every request to Stripe was treated as a new, unique transaction
Discovery
The key log entry that made everything clear:
[2026-07-30T14:22:01.003Z] POST /api/payments/intent userId=usr_892 orderId=ord_441
[2026-07-30T14:22:01.003Z] Creating PaymentIntent for ord_441
[2026-07-30T14:22:01.847Z] PaymentIntent created: pi_3Qx1ABCdef amount=4900
[2026-07-30T14:22:01.848Z] Returning clientSecret to frontend
[2026-07-30T14:22:03.201Z] POST /api/payments/intent userId=usr_892 orderId=ord_441
[2026-07-30T14:22:03.201Z] Creating PaymentIntent for ord_441
[2026-07-30T14:22:03.889Z] PaymentIntent created: pi_3Qx1GHIjkl amount=4900
[2026-07-30T14:22:03.889Z] Returning clientSecret to frontend
[2026-07-30T14:22:07.441Z] Stripe webhook: payment_intent.succeeded pi_3Qx1ABCdef
[2026-07-30T14:22:07.819Z] Order ord_441 marked as paid
[2026-07-30T14:22:09.122Z] Stripe webhook: payment_intent.succeeded pi_3Qx1GHIjkl
[2026-07-30T14:22:09.131Z] Order ord_441 already paid skipping status updateThe backend caught the duplicate at the webhook level and skipped the second order update. But it never caught the duplicate at the PaymentIntent creation level. By the time the webhook arrived, both charges had already been processed by Stripe. The order was not double-created, but the customer was double-charged.
Root Cause
The root cause was missing idempotency keys on the PaymentIntent creation call, combined with no server-side check for an existing PaymentIntent before creating a new one.
When a request creates a new Stripe object without an idempotency key, Stripe treats every request as a unique transaction. Two requests with the same parameters but no idempotency key produce two charges. That is correct behavior from Stripe's perspective it has no way to know the two requests represent the same intended transaction without a key telling it so.
sequenceDiagram
participant FE as Frontend
participant BE as Backend
participant ST as Stripe
FE->>BE: POST /payments/intent (request 1)
BE->>ST: paymentIntents.create() [no idempotency key]
ST-->>BE: pi_ABC (pending)
BE-->>FE: clientSecret_1
FE->>BE: POST /payments/intent (request 2)
BE->>ST: paymentIntents.create() [no idempotency key]
ST-->>BE: pi_XYZ (pending)
BE-->>FE: clientSecret_2
FE->>ST: confirmPayment(clientSecret_1)
ST-->>FE: succeeded ✓ Charge 1
FE->>ST: confirmPayment(clientSecret_2)
ST-->>FE: succeeded ✓ Charge 2Every path that could produce a second request to the backend was a duplicate charge vector: a double-click on a slow connection, a form resubmission on browser refresh, a mobile network retry after a timeout, a browser back-navigation that reloaded the payment form. None of these were prevented.
Understanding Stripe Idempotency
Idempotency means that making the same request multiple times produces the same result as making it once. In the context of Stripe, an idempotency key is a unique string you attach to a request. If Stripe receives two requests with the same key within 24 hours, it returns the result of the first request without creating a new object or processing a new charge.
Idempotency keys must be unique per intended transaction. The correct structure is a key that identifies the specific business operation: the user, the order, and the intent to pay for that order. If the user is paying for order 441, the key might be pi-usr892-ord441. If they later pay for order 442, a different key is used. If they retry the payment for order 441, the same key is used and Stripe returns the existing PaymentIntent instead of creating a new one.
Stripe idempotency keys are valid for 24 hours. After 24 hours, using the same key will create a new object. For payment flows where a user might return to complete a payment hours later, store the PaymentIntent ID in your database and retrieve the existing one rather than relying solely on the 24-hour key window.
The Fix
Step 1: Add Idempotency Keys to All Stripe API Calls
The first and most critical fix: every call to the Stripe API that creates an object must include an idempotency key. The key should be deterministic from the business operation so that retrying the same operation produces the same key.
// BEFORE: No idempotency key every call creates a new charge
async createPaymentIntent(userId: string, orderId: string, amount: number) {
const paymentIntent = await this.stripe.paymentIntents.create({
amount,
currency: "usd",
metadata: { orderId, userId },
});
return { clientSecret: paymentIntent.client_secret };
}// AFTER: Deterministic idempotency key per order
async createPaymentIntent(userId: string, orderId: string, amount: number) {
const idempotencyKey = `pi-${userId}-${orderId}`;
const paymentIntent = await this.stripe.paymentIntents.create(
{
amount,
currency: "usd",
metadata: { orderId, userId },
},
{ idempotencyKey }
);
return { clientSecret: paymentIntent.client_secret };
}Step 2: Check the Database Before Creating a New PaymentIntent
An idempotency key handles the Stripe side. The database check handles the application side. Before creating any PaymentIntent, look up whether a pending or processing PaymentIntent already exists for this order. If one exists, retrieve it from Stripe and return the existing client_secret. This ensures the frontend always confirms the same PaymentIntent, regardless of how many times the backend endpoint is called.
async createOrRetrievePaymentIntent(
userId: string,
orderId: string,
amount: number
): Promise<{ clientSecret: string }> {
// Check for an existing uncompleted PaymentIntent for this order
const existing = await this.db.payment.findFirst({
where: {
orderId,
status: { in: ["pending", "requires_payment_method", "requires_confirmation"] },
},
});
if (existing?.stripePaymentIntentId) {
const intent = await this.stripe.paymentIntents.retrieve(
existing.stripePaymentIntentId
);
this.logger.log(`Returning existing PaymentIntent ${intent.id} for order ${orderId}`);
return { clientSecret: intent.client_secret! };
}
const idempotencyKey = `pi-${userId}-${orderId}`;
const paymentIntent = await this.stripe.paymentIntents.create(
{
amount,
currency: "usd",
metadata: { orderId, userId },
},
{ idempotencyKey }
);
await this.db.payment.create({
data: {
orderId,
userId,
stripePaymentIntentId: paymentIntent.id,
status: "pending",
amount,
},
});
this.logger.log(`Created PaymentIntent ${paymentIntent.id} for order ${orderId}`);
return { clientSecret: paymentIntent.client_secret! };
}Step 3: Lock the Frontend After First Submission
The double-click and form-resubmission cases are prevented at the frontend. Disable the submit button immediately after the first click and keep it disabled until the payment either succeeds (at which point the user is redirected) or fails (at which point the error is displayed and the button is re-enabled for a retry).
import { useState } from "react";
import { useStripe, useElements } from "@stripe/react-stripe-js";
export function PaymentForm({ orderId }: { orderId: string }) {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements || isProcessing) return;
setIsProcessing(true);
setErrorMessage(null);
try {
const res = await fetch("/api/payments/intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId }),
});
if (!res.ok) throw new Error("Failed to initialize payment");
const { clientSecret } = await res.json();
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/payment/success`,
},
});
if (error) {
// Only re-enable on error on success, Stripe redirects
setIsProcessing(false);
setErrorMessage(error.message ?? "Payment failed. Please try again.");
}
} catch {
setIsProcessing(false);
setErrorMessage("Something went wrong. Please try again.");
}
}
return (
<form onSubmit={handleSubmit}>
{/* Stripe Elements fields */}
<button type="submit" disabled={isProcessing || !stripe}>
{isProcessing ? "Processing..." : "Pay Now"}
</button>
{errorMessage && <p role="alert">{errorMessage}</p>}
</form>
);
}Step 4: Webhook Idempotency
Stripe retries failed webhook deliveries. If your webhook endpoint is unavailable, Stripe will retry the same event multiple times over several days. Without idempotency in the webhook handler, a retry will process the same payment_intent.succeeded event twice and may double-fulfill an order. Store processed event IDs and skip events that have already been handled.
import { Controller, Post, Headers, Req, Res } from "@nestjs/common";
import { RawBodyRequest } from "@nestjs/common";
import type { Request, Response } from "express";
import Stripe from "stripe";
@Controller("webhooks")
export class StripeWebhooksController {
constructor(
private readonly stripe: Stripe,
private readonly paymentsService: PaymentsService,
private readonly db: PrismaService
) {}
@Post("stripe")
async handleStripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers("stripe-signature") sig: string,
@Res() res: Response
) {
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return res.status(400).send("Webhook signature verification failed");
}
// Idempotency: skip events already processed
const alreadyProcessed = await this.db.stripeEvent.findUnique({
where: { stripeEventId: event.id },
});
if (alreadyProcessed) {
return res.json({ received: true });
}
try {
switch (event.type) {
case "payment_intent.succeeded":
await this.paymentsService.handlePaymentSucceeded(
event.data.object as Stripe.PaymentIntent
);
break;
case "payment_intent.payment_failed":
await this.paymentsService.handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
case "customer.subscription.created":
await this.paymentsService.handleSubscriptionCreated(
event.data.object as Stripe.Subscription
);
break;
}
await this.db.stripeEvent.create({
data: { stripeEventId: event.id, processedAt: new Date(), type: event.type },
});
} catch (err) {
return res.status(500).json({ error: "Webhook processing failed" });
}
return res.json({ received: true });
}
}NestJS strips the raw body from requests by default. To verify Stripe webhook signatures, you must configure your app to preserve the raw body. Set rawBody: true in NestJSFactory.create() options and use RawBodyRequest<Request> in your controller. Without the raw body, constructEvent() will always throw a signature mismatch error.
Additional Improvements
Payment State Machine
We added explicit payment status transitions to the database: pending → processing → succeeded or failed. Any attempt to create a new PaymentIntent for an order already in the processing or succeeded state is rejected at the service layer before a Stripe API call is ever made. This provides defense in depth beyond the idempotency key alone.
Monitoring and Alerting
We added a daily check that queries the Stripe API for customers with more than one successful charge within a 10-minute window for the same amount. This runs as a background job and alerts the team immediately if a pattern of duplicate charges appears before customers notice. We also added a Datadog monitor on the webhook endpoint that alerts when the same event ID is received more than once, which indicates a processing delay or endpoint reliability issue.
Audit Log
Every PaymentIntent creation, confirmation, webhook receipt, and status update now writes to an append-only audit log table. When a customer reports a billing issue, we can see the complete timeline of their payment flow in one query: when the intent was created, when it was confirmed, when the webhook arrived, when the order was updated. This turns a multi-system investigation that previously took an hour into a two-minute query.
Results
BEFORE THE FIX
==============
Duplicate charges per week: ~3-4 incidents
Refund requests per week: ~3-4
Support tickets per week: ~8-10 (refund + inquiry)
Time to investigate per issue: 45-90 minutes
Customer trust impact: High active churn from affected users
AFTER THE FIX
=============
Duplicate charges per week: 0
Refund requests from duplicates: 0
Support tickets from duplicates: 0
Time to investigate per issue: N/A
Audit log coverage: 100% of payment eventsThe fix was deployed over one engineering day. The idempotency keys and database check were the primary changes. The frontend button lock, webhook idempotency, and audit logging were shipped in the same release. Zero duplicate charges in the period following deployment.
Stripe Payment Architecture Best Practices
flowchart TD
FE["Frontend\nNext.js\nButton lock on submit"] -- "POST /api/payments/intent\norderId + userId" --> BE["Backend\nNestJS"]
BE -- "1. Check DB for existing PI" --> DB[("PostgreSQL\nPayment state")]
DB -- "2a. PI exists → return it" --> BE
DB -- "2b. No PI → create new" --> BE
BE -- "3. paymentIntents.create()\nwith idempotencyKey" --> ST["Stripe API"]
ST -- "4. Return PaymentIntent\nclient_secret" --> BE
BE -- "5. Persist PI ID + status" --> DB
BE -- "6. Return client_secret" --> FE
FE -- "7. stripe.confirmPayment()" --> ST
ST -- "8. Charge processed\nWebhook fired" --> WH["Webhook Endpoint\n/webhooks/stripe"]
WH -- "9. constructEvent()\nVerify signature" --> WH
WH -- "10. Check stripeEvents\ntable for duplicate" --> DB
WH -- "11. Update order status\nSend confirmation" --> DB- Frontend responsibility: lock the submit button on first click, display errors and re-enable only on failure, never store payment method details client-side
- Backend responsibility: check for existing PaymentIntent before creating, use idempotency keys on all Stripe creates, persist PaymentIntent ID immediately, validate amounts server-side before passing to Stripe
- Stripe responsibility: process payments, enforce idempotency within the 24-hour key window, deliver webhooks with retry
- Webhook responsibility: verify signature on every event, check for duplicate event IDs, update order state from webhook events not from payment confirmation redirects
- Database responsibility: source of truth for payment state, enforce unique constraints on (orderId, status) combinations to prevent race conditions
Prevention Checklist
Payments
- Every stripe.paymentIntents.create() call includes a deterministic idempotency key
- Every stripe.checkout.sessions.create() call includes an idempotency key
- Backend checks the database for an existing PaymentIntent before creating a new one
- PaymentIntent ID is stored in the database before the client_secret is returned to the frontend
- Amount is validated server-side against the order total before being passed to Stripe
- Frontend disables the submit button immediately after the first click
- Frontend only re-enables the button on payment failure, not on success
Subscriptions
- stripe.subscriptions.create() calls include idempotency keys
- Backend checks for existing active subscriptions for a customer before creating a new one
- Subscription status is stored in the database and checked before creating
- Upgrade and downgrade flows use stripe.subscriptions.update() rather than cancel-and-create
- Trial end and renewal events are handled via webhooks, not polling
Webhooks
- Webhook signature verified with stripe.webhooks.constructEvent() on every event
- Raw body preserved and available to the signature verification function
- Processed event IDs stored in the database with a uniqueness constraint
- Duplicate event IDs detected and skipped before any business logic runs
- Webhook handler responds with 200 within 5 seconds and processes asynchronously if needed
- Failed webhook processing returns 500 so Stripe retries; successful processing returns 200
Refunds
- Refund requests check that the original charge exists and is not already refunded before calling stripe.refunds.create()
- Refund creation includes an idempotency key based on the charge ID and refund reason
- Refund status is tracked in the database and updated via webhook, not assumed from the API response
- Partial refunds are recorded with the refunded amount to prevent over-refunding
Monitoring
- Stripe Dashboard alerts configured for elevated dispute rates and refund rates
- Daily background job compares Stripe payment records against database records for consistency
- Alert fires immediately if any customer has more than one successful charge in a 10-minute window for the same amount
- Webhook endpoint uptime monitored Stripe retries failed deliveries but prolonged outages lose events
- Audit log covers all payment events: creation, confirmation, webhook receipt, status changes, refunds
Lessons Learned
- 1Never trust client-side state for payment operations the backend must be the authority on payment existence
- 2Every request that creates a Stripe object must include an idempotency key this is not optional for production payment systems
- 3The idempotency key should be deterministic from the business operation, not random a random key on retry is a new charge
- 4Store the Stripe object ID in your database before returning to the frontend if the response fails to reach the client but the object was created, you need a record of it
- 5Check the database for an existing PaymentIntent before calling Stripe the database check is faster, does not consume API rate limit, and works even when the idempotency key has expired
- 6A frontend submit lock is not a substitute for server-side idempotency network conditions can duplicate requests regardless of frontend state
- 7Webhook signature verification is non-negotiable an unverified webhook endpoint accepts arbitrary POST requests that can fake payment events
- 8Webhook idempotency must be implemented explicitly Stripe retries events, and without deduplication your handler will process the same event multiple times
- 9The raw request body must be preserved for webhook signature verification middleware that parses the body before verification will break signature checks
- 10Payment amounts must be validated server-side before being passed to Stripe never use a client-supplied amount for a charge
- 11Audit logs for payment events are essential for incident investigation without them, debugging a billing issue requires cross-referencing Stripe Dashboard, application logs, and database records
- 12Duplicate charge detection should be automated a daily reconciliation job that compares Stripe records against the database catches issues before customers report them
- 13Every refund must be checked against the original charge before processing refunding the same charge twice is as bad as charging twice
- 14Test your payment flow with network throttling, multiple rapid clicks, and page refreshes in staging before deploying these are the exact conditions that produce duplicate charges in production
- 15The Stripe idempotency key window is 24 hours for long-running payment sessions (e.g., a user who returns the next day to complete a payment), use the database to retrieve the existing PaymentIntent rather than relying on the key window
What Nurture Technologies Recommends
Every payment system we build now goes through a six-stage verification before deployment:
flowchart LR
A["Validate\nAmount, user, order\nstatus server-side"] --> B["Protect\nIdempotency keys\nDB existence check"]
B --> C["Process\nStripe API call\nwith error handling"]
C --> D["Verify\nWebhook signature\nevent idempotency"]
D --> E["Monitor\nDuplicate detection\nreconciliation jobs"]
E --> F["Audit\nAppend-only log\nof all payment events"]- Validate: server-side validation of all payment parameters before any Stripe call; the backend is the authority on pricing, not the frontend
- Protect: idempotency keys on every Stripe create operation; database existence checks before creating new objects; frontend submission locks
- Process: Stripe API call within a try-catch; errors mapped to user-facing messages; no silent failures
- Verify: webhook signature verification on every inbound event; processed event ID stored to prevent duplicate handling; order state updated from webhook, not from client-side redirect
- Monitor: daily reconciliation comparing Stripe records against database records; real-time alerts for dispute spikes, refund rate increases, and duplicate charge patterns
- Audit: every payment event written to an immutable audit log; supports customer billing disputes, compliance requirements, and incident investigation
Conclusion
The problem was not Stripe. Stripe processed every request it received correctly. It created a PaymentIntent when we asked it to. Then we asked it again. It created another one. Both were confirmed. Both were charged.
The problem was request handling. We had not told Stripe that the second request represented the same intended transaction as the first. We had not checked our own database before asking Stripe to create a new charge. We had not locked the frontend to prevent the second request from being generated. Every layer of the system behaved correctly in isolation. Together, they allowed a user action that triggered two requests to produce two charges.
Most duplicate payment issues originate in application design, not payment providers. Idempotency keys, server-side existence checks, and frontend submission locks are the three controls that prevent them. Build all three into every payment flow from the beginning. Retrofitting them after customers have been double-charged is much more expensive than building them correctly the first time.
Need help building reliable payment systems? Nurture Technologies helps companies implement Stripe integrations, subscription billing systems, marketplace payments, webhook architectures, and secure payment workflows designed for production scale. We audit existing payment implementations for duplicate charge vulnerabilities, idempotency gaps, and webhook reliability issues. Talk to us about your payment system.