Nurture TechnologiesNurture Tech
Integrations13 min read·August 4, 2026

Duplicate Stripe Charges Were Causing Customer Complaints

Next.jsNestJSStripePostgreSQL

Customers were being charged twice. The Stripe integration worked correctly. The problem was missing idempotency keys, no server-side duplicate detection, and a frontend that allowed multiple submissions. Here is the full investigation and the fixes that brought duplicate charges to zero.

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:

application-logs.txt
[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 update

The 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.

duplicate-charge-flow.mmd
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 2

Every 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.

payments/payments.service.ts BEFORE
// 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 };
}
payments/payments.service.ts AFTER
// 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.

payments/payments.service.ts
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).

components/PaymentForm.tsx
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.

webhooks/stripe-webhooks.controller.ts
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-after.txt
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 events

The 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

stripe-payment-architecture.mmd
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

  1. 1Never trust client-side state for payment operations the backend must be the authority on payment existence
  2. 2Every request that creates a Stripe object must include an idempotency key this is not optional for production payment systems
  3. 3The idempotency key should be deterministic from the business operation, not random a random key on retry is a new charge
  4. 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
  5. 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
  6. 6A frontend submit lock is not a substitute for server-side idempotency network conditions can duplicate requests regardless of frontend state
  7. 7Webhook signature verification is non-negotiable an unverified webhook endpoint accepts arbitrary POST requests that can fake payment events
  8. 8Webhook idempotency must be implemented explicitly Stripe retries events, and without deduplication your handler will process the same event multiple times
  9. 9The raw request body must be preserved for webhook signature verification middleware that parses the body before verification will break signature checks
  10. 10Payment amounts must be validated server-side before being passed to Stripe never use a client-supplied amount for a charge
  11. 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
  12. 12Duplicate charge detection should be automated a daily reconciliation job that compares Stripe records against the database catches issues before customers report them
  13. 13Every refund must be checked against the original charge before processing refunding the same charge twice is as bad as charging twice
  14. 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
  15. 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:

payment-framework.mmd
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.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Why did Stripe charge customers twice?+

Stripe charged customers twice because the application made two separate PaymentIntent creation requests to Stripe without idempotency keys. Without a key, Stripe treats each request as a unique transaction and creates a separate charge. Two requests arrived at the backend from a double-click, a network retry, or a form resubmission and both were forwarded to Stripe. Stripe processed both correctly from its perspective. The root cause was the application's failure to detect and block the duplicate request.

What is Stripe idempotency?+

Stripe idempotency is a mechanism that prevents duplicate API requests from creating duplicate objects. When you include an idempotency key in a Stripe API request, Stripe stores the result of that request for 24 hours. If an identical request with the same key arrives within that window, Stripe returns the stored result rather than processing a new transaction. This ensures that retries, network errors, and duplicate requests all produce the same outcome as the original request.

How do I prevent duplicate payments in Stripe?+

Three layers of protection: first, include a deterministic idempotency key (e.g., pi-userId-orderId) on every stripe.paymentIntents.create() call so Stripe deduplicates at the API level. Second, check your database for an existing PaymentIntent for the same order before calling Stripe, and return the existing client_secret if one is found. Third, disable the submit button in the frontend immediately after the first click to prevent multiple requests from being generated.

Can Stripe automatically stop duplicate charges?+

Not without idempotency keys. Stripe has a basic duplicate charge detection feature that flags potential duplicates (same amount, same card, same description within a short window), but this is a warning mechanism, not a guarantee. The reliable way to prevent duplicate charges is to use idempotency keys on all PaymentIntent creation calls and to implement server-side existence checks in your application before calling the Stripe API.

How do Stripe Payment Intents work?+

A PaymentIntent represents a single attempt to collect payment. Your backend creates a PaymentIntent via the Stripe API, which returns a client_secret. The frontend uses Stripe.js and the client_secret to confirm the payment using the customer's payment method. Stripe then processes the charge and delivers a payment_intent.succeeded or payment_intent.payment_failed webhook to your backend. The PaymentIntent tracks the entire lifecycle of the payment attempt and supports 3D Secure, retries, and multiple confirmation attempts without creating duplicate charges.

What is a Stripe idempotency key?+

An idempotency key is a unique string you provide in the options object of a Stripe API call. Stripe uses this key to detect and deduplicate requests. If the same key is sent within 24 hours, Stripe returns the cached response from the first request. The key should be unique per intended business operation use a combination of user ID, order ID, and intent type (e.g., pi-usr123-ord456) so the same retry always produces the same key, and a different order always produces a different key.

How do I handle duplicate Stripe webhooks?+

Stripe retries webhook delivery when your endpoint does not respond with a 200 within 5 seconds. This means the same event can arrive multiple times. To handle this: create a stripeEvents table in your database with stripeEventId as a unique column; at the start of your webhook handler, check whether the event ID already exists in this table; if it does, return 200 immediately without processing; if it does not, process the event and then insert the event ID. This guarantees each event is processed exactly once regardless of how many times it is delivered.

What causes duplicate Stripe subscriptions?+

Duplicate subscriptions are caused by the same pattern as duplicate charges: creating a subscription without an idempotency key and without checking whether an active subscription already exists for the customer. A user who clicks Subscribe twice, or a retry on a failed network request, can create two active subscriptions. Prevention: check for existing active subscriptions via the Stripe Subscriptions API or your database before creating a new one, and use idempotency keys on all stripe.subscriptions.create() calls.

