Skip to main content
A duplicate charge almost always comes from a retry, not a second sale: a network timeout, a double-click, or a second checkout session for an order that already paid. This page is the cross-product model — the product-specific failure pages link back here for the full picture.
TL;DR — One request_id (or operation_id) per payment attempt, saved to your database before you send it, reused on every retry. One order_reference per checkout attempt; check your own order record before creating a new session. Disable Pay until you hear back. Dedupe webhooks on id.
If it already happened — you’re not trying to prevent a duplicate, you’re looking at one right now — see Handle duplicate payments for confirming it and voiding or refunding the extra transaction.

How duplicates happen

The defence layers

  1. Shopper’s browser — You: disable Pay until you get a response; create one checkout session per order attempt. RadiumOne: Elements rejects a second submit while one is running and allows one submit per second (submit:in_progress, submit:rate_limited); the hosted page locks the session while a payment is processing.
  2. Your server — You: check the order isn’t already paid before you charge or create a session; create and save a request_id before the first call; retry with the same request_id and the identical body. RadiumOne: no safeguard at this layer. Elements sends no idempotency key, so your server owns the request_id.
  3. RadiumOne — Checkout: the same order_reference within the session TTL returns the same session; one payment key per session, so a session can’t charge twice. Payments API: replaying a key returns the original result; same request_id with a changed body → 409 urn:radiumone:transaction:idempotency-body-mismatch; operation_id reused for another operation → 409 urn:radiumone:tx:duplicate-operation; refunds above the captured amount → 422 urn:radiumone:tx:amount-exceeds-captured. You: treat a 409 as a bug in your retry, never as a decline; never swap in a new key to get past a 409; reuse the same session for retries while it’s still open; if a refund retry returns 422, check the refund with GET before trying again.
  4. Webhooks and reconciliation — RadiumOne: signed events, delivered at least once and retried for about 29.6 hours by default (configurable, not a guarantee). You: skip events whose id you’ve already processed; reconcile by order_reference and transaction id.
The one rule that ties layers 1 and 3 together: one order_reference per checkout attempt. Reuse it — you get the same open session back — for any retry while that session is still within its TTL. Once the session is no longer open (expired, declined, or you’re starting over), check your own order record before creating another one: skip it if the order’s already paid, otherwise mint a new order_reference for the new attempt. The gap to close yourself: after the session TTL, the same order_reference creates a new session, and the Payments API never enforces a unique order_reference.

How RadiumOne reacts to a repeated request

  1. A request arrives with a key: request_id on purchase, authorize, standalone and referenced refunds; operation_id on capture and void. Keys are scoped to your merchant account. Balance inquiry also takes a request_id field, but it isn’t deduplicated — see the note below.
  2. Key not used before → processed as a new request and stored against the key.
  3. Reused request_id, body changed (amount, card, channel, metadata or order_reference), or reused for a different operation type (for example, a purchase’s key later sent to a refund) → 409 urn:radiumone:transaction:idempotency-body-mismatch. Resend the stored original. This check doesn’t cover three_ds or loyalty — changing either on a retry replays the original silently instead of returning 409.
  4. Reused request_id, same body, first request still processing → the existing transaction comes back, usually PENDING, with its id. Poll or wait for the webhook.
  5. Reused request_id, same body, first request finished → the stored result with the original HTTP code, whatever the status: CAPTURED, DECLINED or FAILED.
  6. Reused operation_id, same operation on the same transaction → 200 with the first result. The body isn’t compared, so a changed amount is ignored.
  7. Reused operation_id, different operation → 409 urn:radiumone:tx:duplicate-operation.
Referenced refunds always replay on a repeated request_id — matched on the same original transaction, the same amount, and a key already used for a refund (reason isn’t compared) — never 422, even if another refund changed the refundable amount in between; the replay check runs before that cap check. A DECLINED or FAILED refund replays too, so retry after a decline with a new request_id. A wrong transaction_id in the path returns 404 before any of this runs.
Balance inquiry isn’t deduplicated, despite also taking a request_id field — every call re-queries the rewards host, whether or not you reuse the same key. Don’t rely on it to protect against a double-submit.
Keys are 8–64 characters, [a-zA-Z0-9-] only, unique per merchant account (across all your outlets) — not per outlet, and not global.

What you’ll see

Retry safely after a timeout

1

Persist the key before you send

Save the request_id to your database, then send the purchase or authorize call.
2

No response? Resend the identical body

Connection reset, no response, or a 5xx — resend the exact same body with the same request_id, with backoff.
3

Still nothing? Stop guessing

After a few tries with no response, wait for the webhook instead of guessing — RadiumOne doesn’t expose a list-by-order_reference lookup, so don’t keep retrying blind.
4

Branch on the replay's status

PENDING → keep the id and poll status or wait for the webhook. FAILED → the transaction didn’t complete and isn’t a guarantee that no funds moved (only VOIDED/REVERSED assert that); confirm with a status GET before any new attempt with a new request_id. If the outcome was genuinely unknown after an upstream timeout, RadiumOne arms an automatic reversal (REVERSAL_PENDING → REVERSED) instead of leaving it FAILED — see Understand automatic reversals. Anything else final (CAPTURED, AUTHORIZED, DECLINED) → done.
5

Never mint a new key for the same attempt

A new request_id “to retry faster” is the one move that risks a second charge.

By integration

One order_reference per checkout attempt. What a retry with the same order_reference does depends on the existing session’s state and whether the amount/currency match:After a decline, cancellation, or expiry, the reference frees up on its own — reusing it or minting a new one both work, though a new one keeps your tracking cleaner. Once the session is no longer open, check your own order record before creating another one. Each session can charge at most once.See Prevent duplicate sessions and double payments for the full walkthrough.

Anti-patterns

Checklist

  • request_id/operation_id persisted before the first send, reused on every retry of the same attempt
  • One order_reference per checkout attempt; transaction_id stored in your order record, not the order_reference itself
  • Your own order state checked before creating a new checkout session, every time
  • Pay disabled for the whole submit-and-charge attempt, not just the submit() call
  • Webhook handler dedupes on event id before crediting or fulfilling anything
  • Capture, void, and refund each use their own key — never a reused or shared one
Mirrored in the go-live checklist.

Prevent duplicate sessions and double payments

Hosted checkout’s full order_reference and session-retry walkthrough.

Prevent double submission

Elements’ submit guards, and disabling Pay for the whole attempt.

Handle replays and idempotency conflicts

Every request_id/operation_id replay and conflict, in depth.

Handle timeouts and unknown outcomes

Recover a transaction id after a client-side timeout.
Last modified on September 15, 2026