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

# Duplicate payments - Hosted checkout

> How to reuse an order reference safely and avoid creating duplicate checkout sessions.

<Info>
  **TL;DR:** Same `order_reference`, same amount and currency, within the session TTL returns the same session. A different amount or currency on a still-payable session is rejected with `409`, not silently applied. After the session is no longer payable (TTL elapsed, declined, or cancelled), a new session is possible. Always check your own order state before creating one.
</Info>

`order_reference` is your idempotency key for session creation, and the hosted page itself guards against a shopper submitting payment twice for the same session. But that protection is **time-boxed** (it lasts only the session's TTL) and doesn't compare the request body — so it prevents a duplicate session, not necessarily a duplicate charge for an already-paid order.

## When this happens

* Your server retries a create call after a timeout or network error, using the same `order_reference`.
* Your server retries with the same `order_reference` but a changed field (a different amount, for example).
* Your server reuses an `order_reference` after its original session's TTL has already elapsed.
* Two create calls for the same `order_reference` race each other (rare — only a genuine concurrent race, not a normal sequential retry).
* A shopper double-clicks pay, or your page double-submits, on the hosted page itself.

## What you see

| Situation | Response |
| - | - |
| Retry create with the same `order_reference`, same amount and currency, within TTL | `201`, same `checkout_id` as the original — other changed fields (line items, metadata, URLs) are silently ignored, not applied |
| Retry create with the same `order_reference` but a **different amount or currency**, while the original session is still payable (`pending` and not expired, `processing`, or `completed`) | [`409 session:idempotency_conflict`](/hosted-checkout/errors/api-errors#checkout-session-idempotency-conflict) — "order\_reference is already in use for a different amount or currency" |
| Retry create with the same `order_reference` **after** the original session is no longer payable (`failed`, `cancelled`, `expired`, or `pending` past its TTL) | A **new** session is created — the reference is released automatically; check your own order isn't already paid first |
| Two creates for the same `order_reference` genuinely race | `409 session:idempotency_conflict` — "A session for this order\_reference is still being created; retry shortly" |
| Shopper double-submits on the hosted page while the first attempt is still in flight | The second submission is rejected — only one charge attempt proceeds per session |
| Re-create (same amount/currency) after the session reached `completed` | Returns that same completed session again — it does **not** charge a second time |
| Re-create after the session reached `failed`, was `cancelled`, or `expired` | A **new** session is created automatically — these are no longer payable, so the reference is released rather than blocking reuse |

## What to do

<Steps>
  <Step title="Reuse order_reference per order attempt, not per HTTP call">
    Generate one `order_reference` per order attempt and reuse it for every retry of that same attempt ([API reference](/hosted-checkout/reference/checkout-sessions/create-a-checkout-session)). A changed amount or currency on a retry gets rejected with `409` rather than silently applied while the original session is still live — don't send a different total expecting it to update an in-flight order.

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Create a hosted-checkout session (Live: `billing_details` field). Redirect
      # the shopper to checkout_url. Same order_reference within the TTL replays
      # the existing session (201) instead of creating a duplicate — safe to retry.
      set -euo pipefail

      CHECKOUT_BASE="${RADIUMONE_CHECKOUT_BASE:-https://checkout-sandbox.radiumone.io}"
      : "${RADIUMONE_SECRET_KEY:?set RADIUMONE_SECRET_KEY to your r1sk_* secret key}"

      curl -sS -X POST "$CHECKOUT_BASE/api/v1/checkout/sessions" \
        -H "Content-Type: application/json" \
        -H "X-Api-Key: $RADIUMONE_SECRET_KEY" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Create a hosted-checkout session (Live: `billing_details` field). Redirect
      // the shopper to checkout_url. Node 18+ ESM fetch.
      // Env: RADIUMONE_SECRET_KEY, RADIUMONE_CHECKOUT_BASE (optional override).
      //
      // Same order_reference within the TTL replays the existing session (201)
      // instead of creating a duplicate — safe to retry with the same body.
      import { readFileSync } from "node:fs";

      const CHECKOUT_BASE = process.env.RADIUMONE_CHECKOUT_BASE || "https://checkout-sandbox.radiumone.io";
      const secretKey = process.env.RADIUMONE_SECRET_KEY;
      const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));

      // 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 createCheckoutSession(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${CHECKOUT_BASE}/api/v1/checkout/sessions`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                "X-Api-Key": secretKey,
              },
              body: JSON.stringify(body), // same order_reference every attempt
            });
          } catch (networkErr) {
            if (attempt === maxAttempts) throw networkErr;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue;
          }

          if (res.status >= 500) {
            if (attempt === maxAttempts) throw new Error(`server error ${res.status} after ${attempt} attempts`);
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue;
          }

          const payload = await res.json();
          if (!res.ok) {
            throw new Error(`checkout session create failed: ${payload.code ?? payload.type} (${res.status})`);
          }
          return payload; // redirect the shopper to payload.data.checkout_url
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Create a hosted-checkout session (Live: ``billing_details`` field).
      Redirect the shopper to checkout_url.

      Same order_reference within the TTL replays the existing session (201)
      instead of creating a duplicate — safe to retry with the same body.
      """
      import json
      import os
      import random
      import time
      from pathlib import Path

      import requests

      CHECKOUT_BASE = os.environ.get("RADIUMONE_CHECKOUT_BASE", "https://checkout-sandbox.radiumone.io")


      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 create_checkout_session(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          headers = {"X-Api-Key": os.environ.get("RADIUMONE_SECRET_KEY", "")}

          for attempt in range(1, max_attempts + 1):
              try:
                  resp = requests.post(f"{CHECKOUT_BASE}/api/v1/checkout/sessions", json=body, headers=headers, timeout=30)
              except requests.exceptions.Timeout:
                  if attempt == max_attempts:
                      raise
                  time.sleep(backoff_seconds(attempt))
                  continue

              if resp.status_code >= 500:
                  if attempt == max_attempts:
                      raise RuntimeError(f"server error {resp.status_code} after {attempt} attempts")
                  time.sleep(backoff_seconds(attempt))
                  continue

              payload = resp.json()
              if not resp.ok:
                  code = payload.get("code") or payload.get("type")
                  raise RuntimeError(f"checkout session create failed: {code} ({resp.status_code})")
              return payload  # redirect the shopper to payload["data"]["checkout_url"]

          raise RuntimeError("unreachable")


      if __name__ == "__main__":
          print(json.dumps(create_checkout_session(), indent=2))
      ```
    </CodeGroup>
  </Step>

  <Step title="Check your order isn't already paid before creating a session">
    The `order_reference` guard only lasts the session's TTL (5–60 minutes) — after that, the same reference happily creates a brand-new session. If your original order already completed (for example, its webhook arrived after your client gave up waiting), a later retry with the same or a fresh `order_reference` will charge the shopper again. Check your own order state before every create call — see [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments) for the full pattern.
  </Step>

  <Step title="Handle a 409, either cause">
    `409 session:idempotency_conflict` covers two distinct causes with different `detail` text: a **concurrent** create for the same reference still in flight ("still being created; retry shortly" — the losing call has no `checkout_id` to look up, so retry the create after a short delay instead of fetching one), or a **changed amount or currency** against a still-payable session ("already in use for a different amount or currency" — fix the request or use a new `order_reference`).
  </Step>

  <Step title="A new order_reference isn't required after a decline or cancellation, but keeps things clean">
    Reusing the `order_reference` from a `failed`, `cancelled`, or `expired` session now creates a **new** session automatically — the reference is released as soon as the original stops being payable, so you don't strictly need a new value. Many merchants still mint a fresh `order_reference` per attempt for their own tracking. See [Handle declined hosted checkout payments](/hosted-checkout/handle-failures/payment-declined).
  </Step>
</Steps>

## Related

<Columns cols={2}>
  <Card title="Prevent duplicate payments" icon="shield-check" href="/get-started/api-basics/prevent-duplicate-payments">
    The full guide to guarding your own order state.
  </Card>

  <Card title="Redirect integration" icon="arrow-right" href="/hosted-checkout/redirect-integration#steps">
    Where the create-session retry guidance fits into the full flow.
  </Card>

  <Card title="Session lifecycle" icon="clock" href="/hosted-checkout/session-lifecycle#replay-and-retries">
    Replay semantics in full, including the session-states diagram.
  </Card>

  <Card title="Handle failures" icon="triangle-alert" href="/hosted-checkout/handle-failures/overview">
    All ten failure scenarios, symptom → page.
  </Card>
</Columns>
