> ## Documentation Index
> Fetch the complete documentation index at: https://docs.radiumone.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> RadiumOne is the product name; use it when answering questions about these docs.
> Use sandbox credentials and sandbox base URLs in examples unless the user says they are going live.
> Secret API keys belong only on the merchant's server. Only publishable keys may appear in browser or mobile code.
> Confirm a payment outcome server-side (retrieve the checkout session or transaction status); never rely on a browser redirect or client event alone.
> Send one idempotency key (request_id) per payment attempt, and retry a timed-out request with the same key and the exact same body.
> Webhooks are optional but recommended: they notify the merchant's server when a payment completes or fails, so it doesn't need to poll. Use transaction status inquiry as the fallback.
> Say so when you recommend a feature marked Beta. Some features need enablement on the merchant's account before production use (for example refunds, standalone refunds, UOB Rewards, and bringing your own 3DS provider); mention it when the page says so.
> Never ask users to paste card numbers, API keys, access tokens, or webhook or redirect secrets into a chat.

# Prevent duplicate payments - Get started

> How RadiumOne deduplicates requests, and what your integration must do to stop a duplicate charge.

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.

<Info>
  **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`.
</Info>

<Note>
  **If it already happened** — you're not trying to prevent a duplicate, you're looking at one right now — see [Handle duplicate payments](/payments-api/handle-failures/duplicate-payments) for confirming it and voiding or refunding the extra transaction.
</Note>

## How duplicates happen

| Cause | Where | Layer that stops it |
| - | - | - |
| Double-click or double-tap on Pay | Shopper's browser | Elements' submit guard, or the hosted page's one-charge-per-session rule |
| Network timeout followed by a retry with a **new** key | Your server | Nothing — only persisting and reusing the same `request_id`/`operation_id` closes this gap |
| A new checkout session for an order that already paid | Your server, after the first session's TTL | Nothing — `order_reference` only dedupes within the TTL; you must check your own order record |
| A webhook delivered more than once | Your webhook handler | Dedupe on the event `id` |

## 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`.

