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

# Accept a card payment - Elements SDK

> Mount a card field, tokenize it in the browser, and charge the card from your server — the Live, no-3D-Secure integration path.

This is the Live "Elements without 3D Secure" path: mount a card field, tokenize the card in the browser, and charge it from your server. It's the fastest way to accept a card with your own checkout UI.

## How it works

1. Your server creates an access token, then a payment session, and passes `session_id`, `session_secret`, and `pubkey_jws` to the browser.
2. The browser mounts a card field and the shopper types their card details directly into it.
3. The browser calls `elements.submit()`. The card iframe encrypts the card data and tokenizes it with RadiumOne directly — your JavaScript never sees the raw card number.
4. The browser sends the resulting token to your server.
5. Your server charges the token with `POST /v1/transactions/purchase`.
6. Your server returns the result; your page shows success or a decline message.

## Before you begin

<Info>
  Complete [Install and load Elements](/elements/install-and-load) first. You'll need a sandbox secret key (`r1sk_test_…`) on your server and a publishable key (`r1pk_test_…`) in the browser.
</Info>

<Info>
  Amounts are always integers in the currency's minor unit. For example, `5000` for `SGD` means SGD 50.00.
</Info>

## Steps

<Steps>
  <Step title="Create an access token on your server">
    Exchange your secret key for a short-lived access token ([API reference](/payments-api/reference/authentication/exchange-api-key-for-jwt)). Cache it server-side and reuse it until it expires.

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Exchange a secret key for a short-lived access token (300s) and a refresh
      # token (3900s, single-use rotation). Never expose the secret key to a browser.
      set -euo pipefail

      API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"

      curl -sS -X POST "$API_BASE/v1/auth/token" \
        -H "Content-Type: application/json" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Exchange a secret key for a short-lived access token. Node 18+ ESM fetch.
      // Env: RADIUMONE_SECRET_KEY (server-side only), RADIUMONE_API_BASE (optional override).
      import { readFileSync } from "node:fs";

      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));
      if (process.env.RADIUMONE_SECRET_KEY) body.api_key = process.env.RADIUMONE_SECRET_KEY;

      async function createAccessToken() {
        const res = await fetch(`${API_BASE}/v1/auth/token`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`auth/token failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        // Cache access_token server-side for up to expires_in seconds; use
        // refresh_token to get a new pair before it lapses.
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Exchange a secret key for a short-lived access token. Python 3.10+, requests."""
      import json
      import os
      from pathlib import Path

      import requests

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


      def create_access_token() -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          if os.environ.get("RADIUMONE_SECRET_KEY"):
              body["api_key"] = os.environ["RADIUMONE_SECRET_KEY"]

          resp = requests.post(f"{API_BASE}/v1/auth/token", json=body, timeout=30)
          payload = resp.json()
          if not resp.ok:
              raise RuntimeError(f"auth/token failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
          # Cache access_token server-side for up to expires_in seconds; use
          # refresh_token to get a new pair before it lapses.
          return payload


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

  <Step title="Create a payment session on your server">
    Create a session ([API reference](/payments-api/reference/sessions/create-tokenization-session)) and return `session_id`, `session_secret`, and `pubkey_jws` to the browser as-is — never re-serialize `pubkey_jws`. If this call fails, see [Handle expired sessions and card tokens](/elements/handle-failures/expired-sessions-and-tokens).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Create a tokenization session for Elements. Pass session_id/session_secret/
      # pubkey_jws to the browser unchanged — never re-serialize pubkey_jws.
      set -euo pipefail

      API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
      : "${RADIUMONE_ACCESS_TOKEN:?set RADIUMONE_ACCESS_TOKEN to a Bearer access token}"

      curl -sS -X POST "$API_BASE/v1/sessions" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Create a tokenization/checkout session for Elements. Node 18+ ESM fetch.
      // Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
      import { readFileSync } from "node:fs";

      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
      const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));

      async function createPaymentSession() {
        const res = await fetch(`${API_BASE}/v1/sessions`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${accessToken}`,
          },
          body: JSON.stringify(body),
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`sessions create failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        // Pass session_id, session_secret and pubkey_jws to the browser byte-for-byte.
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Create a tokenization/checkout session for Elements. Python 3.10+, requests."""
      import json
      import os
      from pathlib import Path

      import requests

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


      def create_payment_session() -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          resp = requests.post(
              f"{API_BASE}/v1/sessions",
              json=body,
              headers={"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"},
              timeout=30,
          )
          payload = resp.json()
          if not resp.ok:
              raise RuntimeError(f"sessions create failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
          # Pass session_id, session_secret and pubkey_jws to the browser byte-for-byte.
          return payload


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

  <Step title="Mount a card field in the browser">
    <Tabs>
      <Tab title="Vanilla JS">
        ```html theme={null}
        <div id="card-field"></div>
        <button id="pay" type="button" disabled>Pay</button>
        ```

        ```js theme={null}
        const elements = radiumone.elements();
        const card = elements.create("card");
        card.mount("#card-field");

        const payButton = document.getElementById("pay");
        card.on("change", (event) => {
          // Gate on `valid`, not `complete` — `complete` only turns true after
          // blur validation runs, so `valid` is the earlier, more reliable signal.
          payButton.disabled = !event.valid;
        });
        ```
      </Tab>

      <Tab title="React">
        ```tsx theme={null}
        import { CardElement, useElements } from "@cubepay/react-radiumone-js";
        import { useState } from "react";

        function CheckoutForm() {
          const elements = useElements();
          const [ready, setReady] = useState(false);
          const [submitting, setSubmitting] = useState(false); // guards Pay for the whole submit + charge call

          async function pay() {
            // see the next step — fetch a session, call elements.submit(), then charge()
          }

          return (
            <>
              <CardElement onChange={(e) => setReady(e.valid)} />
              <button disabled={!ready || submitting} onClick={pay}>Pay</button>
            </>
          );
        }
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step title="Tokenize the card on submit">
    Call your server for a session (steps 1–2), pass its response straight into `elements.submit()`, then charge the result — all under one guard so the Pay button stays disabled for the entire attempt, not just the `submit()` call. This is the pattern every failure guide on this site assumes; see [Prevent double submission](/elements/handle-failures/double-submit) for why the guard matters.

    <Tabs>
      <Tab title="Vanilla JS">
        ```js theme={null}
        payButton.addEventListener("click", async () => {
          payButton.disabled = true; // disable before the first network call, not after
          try {
            const session = await fetch("/api/create-session", { method: "POST" }).then((r) => r.json());

            const { token } = await elements.submit({
              sessionId: session.session_id,
              sessionSecret: session.session_secret,
              pubkeyJws: session.pubkey_jws,
            });

            await charge(token); // your server call to POST /v1/transactions/purchase — see the next step
          } catch (err) {
            if (err.name === "ElementsError") {
              showError(err.customerMessage ?? "Payment could not be processed.");
            } else {
              throw err;
            }
          } finally {
            payButton.disabled = false; // re-enable only once your server has responded, success or failure
          }
        });
        ```
      </Tab>

      <Tab title="React">
        ```tsx theme={null}
        async function pay() {
          setSubmitting(true); // disable before the first network call, not after
          try {
            const session = await fetch("/api/create-session", { method: "POST" }).then((r) => r.json());

            const { token } = await elements.submit({
              sessionId: session.session_id,
              sessionSecret: session.session_secret,
              pubkeyJws: session.pubkey_jws,
            });

            await charge(token); // your server call to POST /v1/transactions/purchase — see the next step
          } catch (err) {
            if (err.name === "ElementsError") {
              showError(err.customerMessage ?? "Payment could not be processed.");
            } else {
              throw err;
            }
          } finally {
            setSubmitting(false); // re-enable only once your server has responded, success or failure
          }
        }
        ```
      </Tab>
    </Tabs>

    `submit()` throws an [`ElementsError`](/elements/errors/error-object-and-handling) for validation failures, tokenization failures, and gateway bind errors. Always show `customerMessage` (never the raw `message`, which is developer-facing) and check `retryAllowed` before offering a retry. See [Handle tokenization failures](/elements/handle-failures/tokenization-failures) and [Handle browser network errors](/elements/handle-failures/network-errors) for the specific failure paths.
  </Step>

  <Step title="Charge the token on your server">
    Send the token from your server — never from the browser — to `POST /v1/transactions/purchase` ([API reference](/payments-api/reference/payments/purchase)). If the token has expired, see [Handle expired sessions and card tokens](/elements/handle-failures/expired-sessions-and-tokens).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Purchase (authorise + capture in one call). Any 2xx is a response — branch
      # on data.status. On a timeout/5xx/PENDING, retry with the SAME request_id;
      # never mint a new one for the same order attempt.
      set -euo pipefail

      API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
      : "${RADIUMONE_ACCESS_TOKEN:?set RADIUMONE_ACCESS_TOKEN to a Bearer access token}"

      curl -sS -X POST "$API_BASE/v1/transactions/purchase" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Purchase (authorise + capture in one call). Node 18+ ESM fetch.
      // Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
      //
      // Shared result pattern: any 2xx is a response you branch on `data.status`.
      // On a network timeout, a 5xx, or `status:"PENDING"`, retry with the SAME
      // request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
      // request_id for the same order attempt.
      import { readFileSync } from "node:fs";

      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
      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 createPurchase(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/purchase`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                Authorization: `Bearer ${accessToken}`,
              },
              body: JSON.stringify(body), // same request_id every attempt
            });
          } catch (networkErr) {
            if (attempt === maxAttempts) throw networkErr;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue; // network timeout: retry with the same body/request_id
          }

          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; // retry with the same request_id
          }

          const payload = await res.json();
          if (!res.ok) {
            // 4xx: not retryable by re-sending — fix the request, or handle
            // urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
            throw new Error(`purchase failed: ${payload.type ?? payload.code} (${res.status})`);
          }

          if (payload.data.status === "PENDING") {
            if (attempt === maxAttempts) return payload; // caller should poll GET status / wait for webhook
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue; // retry the same request_id
          }

          // Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
          return payload;
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Purchase (authorise + capture in one call). Python 3.10+, requests.

      Shared result pattern: any 2xx is a response you branch on ``status``. On a
      network timeout, a 5xx, or ``status: "PENDING"``, retry with the SAME
      request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
      request_id for the same order attempt.
      """
      import json
      import os
      import random
      import time
      from pathlib import Path

      import requests

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


      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_purchase(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          headers = {"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"}

          for attempt in range(1, max_attempts + 1):
              try:
                  resp = requests.post(f"{API_BASE}/v1/transactions/purchase", json=body, headers=headers, timeout=30)
              except requests.exceptions.Timeout:
                  if attempt == max_attempts:
                      raise
                  time.sleep(backoff_seconds(attempt))
                  continue  # network timeout: retry with the same body/request_id

              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  # retry with the same request_id

              payload = resp.json()
              if not resp.ok:
                  # 4xx: not retryable by re-sending — fix the request, or handle
                  # urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"purchase failed: {code} ({resp.status_code})")

              if payload["data"]["status"] == "PENDING":
                  if attempt == max_attempts:
                      return payload  # caller should poll GET status / wait for webhook
                  time.sleep(backoff_seconds(attempt))
                  continue  # retry the same request_id

              # Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
              return payload

          raise RuntimeError("unreachable")


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

    <Tip>
      Create and persist one `request_id` per payment attempt on your server **before** calling purchase, and reuse it if you retry — Elements sends no idempotency key of its own, so your server is the only thing standing between a retried charge call and a duplicate payment. See [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments) for the full pattern.
    </Tip>

    <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>
  </Step>
</Steps>

## Handle the result

**Any 2xx response is a result you must branch on `status`** — never on `response_code` (that's the verbatim host/acquirer code; useful for support tickets, not for your app logic).

| Status | Meaning | What to do |
| - | - | - |
| `AUTHORIZED` | Funds reserved (authorize only) | Capture within the capture window, or void to release |
| `CAPTURED` | Funds captured (purchase, capture, or refund) | Fulfil the order (or process the refund) |
| `VOIDED` | Authorization released | No funds moved |
| `DECLINED` | Issuer or acquirer declined | Final for this attempt — don't retry the same card without a new attempt from the shopper |
| `FAILED` | The transaction didn't complete — the acquirer returned a non-decline error code, or the gateway couldn't place the request. **Not a guarantee that no funds moved** — `VOIDED` and `REVERSED` are the only statuses that positively assert that. | Confirm via `GET /v1/transactions/{id}/status` before retrying, then retry (a genuinely new attempt, not a replay of the same `request_id`) with a **new** `request_id` |
| `PENDING` | Outcome not yet known (async) | Wait for a webhook, or poll `GET /v1/transactions/{id}/status` |
| `AUTH_EXPIRED` | Authorization lapsed before capture | Create a new authorization |
| `REVERSAL_PENDING` / `REVERSED` | Automatic compensating reversal after an upstream timeout left the outcome genuinely unknown (never left `FAILED` in this case) | No merchant action; webhook confirms the final state |

| Key | Used by | On replay |
| - | - | - |
| `request_id` | Purchase, authorize, standalone and referenced refunds | Same body, same operation type → the original transaction, whatever its status — including `PENDING`, `DECLINED`, or `FAILED`. Changed body, or the same key reused for a different operation type → [`transaction:idempotency-body-mismatch`](/payments-api/errors/payment-operation-errors#transaction-idempotency-body-mismatch). Purchase/authorize/standalone-refund compare `amount`, `currency`, `payment_method_type`, `channel`, the card's `pan_prefix` (first 8 digits — not the full token), `metadata`, and `order_reference`; a **referenced refund** compares only the original transaction and `amount` (`reason` isn't compared) and its replay check runs before the refund gates, so it always replays, even a `DECLINED`/`FAILED` one — mint a **new** `request_id` to retry after a decline. None of these compare `three_ds` or `loyalty`, so changing either on a retry replays the original silently instead of failing. |
| `operation_id` | Capture, void | Same operation type on the same transaction → the original result (body is never compared, so a changed amount is silently ignored). A different operation type reusing the key → [`tx:duplicate-operation`](/payments-api/errors/payment-operation-errors#tx-duplicate-operation). |

<Warning>
  Balance inquiry also takes a `request_id` field, but it isn't an idempotency key — there's no dedup or replay store. Every call re-queries the rewards host, even with the same `request_id`.
</Warning>

<Tip>
  Keys are 8–64 characters, `[a-zA-Z0-9-]` only, unique per merchant account. Generate one key per order **attempt** and persist it to your database before you send the first request — never mint a new key just to retry the same attempt. See [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments).
</Tip>

`PENDING` means the outcome isn't known yet — most often after a processor timeout. Don't assume success or failure. Recover it one of two ways:

1. **Wait for a webhook** (`payment.*`, `authorization.*`, `refund.*` — see [Webhook event types](/payments-api/webhooks/event-types)).
2. **Call `GET /v1/transactions/{id}/status`** for a live inquiry against the acquirer.

If you don't have the transaction `id` yet — a client-side timeout before the first response arrived — replay the same request with the same `request_id` and body. The replay returns the stored transaction and its `id`, whatever status it's reached. Never re-submit with a **new** idempotency key just because the first attempt is slow — that risks a second charge for the same order.

`token` from `elements.submit()` is single-use context tied to that session; treat it as opaque and never branch on its shape.

## Test your integration

Use a sandbox publishable key and a test card. See [Test your integration](/resources/test-your-integration#elements) for sandbox test cards and scenarios.

## Go-live notes

* Complete [Install and load Elements](/elements/install-and-load) and [Content Security Policy](/elements/content-security-policy) before switching to production keys.
* Review the [go-live checklist](/resources/go-live-checklist).
* Confirm the payment result from your server (webhook or authenticated `GET`) — never from the browser alone.

## Next steps

<Columns cols={2}>
  <Card title="Add 3D Secure" icon="shield-check" href="/elements/three-d-secure/add-three-d-secure">
    Authenticate the card with RadiumOne 3D Secure before charging it.
  </Card>

  <Card title="Card fields and events" icon="credit-card" href="/elements/card-fields-and-events">
    Split fields, validation states, and events in depth.
  </Card>
</Columns>
