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

# Authentication failures - Payments API

> How to recover from an expired or rejected access token, or an insufficient-scope error.

A call is rejected before it ever reaches business logic — your access token expired, was malformed, or doesn't carry the scope or outlet the call needs.

<Info>
  **TL;DR** — `401` means re-exchange or refresh your token and retry once; `403` means fix the key's scope or the `outlet_id` you sent — neither is safe to retry as-is.
</Info>

## When this happens

* Your access token has passed its 300-second TTL — `401 urn:radiumone:gateway:token-expired`.
* Your token failed verification (malformed, wrong signature) — `401 urn:radiumone:gateway:token-invalid`.
* Your token is valid but lacks the scope the endpoint requires — `403 urn:radiumone:auth:insufficient-scope`.
* Your key is bound to one outlet and the request named a different `outlet_id` — `403 urn:radiumone:auth:outlet-binding-violation`.

## What you see

| Signal | Value |
| - | - |
| HTTP status | `401` (token) or `403` (scope/outlet) |
| Error type | `gateway:token-expired` / `gateway:token-invalid` / `auth:insufficient-scope` / `auth:outlet-binding-violation` |

## What to do

<Steps>
  <Step title="Re-exchange or refresh, then retry once">
    Access tokens are short-lived by design (300 seconds) — expiry during normal use is expected, not a bug. Exchange your key for a fresh token ([API reference](/payments-api/reference/authentication/exchange-api-key-for-jwt)), or rotate your refresh token if you have one ([API reference](/payments-api/reference/authentication/rotate-refresh-token)):

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

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Rotate a refresh token for a new access + refresh pair. The old refresh
      # token is single-use; replaying a CONSUMED token revokes all refresh tokens
      # for that key, so store the new one immediately and only once.
      set -euo pipefail

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

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Rotate a refresh token. Node 18+ ESM fetch.
      // Env: RADIUMONE_REFRESH_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 body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));
      if (process.env.RADIUMONE_REFRESH_TOKEN) body.refresh_token = process.env.RADIUMONE_REFRESH_TOKEN;

      async function refreshAccessToken() {
        const res = await fetch(`${API_BASE}/v1/auth/token/refresh`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`auth/token/refresh failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        // Store the NEW refresh_token immediately — the old one is single-use.
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Rotate a refresh token for a new access + refresh pair. 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 refresh_access_token() -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          if os.environ.get("RADIUMONE_REFRESH_TOKEN"):
              body["refresh_token"] = os.environ["RADIUMONE_REFRESH_TOKEN"]

          resp = requests.post(f"{API_BASE}/v1/auth/token/refresh", json=body, timeout=30)
          payload = resp.json()
          if not resp.ok:
              raise RuntimeError(f"auth/token/refresh failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
          # Store the NEW refresh_token immediately — the old one is single-use.
          return payload


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

    Retry the original call once with the new token. If it fails again with the same error, don't loop — treat it as a configuration problem instead.
  </Step>

  <Step title="Fix the key's scope for insufficient-scope">
    `403 insufficient-scope` means the token you exchanged doesn't carry the scope the call needs. Request a key scoped for the operation — see [Authentication: scopes and least-privilege keys](/get-started/api-basics/authentication#scopes-and-least-privilege-keys) — rather than retrying with the same key.
  </Step>

  <Step title="Send the right outlet, or omit it">
    `403 outlet-binding-violation` means the `outlet_id` on the request doesn't match your key's bound outlet. Omit `outlet_id` to use the key's own outlet, or send the correct one.
  </Step>
</Steps>

## Test it

See [Test your integration](/resources/test-your-integration#3d-secure) and [Sandbox and API keys](/get-started/sandbox-and-api-keys) for key setup and scope configuration in sandbox.

## Related

<Columns cols={2}>
  <Card title="Authentication" icon="key-round" href="/get-started/api-basics/authentication">
    Exchange a key for a token, and refresh it before it expires.
  </Card>

  <Card title="Sandbox and API keys" icon="flask-conical" href="/get-started/sandbox-and-api-keys">
    Key types, scopes, and where to get sandbox credentials.
  </Card>

  <Card title="Payment operation errors" icon="triangle-alert" href="/payments-api/errors/payment-operation-errors#auth-and-validation">
    The full URN catalog for auth and validation errors.
  </Card>
</Columns>
