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
- 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. - Your server — You: check the order isn’t already paid before you charge or create a session; create and save a
request_idbefore the first call; retry with the samerequest_idand the identical body. RadiumOne: no safeguard at this layer. Elements sends no idempotency key, so your server owns therequest_id. - RadiumOne — Checkout: the same
order_referencewithin 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; samerequest_idwith a changed body →409 urn:radiumone:transaction:idempotency-body-mismatch;operation_idreused 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. - 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
idyou’ve already processed; reconcile byorder_referenceand transactionid.
How RadiumOne reacts to a repeated request
- A request arrives with a key:
request_idon purchase, authorize, standalone and referenced refunds;operation_idon capture and void. Keys are scoped to your merchant account. Balance inquiry also takes arequest_idfield, but it isn’t deduplicated — see the note below. - Key not used before → processed as a new request and stored against the key.
- Reused
request_id, body changed (amount, card,channel,metadataororder_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 coverthree_dsorloyalty— changing either on a retry replays the original silently instead of returning409. - Reused
request_id, same body, first request still processing → the existing transaction comes back, usuallyPENDING, with itsid. Poll or wait for the webhook. - Reused
request_id, same body, first request finished → the stored result with the original HTTP code, whatever the status:CAPTURED,DECLINEDorFAILED. - Reused
operation_id, same operation on the same transaction →200with the first result. The body isn’t compared, so a changed amount is ignored. - Reused
operation_id, different operation →409 urn:radiumone:tx:duplicate-operation.
Referenced refunds always replay on a repeatedrequest_id— matched on the same original transaction, the sameamount, and a key already used for a refund (reasonisn’t compared) — never422, even if another refund changed the refundable amount in between; the replay check runs before that cap check. ADECLINEDorFAILEDrefund replays too, so retry after a decline with a newrequest_id. A wrongtransaction_idin the path returns404before any of this runs.
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.See the full pattern: persist request_id, then retry safely on a timeout
See the full pattern: persist request_id, then retry safely on a timeout
By integration
- Hosted checkout
- Elements + API
- Follow-up ops
- Webhooks
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_idpersisted before the first send, reused on every retry of the same attempt - One
order_referenceper checkout attempt;transaction_idstored in your order record, not theorder_referenceitself - 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
idbefore crediting or fulfilling anything - Capture, void, and refund each use their own key — never a reused or shared one
Related
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.