> ## 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 failures - Payments API

> How to map your own 3DS provider's authentication failures to a safe response.

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

A charge that carries your own 3DS provider's evidence is rejected — either the evidence itself is wrong, or your outlet isn't allowed to submit it this way.

<Info>
  **TL;DR** — Confirm enablement first; re-authenticate with your provider rather than resubmitting the same evidence; never charge on an unconfirmed result.
</Info>

## When this happens

* The acquirer requires 3DS for this channel and no authentication evidence was supplied — `422 urn:radiumone:three-ds:authentication-required`.
* Authentication was attempted through RadiumOne's own 3DS and did not succeed — `403 urn:radiumone:three-ds:not-authenticated`.
* Your outlet isn't enabled to submit your own provider's evidence — `422 urn:radiumone:three-ds:external-auth-not-permitted` (**Beta only** — this gate doesn't exist at the current Live SHA, so an unenabled outlet's request is not yet rejected there; don't rely on that as a substitute for confirming enablement).
* The `three_ds` evidence object has a missing, too-long, or unrecognized field — `400` (schema validation; the arm rejects unknown keys).

## What you see

| Signal | Value |
| - | - |
| HTTP status | `422` (missing/unpermitted evidence) or `403` (failed authentication) or `400` (malformed evidence) |
| Error type | `three-ds:authentication-required` / `three-ds:not-authenticated` / `three-ds:external-auth-not-permitted` / `gateway:validation-error` |

## What to do

<Steps>
  <Step title="Confirm enablement before you rely on this path">
    See [Use your own 3DS provider: before you begin](/payments-api/three-d-secure/use-your-own-provider#before-you-begin). Without enablement, the charge is rejected.
  </Step>

  <Step title="Re-authenticate rather than resubmit the same evidence">
    A `three-ds:not-authenticated` or `authentication-required` result means the charge needs a fresh, successful authentication from your provider — resubmitting the same (failed or missing) evidence won't change the outcome.
  </Step>

  <Step title="Never charge on an unconfirmed result">
    Charge only after your own provider has returned a verified result for this specific attempt — see [Server policy](/payments-api/three-d-secure/use-your-own-provider#server-policy).
  </Step>

  <Step title="Fix the evidence shape for a validation error">
    Check field lengths and that you're not sending an unrecognized key inside the `three_ds` object ([API reference](/payments-api/reference/payments/purchase)):

    <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>
  </Step>
</Steps>

## Related

<Columns cols={2}>
  <Card title="Use your own 3DS provider" icon="shield-check" href="/payments-api/three-d-secure/use-your-own-provider">
    The full guide, including ECI values and limitations.
  </Card>

  <Card title="Authentication results" icon="list-check" href="/elements/three-d-secure/authentication-results#gateway-errors">
    The full 3DS error reference with HTTP codes and remedies.
  </Card>

  <Card title="Problem format and retries" icon="triangle-alert" href="/payments-api/errors/problem-format-and-retries">
    The error shape, status guide, and retry rules.
  </Card>
</Columns>
