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

# Timeouts - Payments API

> How to safely resolve a payment call that timed out or returned no response.

Your request to create, capture, void, or refund a transaction gets no response, or a `503` with no clear outcome. The charge may or may not have reached the card network — resolve it before you do anything else.

<Info>
  **TL;DR** — No response, or `503`? Resend the identical request with the **same** `request_id`/`operation_id` first — that's how you recover the transaction `id` and current status. Then let the confirming webhook tell you the final outcome (recommended); call status inquiry instead if you don't use webhooks or need an answer right now. Never mint a new key for the same attempt.
</Info>

## When this happens

* Your HTTP client times out waiting for a response — the request may have already reached RadiumOne and the acquirer. **You don't have a transaction `id` yet** in this case — the response that would have carried it never arrived.
* RadiumOne's own upstream connection to the payment switch times out, returned as `503 urn:radiumone:gateway:switch-timeout` with `retry_allowed: false`. The outcome is genuinely indeterminate: the charge may have been forwarded to the acquirer host before the timeout.

## What you see

| Signal | Value |
| - | - |
| HTTP status | No response, or `503` |
| Error type | `urn:radiumone:gateway:switch-timeout` (`retry_allowed: false`) |
| Transaction status | Often `PENDING`, until it resolves or becomes a reversal candidate |
| Webhook | The eventual outcome event, once it resolves |

<Warning>
  `retry_allowed: false` here doesn't mean "give up" — it means **don't blindly retry as if this were a fresh attempt.** A blind retry with a *new* idempotency key risks a duplicate charge if the original request actually landed.
</Warning>

## Recovery path

1. A create-type request gets no response, or a `5xx`.
2. Resend the **identical body** with the **same** `request_id`/`operation_id`, with backoff. This is what recovers the transaction `id` — the replay returns the stored result whatever it is, including `PENDING`.
3. If the replay itself comes back `409 idempotency-body-mismatch`, the resend didn't match the original attempt byte-for-byte — resend the **stored original** body instead.
4. Any **other `4xx`** on the replay means something else is wrong with the request — fix it before you retry again.
5. If the replayed response is `2xx` with `CAPTURED`, `AUTHORIZED`, or `DECLINED`, that's final — branch on status and stop.
6. If it's `2xx` with `PENDING`, keep the `id` from that response and wait for the confirming webhook (recommended — it pushes the outcome to you); call status inquiry instead if you don't have webhooks set up or need an answer right now.
7. If it's `2xx` with `FAILED`, that means the transaction didn't complete — it's **not** a guarantee that no funds moved (only `VOIDED`/`REVERSED` assert that). If the outcome was genuinely unknown after an upstream timeout, RadiumOne arms an automatic reversal instead of leaving it `FAILED` (`REVERSAL_PENDING` → `REVERSED` — see [Understand automatic reversals](/payments-api/handle-failures/automatic-reversals)). Confirm with a status `GET` before you consider any new attempt with a new `request_id`.
8. If you still get no response after a few tries, or you can't reproduce the exact original body, don't guess — wait for the confirming webhook instead, or [contact support](/resources/support) with your `order_reference` and approximate timestamp if you need it sooner.
9. **Never** send a **new** `request_id` to "retry faster" — that's the path that risks a duplicate charge.

## What to do

