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

# Redirect integration - Hosted checkout

> Create a checkout session on your server and redirect the shopper to RadiumOne Checkout.

Redirect is the default hosted-checkout mode: your server creates a session, sends the shopper's browser to a RadiumOne-hosted page, and RadiumOne redirects back to your site when the checkout reaches a final state.

## How it works

1. The shopper clicks **Pay** on your site.
2. Your server calls [`POST /api/v1/checkout/sessions`](/hosted-checkout/reference/checkout-sessions/create-a-checkout-session) with your secret key.
3. RadiumOne Checkout returns `checkout_url`.
4. Your server redirects the shopper's browser to `checkout_url`.
5. The shopper enters their card on the hosted page.
6. RadiumOne Checkout charges the card via the Payments API.
7. RadiumOne sends your webhook endpoint a `payment.captured` (or decline/failure) event — confirm the result from here, not from the redirect alone.

## Before you begin

<Info>
  You need a secret key (`r1sk_…`) and a `success_url`/`cancel_url` pair on a domain you control. See [Sandbox and API keys](/get-started/sandbox-and-api-keys) and [Checkout API authentication](/get-started/api-basics/authentication#checkout-api-authentication) for how the `X-Api-Key` header works.
</Info>

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

## Return URL requirements

`success_url` and `cancel_url` must be absolute `https://` URLs, up to 2048 characters each — `http://localhost` is also accepted, for local development. Anything else, including a URL that doesn't parse at all, is rejected at create with [`400 validation:invalid_input`](/hosted-checkout/errors/api-errors#checkout-validation-invalid-input). The host is also checked against your account's `allowed_domains` — an exact match, or a subdomain of a configured domain; any host is accepted if you haven't configured an allow-list, and a host outside the list returns [`403 security:domain_not_allowed`](/hosted-checkout/errors/api-errors#checkout-security-domain-not-allowed).

<Warning>
  If you configure `allowed_domains`, wildcard entries (for example `*.example.com`) are accepted when you set them but are **never matched** against a request — they don't grant access to any subdomain. List every exact host you use explicitly.
</Warning>

## Steps

<Steps>
  <Step title="Create a checkout session">
    Call the [create-session endpoint](/hosted-checkout/reference/checkout-sessions/create-a-checkout-session) from your server with the order amount, currency, your own `order_reference`, and the pages to return to.

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

    Sending the same `order_reference` again within the session's TTL returns the existing session instead of creating a duplicate — safe to retry on a network error.

    <Warning>
      The replay match is on `order_reference` alone. If the amount and currency match the original session — and that session is still payable (`pending` and not yet expired, `processing`, or `completed`) — you get the same session back; other changed fields (line items, metadata, URLs) are **silently ignored**. If the amount or currency is **different** while that session is still payable, the retry is rejected instead with [`409 session:idempotency_conflict`](/hosted-checkout/errors/api-errors#checkout-session-idempotency-conflict) — it never silently applies the new total. Either way, this protection only lasts the session's TTL (5–60 minutes): once it expires, the same `order_reference` creates a **new** session, which can mean a second charge if the shopper's order already paid. Always check your own order state before creating a session — see [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments).
    </Warning>

    See [Prevent duplicate sessions and double payments](/hosted-checkout/handle-failures/duplicate-sessions-and-double-submit) for the race case and for retrying after a decline.
  </Step>

  <Step title="Redirect the shopper">
    Redirect the shopper's browser (HTTP redirect or a client-side navigation) to `data.checkout_url` from the response. Don't fetch or embed this URL — it's a full page for the shopper to visit directly.
  </Step>

  <Step title="Handle the success return">
    When the payment completes, RadiumOne redirects the shopper to your `success_url` with `checkout_id` (and `state`, if you sent one). If you've configured a redirect secret, the URL also carries `status`, `transaction_id`, `ts`, and `sig` — see [Verify the payment result](/hosted-checkout/verify-payment-result) before treating this as anything more than a UX signal.

    <Warning>
      Serve `success_url` with `Referrer-Policy: no-referrer`, and avoid loading
      third-party scripts on it. The query string can carry `transaction_id`
      and `sig` — anything that reads the URL or receives a `Referer` header
      from this page can see them.
    </Warning>

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

  <Step title="Handle the cancel return">
    Three different outcomes all return the shopper to your raw `cancel_url` — none of them carry `checkout_id` or `state`, so you can't tell them apart from the query string alone:

    | Cause | Query params | Next |
    | - | - | - |
    | Declined payment | None | [Handle declined hosted checkout payments](/hosted-checkout/handle-failures/payment-declined) |
    | Abandoned session (shopper leaves without finishing) | None | [Handle abandoned checkouts](/hosted-checkout/handle-failures/shopper-abandons-checkout) |
    | Countdown timeout | `reason=timeout` | [Handle expired checkout sessions](/hosted-checkout/handle-failures/session-expired) |

    Put your own order reference in the `cancel_url` itself (for example `cancel_url=https://shop.example.com/pay/cancel?order=ORD-1024`) so you can correlate the return without relying on query params RadiumOne doesn't send on this path.

    Always re-check the session status from your server before deciding an order failed — see the next step.
  </Step>
</Steps>

## Handle the result

Never fulfil an order from the redirect URL alone. Confirm the outcome with an authenticated `GET` request or a webhook — see [Verify the payment result](/hosted-checkout/verify-payment-result) for the full decision table.

## Test your integration

See [Test your integration](/resources/test-your-integration#hosted-checkout) for sandbox scenarios covering approvals, declines, timeouts, and cancellations.

## Go-live notes

* Register your production `success_url`/`cancel_url` domains if you've set `allowed_domains`.
* Configure a redirect secret so success returns are signed — see [Verify the payment result](/hosted-checkout/verify-payment-result).
* Set up your webhook endpoint before going live; it's the authoritative source of truth, not the redirect.

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

## Next steps

<Columns cols={2}>
  <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="Session lifecycle" icon="clock" href="/hosted-checkout/session-lifecycle">
    Statuses, TTL, cancellation, and retries.
  </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>
