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

# Embedded integration - Hosted checkout

> Keep the shopper on your domain by embedding RadiumOne Checkout in an iframe.

export const productionCheckoutHost = "https://checkout.radiumone.io";

export const sandboxCheckoutHost = "https://checkout-sandbox.radiumone.io";

Embedded mode renders RadiumOne Checkout inside an `<iframe>` on your own page instead of redirecting the full browser tab. Your page listens for `postMessage` events to react to the outcome, but — exactly as with redirect mode — you still confirm the final result from your server.

Embedded mode uses the same [branding](/hosted-checkout/branding) as redirect mode, except it has no header — so your logo and merchant name don't appear.

<Warning>
  **Embedded mode requires `allowed_domains`.** Creating a session with `mode: "embed"` when your account has no usable `allowed_domains` entry returns [`422 embed:origins_not_configured`](/hosted-checkout/errors/api-errors#checkout-embed-origins-not-configured) — the create fails outright rather than handing back a session that would render as a blank iframe. Register your embedding page's domain first — see [Sandbox and API keys](/get-started/sandbox-and-api-keys).
</Warning>

<Info>
  Frame origins are derived from `allowed_domains` at create time, using the same host-or-subdomain rule as `success_url`/`cancel_url`: registering `shop.example.com` also covers `checkout.shop.example.com`. Wildcard entries (for example `*.example.com`) are accepted when configured but never matched — list every exact host you embed on. Sessions created **before** this release don't have frame origins snapshotted and may still render blank for their remaining lifetime (up to the session TTL) regardless of your current `allowed_domains` — this resolves itself as those sessions expire. If the iframe stays blank on a newly created session, see [Fix embedded checkout that won't load](/hosted-checkout/handle-failures/embedded-checkout-not-loading).
</Info>

## How it works

1. Your server creates a checkout session with `mode: "embed"`.
2. Your page renders an `<iframe>` pointed at `checkout_url` and attaches a `message` listener.
3. The iframe posts `CHECKOUT_READY` once the card form loads, then `CHECKOUT_RESIZE` as its content height changes.
4. The shopper enters their card in the iframe. If they navigate away or close the tab without paying, see [Handle abandoned checkouts](/hosted-checkout/handle-failures/shopper-abandons-checkout) — no event is posted for this case.
5. The iframe posts an outcome event (`CHECKOUT_COMPLETE`, `CHECKOUT_PENDING`, `CHECKOUT_DECLINED`, `CHECKOUT_EXPIRED`, or `CHECKOUT_ERROR`).
6. RadiumOne sends your webhook endpoint the authoritative `payment.*` event.
7. Your parent page calls your server, which confirms the result before you navigate the shopper onward.

## Steps