<Steps>
  <Step title="Don't create a new request">
    Never mint a new `request_id` (or `operation_id`) just because the first attempt was slow or came back as `503`. Reuse the **same** key for whatever you do next.
  </Step>

  <Step title="Replay the identical request to recover the transaction id">
    If you don't have a transaction `id` yet — the usual case after a client-side timeout — resend the exact same body with the exact same key first ([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>

    The response is the stored transaction, whatever its status, including its `id` — this is always safe to do, any number of times.
  </Step>

  <Step title="Once you have the id, let the webhook confirm the outcome — or check now with status inquiry">
    If you have a webhook endpoint registered, the confirming event (`payment.*`, `authorization.*`, `refund.*`) arrives without you polling anything — this is the recommended path, and it's what [Webhooks overview](/payments-api/webhooks/overview#recovery-after-timeouts) assumes. If you don't use webhooks, or you need an answer right now rather than waiting, call the status-inquiry endpoint instead — it never writes a new row, so it's always safe to call on demand ([API reference](/payments-api/reference/transactions/transaction-status-inquiry)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Live acquirer status inquiry for a transaction (no new row written). Use
      # this to recover after a timeout instead of guessing the outcome.
      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}"
      : "${RADIUMONE_TRANSACTION_ID:?set RADIUMONE_TRANSACTION_ID to the transaction to inquire}"

      curl -sS "$API_BASE/v1/transactions/$RADIUMONE_TRANSACTION_ID/status" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN"
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Live acquirer status inquiry for a transaction (no new row written). Use
      // this to recover after a timeout instead of guessing the outcome. Node 18+
      // ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID, RADIUMONE_API_BASE.
      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
      const transactionId = process.env.RADIUMONE_TRANSACTION_ID;

      async function inquireTransactionStatus() {
        const res = await fetch(`${API_BASE}/v1/transactions/${transactionId}/status`, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`status inquiry failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        return payload; // branch on data.status
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Live acquirer status inquiry for a transaction (no new row written). Use
      this to recover after a timeout instead of guessing the outcome.
      """
      import json
      import os

      import requests

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


      def inquire_transaction_status() -> dict:
          transaction_id = os.environ["RADIUMONE_TRANSACTION_ID"]
          resp = requests.get(
              f"{API_BASE}/v1/transactions/{transaction_id}/status",
              headers={"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"},
              timeout=30,
          )
          payload = resp.json()
          if not resp.ok:
              code = payload.get("type") or payload.get("code")
              raise RuntimeError(f"status inquiry failed: {code} ({resp.status_code})")
          return payload  # branch on data.status


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

  <Step title="No id and the replay didn't resolve it? Wait for the webhook or contact support">
    If you can't reproduce the exact original body (for example, you lost the `amount` from a form submission), a replay isn't possible. RadiumOne doesn't expose a list-by-`order_reference` lookup, so wait for the confirming webhook (`payment.*`, `authorization.*`, `refund.*`), or [contact support](/resources/support) with your `order_reference` and approximate timestamp.
  </Step>

  <Step title="Branch on the final status">
    **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 |
  </Step>

  <Step title="Reconcile via webhook if you still can't resolve it">
    If every synchronous option above is exhausted, wait for the confirming webhook (`payment.*`, `authorization.*`, `refund.*`) instead of guessing — see [Webhooks overview](/payments-api/webhooks/overview#recovery-after-timeouts).
  </Step>
</Steps>

## Prevent it

* Set a client-side HTTP timeout longer than a few seconds — a request that's merely slow at the switch shouldn't look identical to a dropped connection on your side.
* Build the retry-with-same-key path into your integration before you go live, not during your first production incident.

## Test it

See [Test your integration](/resources/test-your-integration#timeouts-and-retries) for simulated timeout scenarios.

## Related

<Columns cols={2}>
  <Card title="Prevent duplicate payments" icon="shield-check" href="/get-started/api-basics/prevent-duplicate-payments">
    The cross-product guide to avoiding duplicate charges.
  </Card>

  <Card title="Check a transaction's status" icon="search" href="/payments-api/check-transaction-status">
    Status inquiry and timeout recovery, in depth.
  </Card>

  <Card title="Problem format and retries" icon="triangle-alert" href="/payments-api/errors/problem-format-and-retries#retry-rules">
    The full retry-rules reference.
  </Card>

  <Card title="Understand automatic reversals" icon="rotate-ccw" href="/payments-api/handle-failures/automatic-reversals">
    What happens if the switch timeout turns into a reversal.
  </Card>
</Columns>
