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

# Own 3DS provider - Payments API

> Bring authentication results from your own 3D Secure provider (MPI) to a RadiumOne charge.

export const RequiresEnablement = ({feature}) => <Note>
    {feature ? <><strong>{feature}</strong> requires</> : "This feature requires"} enablement on your account before you can use it in production. See <a href="/resources/support#request-enablement">Request enablement</a>.
  </Note>;

<Warning>
  This feature is in **Beta**. Behavior may change before general availability, and production access depends on your RadiumOne rollout. [Contact support](/resources/support) to confirm availability for your account.
</Warning>

<RequiresEnablement feature="Your own 3DS provider" />

If you already run 3D Secure through your own MPI (merchant plug-in) or a
third-party authentication provider, you can bring the resulting evidence to a
RadiumOne charge instead of using RadiumOne's built-in 3DS. Elements is only
used to tokenize the card for the charge — your provider runs 3DS
independently.

When you use your own 3DS provider, your 3DS provider handles card data outside Elements. Confirm your provider's PCI DSS scope and how card data flows between your provider and RadiumOne before you go live.

## How it works

1. The shopper's card is tokenized with Elements as usual (`elements.submit()`,
   without calling `threeDS()`).
2. Your 3DS provider authenticates the shopper using its own access to the
   card — it doesn't receive anything from Elements.
3. Your server charges with the card token plus the evidence your provider
   returned.

## Before you begin

<Info>
  This path requires per-outlet enablement. Without it, the charge is rejected
  with `422 urn:radiumone:three-ds:external-auth-not-permitted`.
</Info>

## Steps

<Steps>
  <Step title="Create a session and tokenize the card">
    Create a tokenization session and mount Elements as usual. **Don't** call
    `radiumone.threeDS()` on this path — you're not using RadiumOne's 3DS.

    ```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 your own 3DS provider">
    Run your own 3DS integration (AReq/ARes/CReq) however you already do
    today. This happens entirely outside RadiumOne. Collect at minimum the
    scheme's ECI, the CAVV (or AAV), and the directory server transaction ID.
  </Step>

  <Step title="Charge with the provider's evidence">
    Send the card token and your provider's evidence to your server, then
    purchase or authorize with the `three_ds` evidence arm. [API reference: purchase](/payments-api/reference/payments/purchase) · [API reference: authorize](/payments-api/reference/payments/authorize).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Beta. Beta, gated (own 3DS provider). Purchase with externally-produced 3DS
      # evidence. Requires per-outlet enablement (three-ds:external-auth-not-permitted
      # otherwise). No `xid`; 3DS1 is not supported.
      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
      // Beta, gated (own 3DS provider). Purchase with externally-produced 3DS
      // evidence. Requires per-outlet enablement (three-ds:external-auth-not-permitted
      // otherwise). No `xid`; 3DS1 is not supported.
      // 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 createPurchaseWithExternalThreeDs(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");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Beta, gated (own 3DS provider). Purchase with externally-produced 3DS
      evidence. Requires per-outlet enablement (three-ds:external-auth-not-permitted
      otherwise). No `xid`; 3DS1 is not supported.

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

    If the charge is rejected, see [Handle 3DS failures with your own provider](/payments-api/handle-failures/own-three-ds-provider-failures).
  </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.

### Errors specific to this path

The two errors you're most likely to hit on this path are
`three-ds:external-auth-not-permitted` (your outlet isn't enabled — see
[Before you begin](#before-you-begin)) and `gateway:validation-error` (a
`three_ds` field is missing, too long, or an unrecognized key was sent — the
evidence arm rejects unknown keys). For the full 3DS error reference with HTTP
codes and remedies, see
[Authentication results](/elements/three-d-secure/authentication-results#gateway-errors).

## ECI values

The electronic commerce indicator (ECI) your provider returns determines
whether liability shifts to the issuer:

| Scheme | ECI | Meaning |
| - | - | - |
| Visa | `05` | Fully authenticated — liability shift |
| Visa | `06` | Attempted authentication — liability shift |
| Visa | `07` | Not authenticated — no liability shift |
| Mastercard | `02` | Fully authenticated — liability shift |
| Mastercard | `01` | Attempted authentication — liability shift |
| Mastercard | `00` | Not authenticated — no liability shift |

## Limitations

* **No 3DS1.** Only 3DS2 evidence is accepted — there's no `xid` field, so a
  provider that only supports 3DS1 can't use this path.
* **Fields**: `cavv` (≤64 chars), `eci` (≤2 chars), `ds_transaction_id` (≤64
  chars), `version` (≤10 chars). Unknown keys in the same object are rejected.
* **Merchant-asserted evidence.** RadiumOne doesn't independently verify the
  CAVV or ECI you submit — it trusts your provider's result. Make sure your
  own integration validates the ARes/RReq signature before you submit its
  fields here.

## Server policy

<Warning>
  Charge only after your own 3DS provider has returned a verified result for
  **this** checkout attempt — never submit placeholder or reused evidence, and
  never charge based on a client-reported outcome your server hasn't
  independently confirmed came from your provider.
</Warning>

<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
scenario coverage.

## Go-live notes

* Confirm enablement for this outlet before relying on this path in
  production.
* Keep your provider's evidence validation server-side; never trust values
  that could originate from the browser.
* Review the [go-live checklist](/resources/go-live-checklist) before launch.

## Next steps

<Columns cols={2}>
  <Card title="3D Secure overview" icon="shield-check" href="/get-started/three-d-secure">
    Compare this path against RadiumOne's built-in 3DS.
  </Card>

  <Card title="Security and PCI scope" icon="lock-keyhole" href="/resources/security-and-pci">
    PCI scope per integration path.
  </Card>
</Columns>
