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

# Declined payments - Hosted checkout

> How a declined payment shows up when the shopper returns to your site, and how to confirm it and let them try again.

<Info>
  **TL;DR:** A decline is a normal `failed` session, not an error — confirm with a `GET`, then start a new session with a **new** `order_reference` if the shopper wants to try again.
</Info>

A declined card is a normal outcome, not an error — the gateway still responds successfully, it just reports that the issuer didn't approve the charge. On hosted checkout, a decline looks identical to a shopper simply abandoning checkout at the redirect layer, which is exactly why you confirm server-side instead of trusting the return URL.

## When this happens

* The issuer declines the card (insufficient funds, risk block, expired card, and so on).

## What you see

| Signal | Value |
| - | - |
| Redirect | Raw `cancel_url` — **no query params at all**, not even `checkout_id` |
| Session status | `failed`, and it's terminal — the shopper can't retry a card on this same session |
| Webhook | `payment.declined` (or a related decline event) |

<Warning>
  Don't treat every `cancel_url` return as "the shopper cancelled." A decline and a genuine abandon both land here with no params — the only way to tell them apart is a `GET` on the session.
</Warning>

## What to do

<Steps>
  <Step title="Confirm the outcome with a GET">
    Since the redirect carries nothing to distinguish a decline from an abandoned checkout, check the session directly ([API reference](/hosted-checkout/reference/checkout-sessions/get-a-checkout-session)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Authenticated merchant view of a checkout session. Branch on data.status;
      # never on gateway_response_code. Note: GET timestamps are epoch
      # milliseconds, unlike the ISO string returned at create time.
      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}"
      : "${RADIUMONE_CHECKOUT_ID:?set RADIUMONE_CHECKOUT_ID to the checkout_id to verify}"

      curl -sS "$CHECKOUT_BASE/api/v1/checkout/sessions/$RADIUMONE_CHECKOUT_ID" \
        -H "X-Api-Key: $RADIUMONE_SECRET_KEY"
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Authenticated merchant view of a checkout session. Branch on data.status;
      // never on gateway_response_code. Node 18+ ESM fetch.
      // Env: RADIUMONE_SECRET_KEY, RADIUMONE_CHECKOUT_ID, RADIUMONE_CHECKOUT_BASE.
      const CHECKOUT_BASE = process.env.RADIUMONE_CHECKOUT_BASE || "https://checkout-sandbox.radiumone.io";
      const secretKey = process.env.RADIUMONE_SECRET_KEY;
      const checkoutId = process.env.RADIUMONE_CHECKOUT_ID;

      async function retrieveCheckoutSession() {
        const res = await fetch(`${CHECKOUT_BASE}/api/v1/checkout/sessions/${checkoutId}`, {
          headers: { "X-Api-Key": secretKey },
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`checkout session fetch failed: ${payload.code ?? payload.type} (${res.status})`);
        }
        // Confirm order_reference and amount match your order before fulfilling.
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Authenticated merchant view of a checkout session. Branch on ``status``;
      never on ``gateway_response_code``.
      """
      import json
      import os

      import requests

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


      def retrieve_checkout_session() -> dict:
          checkout_id = os.environ["RADIUMONE_CHECKOUT_ID"]
          resp = requests.get(
              f"{CHECKOUT_BASE}/api/v1/checkout/sessions/{checkout_id}",
              headers={"X-Api-Key": os.environ.get("RADIUMONE_SECRET_KEY", "")},
              timeout=30,
          )
          payload = resp.json()
          if not resp.ok:
              code = payload.get("code") or payload.get("type")
              raise RuntimeError(f"checkout session fetch failed: {code} ({resp.status_code})")
          # Confirm order_reference and amount match your order before fulfilling.
          return payload


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

    A `status` of `failed` confirms a decline (or another failure) rather than an abandon (which stays `pending` until it `expire`s).
  </Step>

  <Step title="Show the shopper a generic decline message">
    Avoid echoing the issuer's specific reason back to the shopper — see [Decline codes](/payments-api/errors/decline-codes) for how to interpret the code internally without exposing it.
  </Step>

  <Step title="Start a new session for another attempt">
    The failed session can't be retried in place. Create a new checkout session, with a new `order_reference` so it doesn't collide with the terminal one ([API reference](/hosted-checkout/reference/checkout-sessions/create-a-checkout-session)):

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

    <Info>
      Reusing the **same** `order_reference` as the failed session returns that same terminal session again — it doesn't create a new attempt. Use a new reference for a genuine retry. See [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments) for the full guidance on choosing and reusing `order_reference`.
    </Info>
  </Step>
</Steps>

## Related

<Columns cols={2}>
  <Card title="Decline codes" icon="credit-card" href="/payments-api/errors/decline-codes">
    Interpret the issuer's response code.
  </Card>

  <Card title="Verify the payment result" icon="shield-check" href="/hosted-checkout/verify-payment-result">
    The full decision table for every result signal.
  </Card>

  <Card title="Session lifecycle" icon="clock" href="/hosted-checkout/session-lifecycle#replay-and-retries">
    Why a failed session can't be replayed with the same order reference.
  </Card>

  <Card title="Handle failures" icon="triangle-alert" href="/hosted-checkout/handle-failures/overview">
    All ten failure scenarios, symptom → page.
  </Card>
</Columns>