<Steps>
  <Step title="Create a session in embed mode">
    Add `"mode": "embed"` to the [create-session](/hosted-checkout/reference/checkout-sessions/create-a-checkout-session) request body. The response shape is identical to redirect mode.

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Create a hosted-checkout session (Live: `billing_details` field). Redirect
      # the shopper to checkout_url. Same order_reference within the TTL replays
      # the existing session (201) instead of creating a duplicate — safe to retry.
      set -euo pipefail

      CHECKOUT_BASE="${RADIUMONE_CHECKOUT_BASE:-https://checkout-sandbox.radiumone.io}"
      : "${RADIUMONE_SECRET_KEY:?set RADIUMONE_SECRET_KEY to your r1sk_* secret key}"

      curl -sS -X POST "$CHECKOUT_BASE/api/v1/checkout/sessions" \
        -H "Content-Type: application/json" \
        -H "X-Api-Key: $RADIUMONE_SECRET_KEY" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Create a hosted-checkout session (Live: `billing_details` field). Redirect
      // the shopper to checkout_url. Node 18+ ESM fetch.
      // Env: RADIUMONE_SECRET_KEY, RADIUMONE_CHECKOUT_BASE (optional override).
      //
      // Same order_reference within the TTL replays the existing session (201)
      // instead of creating a duplicate — safe to retry with the same body.
      import { readFileSync } from "node:fs";

      const CHECKOUT_BASE = process.env.RADIUMONE_CHECKOUT_BASE || "https://checkout-sandbox.radiumone.io";
      const secretKey = process.env.RADIUMONE_SECRET_KEY;
      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 createCheckoutSession(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${CHECKOUT_BASE}/api/v1/checkout/sessions`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                "X-Api-Key": secretKey,
              },
              body: JSON.stringify(body), // same order_reference 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(`checkout session create failed: ${payload.code ?? payload.type} (${res.status})`);
          }
          return payload; // redirect the shopper to payload.data.checkout_url
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Create a hosted-checkout session (Live: ``billing_details`` field).
      Redirect the shopper to checkout_url.

      Same order_reference within the TTL replays the existing session (201)
      instead of creating a duplicate — safe to retry with the same body.
      """
      import json
      import os
      import random
      import time
      from pathlib import Path

      import requests

      CHECKOUT_BASE = os.environ.get("RADIUMONE_CHECKOUT_BASE", "https://checkout-sandbox.radiumone.io")


      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_checkout_session(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          headers = {"X-Api-Key": os.environ.get("RADIUMONE_SECRET_KEY", "")}

          for attempt in range(1, max_attempts + 1):
              try:
                  resp = requests.post(f"{CHECKOUT_BASE}/api/v1/checkout/sessions", 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("code") or payload.get("type")
                  raise RuntimeError(f"checkout session create failed: {code} ({resp.status_code})")
              return payload  # redirect the shopper to payload["data"]["checkout_url"]

          raise RuntimeError("unreachable")


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

    <Warning>
      Retrying create with the same `order_reference` returns the same session as long as the amount and currency match — other changed fields are **silently ignored**. A different amount or currency, while the original session is still payable, is rejected with `409 session:idempotency_conflict` instead. Either way this only lasts the session's TTL (5–60 minutes) — after that, the same `order_reference` creates a new session. Check your own order isn't already paid before creating one — see [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments).
    </Warning>
  </Step>

  <Step title="Render the iframe and listen for events">
    Point the iframe at `checkout_url` and add a `message` listener. Always check both `event.origin` (the RadiumOne Checkout host) and `event.data.source` before trusting a message — never rely on `source` alone, since any page can post a same-shaped message.

    ```html theme={null}
    <iframe id="r1-checkout" allow="payment" style="width: 100%; height: 640px; border: 0;"></iframe>

    <script>
      // Replace with your checkout host: sandbox or production (see below).
      const CHECKOUT_ORIGIN = "<your-checkout-host-origin>";

      async function startCheckout() {
        const res = await fetch("/api/create-checkout-session", { method: "POST" }); // your own server endpoint
        const { checkout_url } = await res.json();
        document.getElementById("r1-checkout").src = checkout_url;
      }

      window.addEventListener("message", (event) => {
        if (event.origin !== CHECKOUT_ORIGIN) return; // reject any other origin
        const msg = event.data;
        if (!msg || msg.source !== "radiumone-checkout") return; // reject any other payload shape

        switch (msg.type) {
          case "CHECKOUT_RESIZE":
            document.getElementById("r1-checkout").style.height = `${msg.data.height}px`;
            break;
          case "CHECKOUT_COMPLETE":
          case "CHECKOUT_PENDING":
          case "CHECKOUT_DECLINED":
          case "CHECKOUT_EXPIRED":
          case "CHECKOUT_ERROR":
            // UX signal only — confirm with your server before acting on it.
            handleCheckoutOutcome(msg.type, msg.data);
            break;
        }
      });

      startCheckout();
    </script>
    ```

    See the [embedded events reference](/hosted-checkout/reference/embedded-events) for the full event/payload list. If your listener never fires, see [Debug missing embedded checkout events](/hosted-checkout/handle-failures/embedded-events-not-received). If the iframe never renders at all, see [Fix embedded checkout that won't load](/hosted-checkout/handle-failures/embedded-checkout-not-loading).
  </Step>

  <Step title="Confirm the result server-side">
    On any outcome event, call your own server, which confirms the payment with an authenticated `GET /api/v1/checkout/sessions/{id}` request or waits for the webhook — see [Verify the payment result](/hosted-checkout/verify-payment-result). Only then navigate the shopper to your own order-confirmation page.

    <Warning>
      Treat any client-side redirect or callback as a hint only. Always confirm the final payment status from your server, using an authenticated `GET` request or a webhook — never from a query parameter or browser postMessage alone.
    </Warning>
  </Step>
</Steps>

## Content Security Policy

Your page's CSP needs `frame-src` set to the RadiumOne Checkout host so the browser allows the iframe to load:

```http theme={null}
Content-Security-Policy: frame-src <your-checkout-host>;
```

Use <code>{sandboxCheckoutHost}</code> in sandbox and <code>{productionCheckoutHost}</code> in production. Also avoid a strict `Referrer-Policy` (such as `no-referrer`) on the embedding page if you rely on referrer-based analytics inside the iframe — RadiumOne Checkout does not require a specific `Referrer-Policy` from your page, but a very strict setting can affect your own iframe integration.

<Warning>
  **Firefox**: `window.location.ancestorOrigins` (used to detect the parent frame's origin) isn't available in Firefox. The iframe falls back to the origin of your `success_url`, so make sure `success_url` shares your embedding page's origin, or the iframe won't be able to verify it's allowed to message that parent.
</Warning>

## Handle the result

Never treat a `CHECKOUT_COMPLETE` (or any other) event as proof of payment — `postMessage` isn't authenticated and any page can attempt to send a same-shaped message. Confirm with your server, exactly as in [Verify the payment result](/hosted-checkout/verify-payment-result). A `CHECKOUT_ERROR` event means the attempt didn't go through — see [Handle payment service outages during checkout](/hosted-checkout/handle-failures/payment-service-unavailable) for how to confirm nothing was charged before letting the shopper retry.

## Test your integration

See [Test your integration](/resources/test-your-integration#hosted-checkout) for sandbox scenarios, including embedded-mode events.

## Go-live notes

* Register your production embedding domain(s) with RadiumOne before going live.
* Confirm your CSP allows the RadiumOne Checkout host in `frame-src`.
* Confirm fulfilment server-side — never from a `postMessage` event.

See the full [go-live checklist](/resources/go-live-checklist).

## Next steps

<Columns cols={2}>
  <Card title="Embedded events reference" icon="webhook" href="/hosted-checkout/reference/embedded-events">
    Full event and payload reference.
  </Card>

  <Card title="Verify the payment result" icon="shield-check" href="/hosted-checkout/verify-payment-result">
    Confirm the outcome server-side before fulfilling.
  </Card>

  <Card title="Handle failures" icon="triangle-alert" href="/hosted-checkout/handle-failures/overview">
    Ten common failure scenarios and what to do for each.
  </Card>
</Columns>