How should I structure Stripe webhook handling in NestJS?+

Create a dedicated controller for Stripe webhooks. Configure NestJS to preserve the raw request body (rawBody: true in NestFactory.create options). Use the RawBodyRequest type to access the raw body. Verify the Stripe signature using stripe.webhooks.constructEvent() before processing any event. Implement event ID deduplication using a database table. Process events in a try-catch and return 500 on processing failures so Stripe retries. Store processed event IDs only after successful processing to avoid skipping events that failed mid-processing.

How do I verify Stripe webhook signatures in Node.js?+

Use stripe.webhooks.constructEvent(rawBody, signature, webhookSecret). The rawBody must be the exact raw bytes of the request body before any JSON parsing. The signature comes from the stripe-signature request header. The webhookSecret is the signing secret from your Stripe Dashboard webhook configuration (starts with whsec_). If the signature is invalid, constructEvent throws an error. Catch it and return HTTP 400. Never process a webhook event that fails signature verification.

Should payment state be updated from the payment confirmation redirect or from webhooks?+

From webhooks. The return_url redirect after payment confirmation is a best-effort client-side signal that can be missed if the user closes the browser, loses network connectivity, or navigates away. Webhooks are the authoritative, server-to-server signal that Stripe guarantees to deliver (with retries). Update order status, send confirmation emails, and trigger fulfillment logic from payment_intent.succeeded webhook events, not from the redirect handler.

What should I store in the database before returning a PaymentIntent to the frontend?+

Store the PaymentIntent ID, the associated order ID, the user ID, the amount, and the initial status (pending). Do this before returning the client_secret to the frontend. If the network fails between the Stripe API call and the return to the frontend, you have a record of the created PaymentIntent. On the next request, you can retrieve the existing PaymentIntent from Stripe using the stored ID and return the same client_secret, preventing a duplicate charge.

How do I handle a Stripe API call that succeeds but the response never reaches my server?+

If a Stripe API call succeeds but the response is lost in transit, your idempotency key is the safety net. On the next request with the same key, Stripe returns the same result as the first call. Combined with a database existence check (looking up stored PaymentIntent IDs before calling Stripe), you can retrieve the already-created object from either your database or directly from Stripe using the stored ID. This is why storing the Stripe object ID before returning to the client is critical.

How long are Stripe idempotency keys valid?+

Stripe idempotency keys are valid for 24 hours. Within that window, any request using the same key returns the cached result from the first request. After 24 hours, the key is no longer recognized and a new request with that key creates a new object. For payment flows where a user might return to complete a payment the next day (e.g., a saved cart), do not rely on the 24-hour key window alone. Store the PaymentIntent ID in your database and retrieve the existing PaymentIntent from Stripe when the user returns.

What is the best way to prevent double-click duplicate payments in React?+

Use a boolean state variable (isProcessing) that is set to true when the form is submitted and only reset to false if the payment fails. Disable the submit button when isProcessing is true. On success, Stripe's confirmPayment redirect removes the user from the page, making the button state irrelevant. On failure, reset isProcessing to false so the user can retry. Never re-enable the button automatically after success always rely on the redirect to move the user away from the payment form.

How do I audit payment events in a Stripe integration?+

Create an append-only audit_log table that records every payment event with a timestamp, event type, Stripe object ID, user ID, and a JSON snapshot of the relevant data. Write to this table on PaymentIntent creation, webhook receipt, status changes, and refund events. Use INSERT only, never UPDATE or DELETE. This gives you a complete immutable timeline of every billing action for every customer, which is invaluable for investigating disputes, debugging incidents, and satisfying compliance requirements.

Why is my Stripe webhook signature verification failing in NestJS?+

The most common cause is that the request body has been parsed by Express's JSON middleware before the signature verification function receives it. Stripe's constructEvent() requires the raw, unparsed request bytes. In NestJS, set rawBody: true in the NestFactory.create() options and use the RawBodyRequest<Request> type in your controller to access req.rawBody. Apply body parsing exclusion or raw body middleware specifically to the webhook route.

How do I reconcile Stripe charges with my database?+

Run a daily background job that lists all Stripe charges and payment intents from the previous 48 hours using the Stripe API and compares them against your database records. Flag any Stripe object that has no corresponding database record (a creation that failed to persist) and any database record with a status that does not match the Stripe API (a webhook that was missed). Alert on discrepancies immediately for manual review. This reconciliation job is your safety net for any gaps in the real-time webhook pipeline.

Can I use a random UUID as a Stripe idempotency key?+

You can, but it defeats the purpose for retry scenarios. A random UUID generated fresh on each request means a retry creates a new key, which Stripe treats as a new transaction producing a duplicate charge. Use a deterministic key derived from the business operation (e.g., pi-userId-orderId) so that any retry for the same operation produces the same key. Reserve random UUIDs for idempotency keys that should not be reused across retries, such as intentional separate transactions.

How do I test for duplicate charge vulnerabilities in staging?+

Test the following scenarios using Stripe test mode: rapid double-click of the submit button, form refresh during payment processing (use browser DevTools to simulate slow network so you can trigger a refresh mid-request), network timeout simulation using DevTools throttling, and running two browser tabs with the same order and submitting both simultaneously. Verify that each scenario results in exactly one charge in the Stripe Dashboard. Add these as explicit test cases in your QA checklist before every payment-related deployment.