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

# Charge or authorize - Payments API

> Create a purchase to charge a card immediately, or an authorization to reserve funds and capture later, using the RadiumOne Payments API.

Call the Payments API directly from your server when you're using RadiumOne Elements (or your own card-on-file flow) instead of hosted checkout. Use **purchase** to charge a card in one step, or **authorize** to reserve funds and capture the amount later.

## Purchase vs. authorize

| Use | Endpoint | Result status on success | When to use it |
| - | - | - | - |
| Purchase | `POST /v1/transactions/purchase` | `CAPTURED` | Card-present-equivalent flows where you fulfil immediately: digital goods, most e-commerce checkouts |
| Authorize | `POST /v1/transactions/auth` | `AUTHORIZED` | You need to reserve funds before you know the final amount or can fulfil — ship-later goods, pre-orders, tabs |

An authorization must be [captured](/payments-api/capture) within your account's capture window or it lapses to `AUTH_EXPIRED`. See [Payment lifecycle](/payments-api/payment-lifecycle) for the full state diagram.

<Danger>
  Never use a secret key (`r1sk_…`) in browser code, mobile apps, or anywhere a shopper can inspect it. Secret keys belong on your server only.
</Danger>

<Info>
  This request requires a valid access token. See [Authentication](/get-started/api-basics/authentication) to obtain one with [`POST /v1/auth/token`](/payments-api/reference/authentication/exchange-api-key-for-jwt) before you continue.
</Info>

## Request fields

| Field | Required | Notes |
| - | - | - |
| `request_id` | Yes | 8–64 characters, `[a-zA-Z0-9-]` only. Your idempotency key — see [Idempotency and replay](#idempotency-and-replay) |
| `amount` | Yes | `{currency, value}` — `currency` is a 3-letter ISO 4217 code, `value` is the amount as a **minor-units string** (`"5000"` = 50.00 in a 2-decimal currency), up to 12 digits, non-zero |
| `card` | Yes | `{token}` — the tokenized card from RadiumOne Elements or your bind flow, never a raw PAN |
| `channel` | Yes | One of `CARD_PRESENT`, `ECOMMERCE`, `MOTO`, `PAYMENT_LINK`, `IN_APP`, `RECURRING` |
| `order_reference` | No | Up to 128 characters. Appears on transaction lookups and some issuer-facing records |
| `three_ds` | No | One of three mutually exclusive shapes — an existing 3DS `ref`, an explicit `{mode:"non_payer_auth"}` opt-out, or your own 3DS provider's evidence. See [3D Secure overview](/get-started/three-d-secure). Sending more than one shape, or an unrecognized key inside it, is rejected |
| `metadata` | No | Your own key/value data, up to 10 KB serialized, 5 levels deep. Never put card numbers or other PANs here |
| `loyalty` | No | Loyalty redemption details — see [Pay with points](/payments-api/payment-methods/uob-rewards/pay-with-points) |

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

<Tip>
  `order_reference` accepts up to 128 characters, but issuer statements, receipts, and some reports display far fewer. Put the part a human needs to recognize — your own order number — in the first 20–30 characters.
</Tip>

Fields you don't recognize in the request body are currently ignored rather than rejected — don't rely on this; only send documented fields.

## Steps

<Steps>
  <Step title="Charge the card immediately (purchase)">
    Use this when you can fulfil the order right away. [API reference](/payments-api/reference/payments/purchase).

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

    If the call times out or you get no response, see [Handle timeouts and unknown outcomes](/payments-api/handle-failures/timeouts-and-unknown-outcomes) — never mint a new `request_id` for the same attempt.
  </Step>

  <Step title="Or reserve funds for later (authorize)">
    Use this when you'll capture a (possibly different, smaller) amount after the fact. [API reference](/payments-api/reference/payments/authorize).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Authorize only (reserve funds, capture later). 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/auth" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Authorize only (reserve funds, capture later). 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.
      // 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 — never mint a new one for the same 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 createAuthorization(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/auth`, {
              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;
          }

          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(`request failed: ${payload.type ?? payload.code} (${res.status})`);
          }

          if (payload.data.status === "PENDING") {
            if (attempt === maxAttempts) return payload;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue;
          }

          return payload; // branch on data.status
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Authorize only (reserve funds, capture later). 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.

      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 -- never mint a new one for the same 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_authorization(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/auth", 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("type") or payload.get("code")
                  raise RuntimeError(f"request failed: {code} ({resp.status_code})")

              if payload["data"]["status"] == "PENDING":
                  if attempt == max_attempts:
                      return payload
                  time.sleep(backoff_seconds(attempt))
                  continue

              return payload  # branch on data.status

          raise RuntimeError("unreachable")


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

    If the operation is disabled or no terminal is available for your outlet, see [Fix operations unavailable for your outlet](/payments-api/handle-failures/operation-unavailable-for-outlet).
  </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 |

A decline is still a successful HTTP call — `201` with `data.status: "DECLINED"` in the body, not an error response. Always branch on `status`, never on the HTTP status code alone:

```json theme={null}
{
  "status": "ok",
  "data": {
    "id": "txn_8f2a1c",
    "status": "DECLINED",
    "response_code": "05",
    "amount": 5000,
    "request_id": "ord-1001-pay-1"
  }
}
```

Treat a decline as final for this attempt — don't retry the same `request_id` hoping for a different outcome; ask the shopper for another card instead. See [Handle declined payments](/payments-api/handle-failures/declined-payments) for the full walkthrough.

## Idempotency and replay

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

Persist `request_id` to your database **before** you send the first request, so a retry after a timeout reuses the same key and body instead of minting a new one:

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

See [Resolve idempotent replays and conflicts](/payments-api/handle-failures/idempotent-replays-and-conflicts) for the full decision flow.

## Test your integration

Use the approval, decline, and timeout scenarios in [Test your integration](/resources/test-your-integration#purchase-and-authorize) before you go live.

## Go-live notes

* Confirm your integration handles `PENDING` by polling `GET /v1/transactions/{id}/status` or waiting for a webhook — never by guessing.
* Never mint a new `request_id` to "retry faster" after a timeout; it risks a duplicate charge.
* If you take 3D Secure evidence, verify it server-side per [3D Secure overview](/get-started/three-d-secure) before charging.
* Work through the full [go-live checklist](/resources/go-live-checklist).

## Next steps

<Columns cols={2}>
  <Card title="Payment lifecycle" icon="repeat" href="/payments-api/payment-lifecycle">
    See every status a transaction can reach and how it gets there.
  </Card>

  <Card title="Capture an authorization" icon="check-check" href="/payments-api/capture">
    Capture a reserved amount within the capture window.
  </Card>

  <Card title="3D Secure overview" icon="shield-check" href="/get-started/three-d-secure">
    Add strong customer authentication to your charges.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/payments-api/webhooks/overview">
    Get notified asynchronously instead of polling.
  </Card>
</Columns>
