A customer writes “You charged me twice”, or your team sees two shipments, licence emails or credits for one purchase. This post is for people who run a web app that takes payments through Stripe. It shows you which of two failures you have before you refund anything: two payments for one order, or one payment your app fulfilled twice.
Every platform claim below was checked against Stripe and PostgreSQL documentation on 2026-09-28. App Rescue Desk is independent and is not affiliated with Stripe.
TL;DR
- Separate two succeeded payments for one order from one payment with two fulfilments. The refund decision depends on which one you have.
- Two payments for one order point to a repeated create request: a double-submit, or a retry that did not reuse the same idempotency key.
- One payment with two fulfilments points to a repeated delivery, a second Event object or a repeated internal job that your handler processed again.
- Treat the triage result as a candidate path until payment IDs, event IDs and your own logs agree.
- Each path has a different fix: an idempotency key tied to the order, or an event receipt and a job row recorded in one transaction.
Two shapes look like a duplicate charge
Shape 1: two payments, one order. Two succeeded PaymentIntents map to the same internal order. A PaymentIntent is the Stripe object that tracks one payment from creation through checkout (Payment Intents). Two payments for two legitimate orders are not a duplicate.
Shape 2: one payment, two fulfilments. One succeeded PaymentIntent, but two order records, shipments, licence emails or credits.
The customer-facing symptom has no error string. Shape 2 can come with failed attempts in the webhook endpoint’s delivery history, which Stripe’s status table shows like this:
(Timed out) ERR
(500) ERR
Some cases look like a duplicate but are neither shape. Check these first:
- An uncaptured authorization. A PaymentIntent with status
requires_capturehas not been captured. Stripe says you cancel it; you cannot refund it directly (refunds). - Several charge attempts on one PaymentIntent. One PaymentIntent can have more than one Charge after several payment attempts. Check each charge’s outcome.
- Two subscriptions, possibly on separate Customer records.
- A customer who really ordered twice.
If your integration creates Charges directly, the object names in this post differ.
Why the obvious first checks fall short
Each of these is a reasonable first move. None of them answers which path fired.
Opening a Stripe support ticket. Support can confirm what Stripe processed. Only your app knows which order each payment belongs to, unless you store the order ID in the payment’s metadata. Map each payment to your order first.
Disabling the webhook endpoint. It looks like the fastest way to stop duplicate fulfilment. But if the destination is disabled or deleted when a retry is due, Stripe stops future retries of that event; re-enable it before the retry and retries continue (automatic retries). Other pending events use the same endpoint, so their retries can stop too. Record the affected event IDs and plan a replay first.
A new random UUID for every request. Stripe suggests V4 UUIDs as idempotency keys, so a random UUID looks correct. The problem is when you generate it. A new UUID per request gives each double-click its own key, and Stripe treats the two requests as two operations. Generate the key once per purchase and reuse it for every retry.
Deduplicating on the event’s created timestamp. Stripe records created in seconds, so separate events can share a value. Stripe says not to use it to decide whether you already processed an event. Track event IDs instead.
Testing only in a sandbox. Stripe retries live-mode deliveries for up to three days with exponential backoff, but sandbox deliveries only three times over a few hours. A sandbox test can miss a late live-mode retry.
How each path produces the duplicate
Path 1 — repeated create request → two payments, one order
checkout ─► your server ─► Stripe: create PaymentIntent (key A)
checkout ─► your server ─► Stripe: create PaymentIntent (no key / key B)
Path 2 — repeated processing → one payment, two fulfilments
Stripe event ─► webhook handler ─► fulfilment
retry, resend or second Event object ─► webhook handler ─► fulfilment
your own queue retries the job ─────────────────────────► fulfilment
Path 1: your app asked Stripe twice
A customer double-clicks pay, or the form submits twice. Or your client or server times out waiting for Stripe and sends another create request.
Stripe saves the status code and body of the first request for each idempotency key. Later requests with the same key get that saved result (idempotent requests). A create request with no key, or with a new key, is a new operation. Stripe’s Payment Intents guide tells you to send an idempotency key so that one purchase cannot create two PaymentIntents.
Timeouts are the less obvious case. After a network error, the client usually cannot know whether Stripe received the request, so Stripe tells you to retry with the same key and the same parameters (network errors). A retry with a new key is a second request.
Stripe’s official libraries can generate keys and retry automatically, but only when configured to (idempotency). A library can reuse a key only for retries it makes itself; a second submit or your own retry loop is a new call. For double-submit protection, Stripe suggests deriving the key from an object such as the cart ID.
Path 2: a webhook processed twice for one payment
A webhook is an HTTP request Stripe sends to your server when something happens, such as a payment succeeding. Stripe states that an endpoint might occasionally receive the same event more than once. Design the handler for repeated delivery. If the handler rejects deliveries instead, for example after a signature check fails, see Stripe: No Signatures Found Matching the Expected Signature.
Four things can bring one payment to your fulfilment code twice. Stripe documents the first three:
- A failed delivery and a retry. A timeout or a non-2xx response is a failed attempt, and Stripe retries it. Stripe does not publish a numeric webhook timeout on that page, so this post does not give one.
- A manual resend. You can resend an event from the Dashboard for up to 15 days, or with the CLI for up to 30 days. A resend does not cancel Stripe’s automatic retries of an earlier failed event, even when the resend gets a 2xx, so both can reach your handler (manual retries). A resend is a replay action, not proof of the original cause.
- Two separate Event objects. Stripe says that in some cases it generates and sends two separate Event objects. Identify them by the
data.objectID plusevent.type. Deduplicating on event ID alone does not catch them. - Your own job queue. A retried internal job produces the same symptom even when Stripe delivered the event once. Compare Stripe’s delivery history with your own job records.
If the handler fulfils on every delivery without a guard, one payment can be fulfilled twice.
Check which path you are on
This check is read-only. Run it before you refund anything. You need access to the Stripe Dashboard, and someone with read-only database access.
Question 1: how many payments, and which order does each one belong to?
- Open the Stripe Dashboard. Find the customer’s succeeded payments.
- Map each payment to your order ID. Use the order ID in the payment’s metadata if your app stores one.
- For two payments on one order, open the
payment_intent.createdevent of each payment. - Compare
request.idand, when present,request.idempotency_key(Event object). - For two payments on two legitimate orders, stop. This is not a duplicate.
Different request IDs show two separate API requests. Different or missing idempotency keys are consistent with Path 1. They do not show why your code sent the second request.
Question 2: how many deliveries, and how many fulfilments?
- Open Workbench. Select the webhook endpoint. Open the Event deliveries tab.
- Find the event for the payment. Check for several attempts or failed attempts.
- Search your logs for handler runs with that
evt_ID. - Count fulfilment rows by PaymentIntent ID and event type.
- Check your job queue for retries of the same job.
| What you find | What it supports | What still needs to be excluded |
|---|---|---|
| Two succeeded payments, one order, different request IDs | Path 1: repeated create request | A legitimate second order |
One payment, several delivery attempts, several handler runs for one evt_ ID |
Path 2: repeated delivery processed again | Your own queue also retried the job |
One payment, two evt_ IDs with the same data.object ID and type |
Path 2: two separate Event objects | A handler for another event type caused the effect |
| One payment, one handler run, two fulfilment jobs | A retry inside your own queue | A manual operation, concurrent workers |
| No repeated delivery or job evidence | Inconclusive | Other event types, manual operations, concurrency |
Give this query to whoever has read-only database access. Adapt the table and column names to your schema:
SELECT payment_intent_id, COUNT(*) AS order_count
FROM orders
WHERE payment_intent_id IS NOT NULL
GROUP BY payment_intent_id
HAVING COUNT(*) > 1;
On a demo table in PostgreSQL 16.13, the query returned no rows for clean data, and one row with order_count 2 after a second order pointed at the same PaymentIntent.
Rows returned are a signal, not a root cause. No rows rules out duplicate orders in this table only, not duplicate emails, credits or shipments.
Contain the damage, then refund
Before you pause anything, decide who will replay the paused work. Pausing a worker delays legitimate fulfilment. Holding checkout stops every new payment.
-
Stop the path that is still running. For Path 1, pause the server code that creates or retries PaymentIntents, or hold checkout. For Path 2, pause the fulfilment worker. A disabled pay button does not stop a retry already running on your server.
-
Export the evidence. Include payment, order and event IDs, request IDs, delivery statuses and logs. Record the time window and what you paused. Do not paste API keys, webhook signing secrets or card details into tickets or chat.
-
Check each payment for an open dispute. If a customer disputes a charge while its refund is pending, the refund fails with this reason:
charge_for_pending_refund_disputedStripe recommends accepting or challenging the dispute instead of refunding, so the customer is not reimbursed twice (failed refunds). For bank debit payment methods, Stripe warns that a refund plus a bank-initiated dispute can credit the customer twice.
-
Two payments, one order: refund only the confirmed duplicate. Set the refund reason to
duplicate. If the duplicate is stillrequires_capture, cancel the PaymentIntent instead. -
One payment, two fulfilments: do not refund on this evidence alone. Reverse the extra shipment, licence or credit where you can. Where you cannot, the remedy is your business decision.
-
Tell the customer when you know the payment, the order and the refund reference.
Stripe’s refund docs say its processing fees from the original payment are not returned. A refund issued shortly after the charge can appear as a reversal, which drops the charge from the statement and for which Stripe withholds no fees. Otherwise the customer sees the credit about 5–10 business days later, depending on the bank.
Stop it happening again
This section is for whoever maintains the payment code. If that is not you, send it to them with the IDs from the triage check.
Tie the idempotency key to the order
Build the key once per purchase from a non-sensitive internal ID, for example order_<internal-id>_create_payment_intent. Stripe’s Payment Intents guide says the key is typically based on the cart or customer-session ID. Store the key with the order and reuse it for every retry of that create call.
Never reuse one key across orders. Never put an email address or other personal identifier in the key; Stripe tells you to avoid sensitive data there.
How Stripe treats a repeated key (idempotent requests):
| Repeated request | Stripe’s behaviour |
|---|---|
| Same key, same parameters, after the first request began executing | Returns the saved result of the first request, including a saved 500 |
| Same key, different parameters (for example, a changed cart total) | Returns an error instead of treating it as the same operation |
| Same key while the first request is still executing | Saves no result for the conflicting request; you can retry it |
| Same key, more than 24 hours later | Stripe can prune the key; a reused pruned key creates a new request |
Stripe says to treat a 500 as indeterminate and advises against retrying it with a new key, because the original request may have had side effects (server errors). Because keys can be pruned after 24 hours, a retry job that runs later also needs order state that records whether the payment already succeeded.
Record the event receipt and the job in one transaction
Keep a processed_stripe_events table with event_id as the primary key. In PostgreSQL, INSERT ... ON CONFLICT DO NOTHING skips a conflicting row, and RETURNING returns only rows actually inserted (PostgreSQL INSERT).
A receipt row proves only that you recorded the event. It does not prove that the shipment, email, credit or licence completed. If the process stops after the receipt commits but before the work is recorded, the next delivery is skipped and the fulfilment is lost.
Record both in one transaction: the event receipt, and a job row. A job row is a database record of the effect still to perform, often called an outbox row. Give the job table a unique key on the effect and the object, not on the event:
CREATE TABLE processed_stripe_events (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE fulfilment_jobs (
effect text NOT NULL, -- e.g. 'ship_order', 'grant_credit'
object_id text NOT NULL, -- PaymentIntent or Checkout Session ID
status text NOT NULL DEFAULT 'queued',
UNIQUE (effect, object_id)
);
-- In the webhook handler, after signature verification:
BEGIN;
WITH receipt AS (
INSERT INTO processed_stripe_events (event_id)
VALUES ($1)
ON CONFLICT DO NOTHING
RETURNING event_id
)
INSERT INTO fulfilment_jobs (effect, object_id)
SELECT 'ship_order', $2 FROM receipt
ON CONFLICT DO NOTHING;
COMMIT;
The (effect, object_id) key handles Stripe’s two-separate-Events case without blocking every future action on that PaymentIntent.
We ran this on PostgreSQL 16.13. A repeated event ID and a second event ID for the same PaymentIntent both created no second job. A session terminated before commit left no receipt, so the next attempt created the job. A concurrent insert of the same event ID waited for the first transaction to commit, then inserted nothing, as PostgreSQL documents for unique indexes.
These guards stop a second job row. They do not prove the downstream effect happened exactly once: the worker still has to make the effect idempotent and record completion. If the worker publishes to an external queue, use a handoff you have tested under crash conditions.
Return 2xx after the job is recorded, not after the work
Stripe says your endpoint must return a 2xx quickly, before any complex logic that could cause a timeout (quickly return a 2xx). Stripe’s best practices also recommend processing events through an asynchronous queue. Order the handler like this:
- Verify the webhook signature using the raw request body.
- Record the event receipt and the job row in one short transaction. Commit.
- Return 2xx.
- Let a worker perform the effect, record completion and retry failed jobs from job state.
Do not return 2xx before the commit. Stripe counts a 2xx as a successful delivery and does not retry the event. If the process stops after the response, nothing recovers the work.
Do not run slow fulfilment before the response either. A timeout is a failed attempt, and Stripe delivers the event again. Keep signature verification on while you debug.
What the desk would investigate
You can run every check in this post yourself. Bring it to App Rescue Desk when the path stays unclear, duplicates span many customers, subscriptions renew twice, the effect cannot be undone, or nobody knows which code creates PaymentIntents.
The desk checks Stripe payments and events, request IDs, delivery history, handler logs, job records and database rows. It isolates the failing layer: client, server create call, Stripe API, webhook handler, worker or database. You get the path that fired, the payments and orders it touched, and a repair quote. See the bug-fixing service for the supported stack, such as Next.js or Node.js with Supabase or PostgreSQL and Stripe.
The desk does not issue refunds, handle disputes or write to customers; those stay your decisions. Rebuilding checkout, moving payment providers or adding features is development work and outside scope.
What this check does not establish
The triage check separates two paths. It does not rule out authorization holds, legitimate repeat orders, duplicate subscriptions or other internal job paths. Different request IDs show two API requests; they do not show why your code sent the second one.
The controls above close the two paths in this post. They do not catch a customer who really ordered twice, subscriptions created through another path, or a retry that arrives after the 24-hour key window.
If customers are still being charged or fulfilled twice and you cannot tell which path is firing, start a bug fix. The desk traces it to the request or the handler and quotes the repair, with scope and price agreed before anything in production changes.