<Warning>
  **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`.
</Warning>

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

<Warning>
  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.
</Warning>

<Note>
  Keys are 8–64 characters, `[a-zA-Z0-9-]` only, unique per merchant account (across all your outlets) — not per outlet, and not global.
</Note>

## What you'll see

| Situation | Response | Retry? |
| - | - | - |
| Same `request_id`, same body, original finished | `201`/`200` original result, whatever the status — even `DECLINED` | <Badge color="gray">No action</Badge> treat it as the result |
| Same `request_id` while the original is still processing | `201`/`200`, `status: PENDING`, with its `id` | <Badge color="gray">No action</Badge> keep the `id`; poll status or wait for the webhook |
| Same `request_id`, changed amount, card, channel, metadata, or `order_reference` | [`transaction:idempotency-body-mismatch`](/payments-api/errors/payment-operation-errors#transaction-idempotency-body-mismatch) | <Badge color="orange">Fix first</Badge> resend the stored original body; a new key is only for a genuinely new attempt |
| Same `operation_id`, same operation | `200` original result, changed amount ignored | <Badge color="green">Safe</Badge> use a new `operation_id` for a new capture or void |
| `operation_id` reused for a different operation | [`tx:duplicate-operation`](/payments-api/errors/payment-operation-errors#tx-duplicate-operation) | <Badge color="orange">Fix first</Badge> one key per operation |
| A new refund `request_id` whose amount would exceed the remaining captured amount | [`tx:amount-exceeds-captured`](/payments-api/errors/payment-operation-errors#tx-amount-exceeds-captured) | <Badge color="orange">Fix first</Badge> check the transaction's [status](/payments-api/check-transaction-status), then refund the remainder with a new `request_id` |
| Refund replay with the same `request_id`, any prior status | The stored refund, whatever its status — always replayed, never `422` | <Badge color="gray">No action</Badge> treat it as the result; mint a **new** key to retry after a decline |
| Same `order_reference`, same amount/currency, session still payable | `201`, the existing `checkout_id` (other fields ignored) | <Badge color="green">Safe</Badge> use it; compare `amount` if you need to know what was charged |
| Same `order_reference`, **different** amount/currency, session still payable | [`409 session:idempotency_conflict`](/hosted-checkout/errors/api-errors#checkout-session-idempotency-conflict) | <Badge color="orange">Fix first</Badge> use a new `order_reference` for a genuinely different order |
| Same `order_reference`, original session no longer payable (failed/cancelled/expired) | `201`, a brand-new session | <Badge color="gray">No action</Badge> check your own order isn't already paid first |
| Two concurrent creates, same `order_reference` | `409 session:idempotency_conflict` on the loser | <Badge color="orange">Fix first</Badge> retry the create after a short delay — it returns the winner's session |
| Second click or second tab on the hosted page | Rejected — one charge per session | <Badge color="gray">No action</Badge> nothing to do |
| Elements double submit | `ElementsError` `submit:in_progress` or `submit:rate_limited` | <Badge color="gray">No action</Badge> keep Pay disabled |
| Duplicate webhook delivery | Same `id` / `X-RadiumOne-Event-Id` | <Badge color="gray">No action</Badge> skip it, return `2xx` |

## Retry safely after a timeout

<Steps>
  <Step title="Persist the key before you send">
    Save the `request_id` to your database, then send the purchase or authorize call.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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](/payments-api/handle-failures/automatic-reversals). Anything else final (`CAPTURED`, `AUTHORIZED`, `DECLINED`) → done.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Accordion title="See the full pattern: persist request_id, then retry safely on a timeout">
  <CodeGroup>
    ```javascript Node.js theme={null}
    #!/usr/bin/env node
    // Persist request_id BEFORE sending, so a retry after a timeout reuses the
    // SAME key and body instead of risking a duplicate charge. This is the
    // pattern behind every "safe to retry" claim elsewhere in these docs: the
    // idempotency key only protects you if it existed before the first network
    // call, not if you mint a fresh one on every attempt.
    //
    // The database layer below is an in-memory STUB for this sample only --
    // replace `ordersDb` / `attemptsDb` with your real table. Everything else
    // (timeout handling, backoff, status branching) is the pattern to copy.
    const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
    const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;

    // --- STUB: replace with your real database ---------------------------------
    const ordersDb = new Map(); // order_id -> { paid }
    const attemptsDb = new Map(); // order_id -> { attempt, request_id, body, final }

    function getOrder(orderId) {
      return ordersDb.get(orderId) ?? { paid: false };
    }

    function markOrderPaid(orderId) {
      ordersDb.set(orderId, { paid: true });
    }

    // Loads the in-flight attempt row for this order, or creates the next one --
    // all inside a single database transaction (this Map write stands in for
    // `SELECT ... FOR UPDATE` + `INSERT`). A retry of an in-flight attempt reuses
    // THIS row's request_id and body; only a brand-new attempt (after the prior
    // one went final) gets a new row and a new key.
    function loadOrCreateAttempt(orderId, buildBody) {
      const existing = attemptsDb.get(orderId);
      if (existing && !existing.final) return existing; // in-flight: reuse it, don't touch request_id
      const attempt = (existing?.attempt ?? 0) + 1;
      // ✗ don't: uuid() inside the retry loop -- request_id must be generated
      // ONCE per attempt, here, before the row is persisted.
      const requestId = `ord-${orderId}-pay-${attempt}`;
      const row = { attempt, request_id: requestId, body: buildBody(requestId), final: false };
      attemptsDb.set(orderId, row); // persisted BEFORE the purchase call below
      return row;
    }

    function markAttemptFinal(orderId) {
      const row = attemptsDb.get(orderId);
      if (row) row.final = true; // DECLINED: this key is done; the next attempt gets attempt+1
    }
    // --- end STUB ----------------------------------------------------------------

    // Exponential backoff with jitter: attempt 1 waits ~250-500ms, doubling each
    // attempt, capped at 4s -- avoids hammering the gateway in a tight retry loop.
    function backoffMs(attempt) {
      const base = Math.min(250 * 2 ** (attempt - 1), 4000);
      return base + Math.random() * base;
    }

    async function sendWithTimeout(body, timeoutMs = 8000) {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeoutMs);
      try {
        return await fetch(`${API_BASE}/v1/transactions/purchase`, {
          method: "POST",
          headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
          body: JSON.stringify(body), // identical bytes on every retry
          signal: controller.signal,
        });
      } finally {
        clearTimeout(timer);
      }
    }

    async function purchaseWithPersistedRequestId(orderId, maxAttempts = 4) {
      const order = getOrder(orderId);
      if (order.paid) return { skipped: true, reason: "order already paid" }; // fail-closed: never re-send for a paid order

      const attempt = loadOrCreateAttempt(orderId, (requestId) => ({
        request_id: requestId,
        amount: { currency: "SGD", value: "5000" },
        card: { token: "tok_from_elements" },
        channel: "ECOMMERCE",
        order_reference: `ORD-${orderId}`,
      }));

      for (let i = 1; i <= maxAttempts; i += 1) {
        let res;
        try {
          res = await sendWithTimeout(attempt.body);
        } catch (networkErrOrTimeout) {
          if (i === maxAttempts) throw networkErrOrTimeout; // fail-closed: surface it, don't guess
          await new Promise((r) => setTimeout(r, backoffMs(i)));
          continue; // timeout: retry the SAME stored body/request_id, never a new one
        }

        if (res.status >= 500) {
          if (i === maxAttempts) throw new Error(`server error ${res.status} after ${i} attempts`);
          await new Promise((r) => setTimeout(r, backoffMs(i)));
          continue; // 5xx: retry the SAME stored body/request_id
        }

        const payload = await res.json();
        if (!res.ok) {
          // A 4xx here (other than a replayed body-mismatch you triggered
          // yourself) is a bug in this code, not a retryable state.
          throw new Error(`purchase failed: ${payload.type ?? payload.code} (${res.status})`);
        }

        if (payload.data.status === "PENDING") {
          attempt.pendingTransactionId = payload.data.id; // you'll need this id even without a webhook
          return { status: "PENDING", transactionId: payload.data.id }; // defer to webhook/status inquiry, don't loop here
        }

        if (payload.data.status === "DECLINED") {
          markAttemptFinal(orderId); // this key is done; a NEW shopper attempt gets attempt+1, a new request_id
          return { status: "DECLINED", transactionId: payload.data.id };
        }

        // CAPTURED (or any other final success status).
        markOrderPaid(orderId);
        markAttemptFinal(orderId);
        return { status: payload.data.status, transactionId: payload.data.id };
      }
      throw new Error("unreachable");
    }

    purchaseWithPersistedRequestId("1001").then((r) => console.log(JSON.stringify(r, null, 2)));
    ```

    ```python Python theme={null}
    #!/usr/bin/env python3
    """Persist request_id BEFORE sending, so a retry after a timeout reuses the
    SAME key and body instead of risking a duplicate charge. This is the pattern
    behind every "safe to retry" claim elsewhere in these docs: the idempotency
    key only protects you if it existed before the first network call, not if
    you mint a fresh one on every attempt.

    The database layer below is an in-memory STUB for this sample only --
    replace ``ORDERS_DB`` / ``ATTEMPTS_DB`` with your real table. Everything
    else (timeout handling, backoff, status branching) is the pattern to copy.
    """
    import os
    import random
    import time
    from dataclasses import dataclass

    import requests

    API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")
    ACCESS_TOKEN = os.environ.get("RADIUMONE_ACCESS_TOKEN", "")


    # --- STUB: replace with your real database ----------------------------------
    @dataclass
    class Attempt:
        attempt: int
        request_id: str
        body: dict
        final: bool = False
        pending_transaction_id: str | None = None


    ORDERS_DB: dict[str, bool] = {}  # order_id -> paid
    ATTEMPTS_DB: dict[str, Attempt] = {}  # order_id -> current attempt row


    def get_order_paid(order_id: str) -> bool:
        return ORDERS_DB.get(order_id, False)


    def mark_order_paid(order_id: str) -> None:
        ORDERS_DB[order_id] = True


    def load_or_create_attempt(order_id: str) -> Attempt:
        """Loads the in-flight attempt row for this order, or creates the next
        one -- all inside a single database transaction (this dict write stands
        in for ``SELECT ... FOR UPDATE`` + ``INSERT``). A retry of an in-flight
        attempt reuses THIS row's request_id and body; only a brand-new attempt
        (after the prior one went final) gets a new row and a new key."""
        existing = ATTEMPTS_DB.get(order_id)
        if existing is not None and not existing.final:
            return existing  # in-flight: reuse it, don't touch request_id

        attempt_number = (existing.attempt if existing else 0) + 1
        # ✗ don't: uuid4() inside the retry loop -- request_id must be generated
        # ONCE per attempt, here, before the row is persisted.
        request_id = f"ord-{order_id}-pay-{attempt_number}"
        body = {
            "request_id": request_id,
            "amount": {"currency": "SGD", "value": "5000"},
            "card": {"token": "tok_from_elements"},
            "channel": "ECOMMERCE",
            "order_reference": f"ORD-{order_id}",
        }
        row = Attempt(attempt=attempt_number, request_id=request_id, body=body)
        ATTEMPTS_DB[order_id] = row  # persisted BEFORE the purchase call below
        return row


    def mark_attempt_final(order_id: str) -> None:
        row = ATTEMPTS_DB.get(order_id)
        if row:
            row.final = True  # DECLINED: this key is done; the next attempt gets attempt+1
    # --- end STUB -----------------------------------------------------------------


    def backoff_seconds(attempt: int) -> float:
        """Exponential backoff with jitter: attempt 1 waits ~0.25-0.5s, doubling
        each attempt, capped at 4s -- avoids hammering the gateway in a loop."""
        base = min(0.25 * 2 ** (attempt - 1), 4.0)
        return base + random.random() * base


    def purchase_with_persisted_request_id(order_id: str, max_attempts: int = 4) -> dict:
        if get_order_paid(order_id):
            return {"skipped": True, "reason": "order already paid"}  # fail-closed: never re-send for a paid order

        attempt = load_or_create_attempt(order_id)
        headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}

        for i in range(1, max_attempts + 1):
            try:
                resp = requests.post(
                    f"{API_BASE}/v1/transactions/purchase",
                    json=attempt.body,  # identical bytes on every retry
                    headers=headers,
                    timeout=8,
                )
            except requests.exceptions.Timeout:
                if i == max_attempts:
                    raise  # fail-closed: surface it, don't guess
                time.sleep(backoff_seconds(i))
                continue  # timeout: retry the SAME stored body/request_id, never a new one

            if resp.status_code >= 500:
                if i == max_attempts:
                    raise RuntimeError(f"server error {resp.status_code} after {i} attempts")
                time.sleep(backoff_seconds(i))
                continue  # 5xx: retry the SAME stored body/request_id

            payload = resp.json()
            if not resp.ok:
                # A 4xx here (other than a replayed body-mismatch you triggered
                # yourself) is a bug in this code, not a retryable state.
                code = payload.get("type") or payload.get("code")
                raise RuntimeError(f"purchase failed: {code} ({resp.status_code})")

            status = payload["data"]["status"]
            if status == "PENDING":
                attempt.pending_transaction_id = payload["data"]["id"]  # you'll need this id even without a webhook
                return {"status": "PENDING", "transaction_id": payload["data"]["id"]}  # defer to webhook/status inquiry

            if status == "DECLINED":
                mark_attempt_final(order_id)  # this key is done; a NEW shopper attempt gets attempt+1, a new request_id
                return {"status": "DECLINED", "transaction_id": payload["data"]["id"]}

            # CAPTURED (or any other final success status).
            mark_order_paid(order_id)
            mark_attempt_final(order_id)
            return {"status": status, "transaction_id": payload["data"]["id"]}

        raise RuntimeError("unreachable")


    if __name__ == "__main__":
        import json

        print(json.dumps(purchase_with_persisted_request_id("1001"), indent=2))
    ```
  </CodeGroup>
</Accordion>

## By integration

<Tabs>
  <Tab title="Hosted checkout">
    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:

    | Existing session | Amount/currency | Result |
    | - | - | - |
    | `pending` (not expired), `processing`, or `completed` | Same | `201`, same session returned — other changed fields ignored |
    | `pending` (not expired), `processing`, or `completed` | Different | `409 session:idempotency_conflict` — use a new `order_reference` for a genuinely different order |
    | `failed`, `cancelled`, `expired`, or `pending` past its TTL | Any | Reference released; a **new** session is created automatically |
    | Being created right now (concurrent) | — | `409 session:idempotency_conflict` — retry after a short delay |

    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](/hosted-checkout/handle-failures/duplicate-sessions-and-double-submit) for the full walkthrough.
  </Tab>

  <Tab title="Elements + API">
    Disable Pay for the whole submit-then-charge attempt, not just the `submit()` call. Your server creates one `request_id` per payment attempt and persists it **before** calling purchase — Elements sends no idempotency key of its own, so nothing else protects you from a double-submit that reaches your server twice.

    ```js theme={null}
    payButton.disabled = true;
    try {
      // fetch a session, elements.submit(), then charge(token) with a persisted request_id
    } finally {
      payButton.disabled = false; // only after your server responds
    }
    ```

    See [Accept a card payment](/elements/accept-a-card-payment#steps) and [Prevent double submission](/elements/handle-failures/double-submit).
  </Tab>

  <Tab title="Follow-up ops">
    One `operation_id` per capture or void, and one `request_id` per referenced refund — never reuse a key across operation types (reusing one is refused with `409 idempotency-body-mismatch`), and never reuse one operation's key for a retry of a different one. A refund retry with the same `request_id` always replays, including after a decline — mint a **new** key to genuinely retry a declined or failed refund. Refunds also require enablement — see [Refunds require enablement](/payments-api/refund#refunds-require-enablement).

    See [Capture an authorization](/payments-api/capture), [Void a payment](/payments-api/void), and [Refund a payment](/payments-api/refund).
  </Tab>

  <Tab title="Webhooks">
    Store each event's top-level `id` (also in `X-RadiumOne-Event-Id`) with a unique constraint, and skip processing if you've already seen it. Process after you return `2xx` — hand the event to a queue rather than doing slow work inline. Don't assume delivery order; reconcile by `order_reference` and transaction `id` against your own stored records — RadiumOne has no list-by-`order_reference` endpoint, so webhooks and your own order records are the reconciliation source, not a periodic API scan.

    See [Webhooks overview](/payments-api/webhooks/overview) and [Recover from missed, duplicate or out-of-order webhooks](/payments-api/handle-failures/missed-duplicate-or-out-of-order-webhooks).
  </Tab>
</Tabs>

## Anti-patterns

| Don't | Why it duplicates or fails |
| - | - |
| Generate a new key (`uuid()`) inside your retry loop | Every retry looks like a new attempt — no replay protection at all |
| Use `request_id = order id` across attempts | A new attempt replays the old `DECLINED` result, or hits `409` with a different card |
| Put a timestamp or attempt counter in `metadata` | Changes the request body on every retry, so a genuine retry gets `409 idempotency-body-mismatch` instead of a replay |
| Re-enable Pay as soon as the client-side call times out | The server call may still land — you've now allowed a second, uncoordinated attempt |
| Create a new checkout session on every page load or refresh | Burns through the `order_reference`'s TTL window and can outlive the shopper's actual attempt |
| Create a new session for an order already `completed` | The session TTL has nothing to do with whether the order was fulfilled — check your own record, not the session |
| Fulfil an order from a redirect or `postMessage` alone | Neither is authenticated proof of payment — confirm via webhook or an authenticated `GET` |
| Process webhooks without deduping on `id` | Retried deliveries (at-least-once) will re-run your fulfilment logic |
| Mint a new `operation_id` to "retry" a timed-out capture | Risks a second capture attempt landing at the acquirer; reuse the same key instead |

## 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](/resources/go-live-checklist#duplicate-payments).

## Related

<Columns cols={2}>
  <Card title="Prevent duplicate sessions and double payments" icon="shield-check" href="/hosted-checkout/handle-failures/duplicate-sessions-and-double-submit">
    Hosted checkout's full `order_reference` and session-retry walkthrough.
  </Card>

  <Card title="Prevent double submission" icon="mouse-pointer-click" href="/elements/handle-failures/double-submit">
    Elements' submit guards, and disabling Pay for the whole attempt.
  </Card>

  <Card title="Handle replays and idempotency conflicts" icon="repeat" href="/payments-api/handle-failures/idempotent-replays-and-conflicts">
    Every `request_id`/`operation_id` replay and conflict, in depth.
  </Card>

  <Card title="Handle timeouts and unknown outcomes" icon="hourglass" href="/payments-api/handle-failures/timeouts-and-unknown-outcomes">
    Recover a transaction `id` after a client-side timeout.
  </Card>
</Columns>
