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

# Add 3D Secure - Elements SDK

> Authenticate a card with 3D Secure in the browser using Elements, then charge it on your server with the resulting reference.

Elements can drive 3D Secure authentication directly in the browser, using the
same tokenization session you already create for a card payment. Your server
pins the amount to authenticate, the browser runs the challenge (if any), and
your server charges using the resulting reference.

## How it works

1. Your server creates a tokenization session and pins the net payable amount.
2. The shopper enters their card; Elements tokenizes it (`elements.submit()`).
3. The browser starts 3DS authentication (`threeDS.authenticate()`), which may
   run frictionless or present a challenge.
4. Your server authorizes or purchases using the card token plus the 3DS
   reference. The gateway independently verifies the reference before it
   authorizes the charge.

## Before you begin

<Info>
  Requires enablement on your account, a CSP that allows the 3DS iframe and
  fetch origins — including your API origin in `connect-src`, or a challenge
  times out after 5 minutes instead of failing fast (see [Content Security
  Policy](/elements/content-security-policy)) — and Elements SDK `v1.6.0` or
  later.
</Info>

## Steps

<Steps>
  <Step title="Create a payment session and pin the amount">
    Create a session as usual, then pin the amount that 3DS will authenticate
    — this must be the amount you'll actually charge, which may be lower than
    the gross order amount if you redeem loyalty points.

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

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Beta — requires enablement. Pin the net payable amount (after loyalty redemption)
      # BEFORE the browser calls threeDS.authenticate() — required for 3DS + rewards.
      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_SESSION_ID:?set RADIUMONE_SESSION_ID to the session to pin}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Beta — requires enablement. Pin the net payable amount before threeDS.authenticate().
      // Node 18+ ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_SESSION_ID, RADIUMONE_API_BASE.
      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 sessionId = process.env.RADIUMONE_SESSION_ID;
      const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));

      async function updateSessionAmount() {
        const res = await fetch(`${API_BASE}/v1/sessions/${sessionId}`, {
          method: "PATCH",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${accessToken}`,
          },
          body: JSON.stringify(body),
        });
        const payload = await res.json();
        if (!res.ok) {
          // 422 sessions:amount-not-pinnable if net_payable_amount > gross or the session expired.
          throw new Error(`sessions PATCH failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Beta — requires enablement. Pin the net payable amount before 3DS. 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 update_session_amount() -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          session_id = os.environ["RADIUMONE_SESSION_ID"]
          resp = requests.patch(
              f"{API_BASE}/v1/sessions/{session_id}",
              json=body,
              headers={"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"},
              timeout=30,
          )
          payload = resp.json()
          if not resp.ok:
              # 422 sessions:amount-not-pinnable if net_payable_amount > gross or the session expired.
              raise RuntimeError(f"sessions PATCH failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
          return payload


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

    There's no public `PATCH /v1/sessions/{id}` reference page yet — see the
    [`ThreeDS` reference](/elements/reference/three-ds) for how this step fits
    the browser-driven flow. Keep `ttl_minutes` at 30 or less for a 3DS
    checkout — see [Token lifetime](#token-lifetime) below.
  </Step>

  <Step title="Tokenize the card in the browser">
    ```js theme={null}
    const elements = radiumone.elements();
    const card = elements.create("card");
    card.mount("#card-element");

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

  <Step title="Authenticate with 3D Secure">
    ```js theme={null}
    const threeDS = radiumone.threeDS();

    const { status, ref } = await threeDS.authenticate({
      sessionId: session.session_id,
      sessionSecret: session.session_secret,
      cardToken: token,
    });

    if (status === "AUTHENTICATED" || status === "ATTEMPTED") {
      // proceed to charge — see the next step
    } else {
      // NOT_AUTHENTICATED, FAILED, REJECTED, EXPIRED: don't charge.
      // This branch decides your UX only — the gateway independently
      // re-verifies the ref at purchase time regardless of this status.
      showDeclined(status);
    }
    ```

    A decline **resolves**, it doesn't throw. See
    [Authentication results](/elements/three-d-secure/authentication-results) for every
    status and its meaning, and for 3DS error remedies.
  </Step>

  <Step title="Charge with the 3DS reference on your server">
    Send the token and the 3DS `ref` from the browser to your server, then
    purchase or authorize with `three_ds:{ref}` ([API reference](/payments-api/reference/payments/purchase)).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Requires enablement. Purchase using a completed Elements 3DS result ref.
      # The gateway checks the ref is single-use, unexpired, and amount-bound.
      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
      // Requires enablement. Purchase using a completed Elements 3DS result ref.
      // The gateway checks the ref is single-use, unexpired, and amount-bound.
      // 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 createPurchaseWithThreeDsRef(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;
          }

          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");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Requires enablement. Purchase using a completed Elements 3DS result ref.
      The gateway checks the ref is single-use, unexpired, and amount-bound.

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

              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_purchase_with_three_ds_ref(), indent=2))
      ```
    </CodeGroup>
  </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.

If the charge itself is rejected for a 3DS reason (for example
`three-ds:ref-invalid` or `three-ds:ref-expired`), see
[Authentication results](/elements/three-d-secure/authentication-results#gateway-errors)
for remedies, or [Handle 3D Secure failures in Elements](/elements/handle-failures/three-ds-failures)
for the recovery-action version of the same failures.

## Server policy

<Warning>
  Charge only after your server receives a `ref` from **this checkout's own**
  session flow — never accept a ref from an unrelated session, and never skip
  straight to charging because the browser reported `AUTHENTICATED`. The
  gateway independently checks the ref is single-use, unexpired, authenticated,
  and bound to the same card token, currency and amount before it authorizes.
</Warning>

## Token lifetime

The card token from `elements.submit()` is transient (about 30 minutes) and the
3DS authentication is bound to it. Keep `ttl_minutes` at 30 or less for a 3DS
checkout. If the token lapses or the shopper switches cards mid-flow:

* **Before charging**: `authenticate()` rejects with
  `three-ds:card-token-invalid` — re-bind the card (`elements.submit()` again)
  and re-authenticate.
* **At charge time**: the purchase/authorize call rejects with
  `422 urn:radiumone:three-ds:card-token-mismatch` if the charged token differs
  from the one that was authenticated — re-bind and re-authenticate rather
  than retrying the charge with the same ref.

See [Handle 3D Secure failures in Elements](/elements/handle-failures/three-ds-failures) and
[Handle expired sessions and card tokens](/elements/handle-failures/expired-sessions-and-tokens)
for the full recovery flow.

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

## Test your integration

See [Test your integration](/resources/test-your-integration#3d-secure) for 3DS
scenario coverage.

## Go-live notes

* Confirm your account has 3DS enabled before relying on this flow in
  production.
* Make sure your CSP allows the 3DS iframe/fetch origins — see
  [Content Security Policy](/elements/content-security-policy).
* Review the [go-live checklist](/resources/go-live-checklist) before launch.

## Next steps

<Columns cols={2}>
  <Card title="Challenge presentation and redirects" icon="panel-top" href="/elements/three-d-secure/challenge-presentation">
    Customize where the challenge renders, and handle redirect return pages.
  </Card>

  <Card title="Authentication results" icon="triangle-alert" href="/elements/three-d-secure/authentication-results">
    Every status, ECI value and 3DS error remedy.
  </Card>
</Columns>
