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

# Invalid signatures - Hosted checkout

> How to verify the redirect signature and reject a forged or stale return.

<Info>
  **TL;DR:** A missing or invalid `sig` means don't trust the redirect — confirm the result another way.
</Info>

If you've configured a redirect secret, a successful return to `success_url` carries `checkout_id`, `status`, `transaction_id`, `ts`, and `sig`. Anything wrong with `sig` — missing, malformed, or not matching — means you can no longer trust that the URL wasn't altered in the shopper's browser.

## When this happens

* No redirect secret is configured for your account — the redirect is unsigned by design, not by defect.
* The shopper's browser (or something in front of it) altered the query string.
* `ts` is older than 5 minutes when you verify it — treat this the same as an invalid signature, even if `sig` itself would otherwise check out.
* You verify with the wrong key encoding — the redirect signature's HMAC key is the **full `rsec_…` string**, used directly. This is different from the webhook signature's key, which is hex-decoded after stripping `whsec_`. Using either encoding for the wrong signature makes it fail every time.

## What you see

| Signal | Value |
| - | - |
| `sig` | Missing, or present but doesn't verify |
| `ts` | Present but more than 5 minutes old |
| Everything else in the URL | May look otherwise well-formed — that's exactly why you can't skip verification |

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

## What to do

<Steps>
  <Step title="Fail closed">
    If a redirect secret is configured for your account and the redirect is missing `sig`, or `sig` doesn't verify, or `ts` is stale — treat the return as unverified. Never treat it as confirmation of payment.
  </Step>

  <Step title="Verify with the correct key and payload">
    <CodeGroup>
      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Verify an HPP redirect signature. Node 18+, no dependencies.
      //
      // Key: the FULL `rsec_...` secret STRING, used directly as the HMAC key
      // (this differs from the webhook secret, which is hex-decoded after
      // stripping its prefix -- see verify-webhook-signature).
      // Payload: `${checkout_id}|${status}|${transaction_id_or_empty}|${ts}`.
      // `state` is NOT part of the signed payload.
      // Fail closed: if a secret is configured and `sig` is missing (or invalid),
      // treat the redirect as UNVERIFIED -- never as proof of payment. Always
      // confirm fulfilment via an authenticated GET or a webhook.
      import { createHmac, timingSafeEqual } from "node:crypto";

      const SECRET_PATTERN = /^rsec_[0-9a-fA-F]{48}$/;
      const SIGNATURE_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;

      /**
       * @param {{secret: string, checkoutId: string, status: string, transactionId: string|null,
       *   ts: number, sig: string|null, storedCheckoutId: string, toleranceSeconds?: number, now?: number}} args
       * @returns {boolean}
       */
      export function verifyRedirectSignature({
        secret,
        checkoutId,
        status,
        transactionId,
        ts,
        sig,
        storedCheckoutId,
        toleranceSeconds = 300,
        now = Math.floor(Date.now() / 1000),
      }) {
        // Fail closed: an empty, missing, or wrong-shaped secret is never a valid
        // signing key -- never fall through to HMAC-ing with an empty string.
        if (typeof secret !== "string" || !SECRET_PATTERN.test(secret)) return false;
        if (typeof sig !== "string" || sig.length === 0) return false;
        if (!checkoutId || !storedCheckoutId || !status) return false;
        if (checkoutId !== storedCheckoutId) return false;
        // Number.isInteger (not isFinite): a signed/fractional ts (e.g. 1700000000.5)
        // is not a valid Unix timestamp and must be rejected, not silently truncated.
        if (typeof ts !== "number" || !Number.isInteger(ts) || Math.abs(now - ts) > toleranceSeconds) return false;
        if (!SIGNATURE_HEX_PATTERN.test(sig)) return false;

        const payload = `${checkoutId}|${status}|${transactionId ?? ""}|${ts}`;
        const expected = Buffer.from(createHmac("sha256", secret).update(payload).digest("hex"), "hex");
        const actual = Buffer.from(sig, "hex");

        if (expected.length !== actual.length) return false;
        return timingSafeEqual(expected, actual);
      }

      // Example: verifying a success_url visit.
      // const url = new URL(req.url, "https://shop.example.com");
      // const ok = verifyRedirectSignature({
      //   secret: process.env.RADIUMONE_REDIRECT_SECRET,
      //   checkoutId: url.searchParams.get("checkout_id"),
      //   status: url.searchParams.get("status"),
      //   transactionId: url.searchParams.get("transaction_id"),
      //   ts: Number(url.searchParams.get("ts")),
      //   sig: url.searchParams.get("sig"),
      //   storedCheckoutId: sessionRecord.checkoutId, // your own stored order/session mapping
      // });
      // // The signature is a UX hint only. Always confirm amount + status via
      // // GET /api/v1/checkout/sessions/{id} (X-Api-Key) or a gateway webhook.
      ```

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Verify an HPP redirect signature. Python 3.10+, standard library only.

      Key: the FULL ``rsec_...`` secret STRING, used directly as the HMAC key
      (this differs from the webhook secret, which is hex-decoded after
      stripping its prefix -- see verify-webhook-signature).
      Payload: ``{checkout_id}|{status}|{transaction_id_or_empty}|{ts}``.
      ``state`` is NOT part of the signed payload.
      Fail closed: if a secret is configured and ``sig`` is missing (or invalid),
      treat the redirect as UNVERIFIED -- never as proof of payment. Always
      confirm fulfilment via an authenticated GET or a webhook.
      """
      import hmac
      import hashlib
      import re
      import time

      # fullmatch (not match): plain `match()` with a trailing `$` lets Python
      # accept a string with one trailing "\n" (same class of bug as PCRE without
      # the `D` modifier) -- see verify-webhook-signature/python.py.
      _SECRET_PATTERN = re.compile(r"^rsec_[0-9a-fA-F]{48}$")
      _SIGNATURE_HEX_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")


      def verify_redirect_signature(
          secret: str,
          checkout_id: str,
          status: str,
          transaction_id: str | None,
          ts,
          sig: str | None,
          stored_checkout_id: str,
          tolerance_seconds: int = 300,
          now: int | None = None,
      ) -> bool:
          now = int(time.time()) if now is None else now

          # Fail closed: an empty, missing, or wrong-shaped secret is never a
          # valid signing key -- never fall through to HMAC-ing with an empty string.
          if not isinstance(secret, str) or not _SECRET_PATTERN.fullmatch(secret):
              return False
          if not sig:
              return False
          if not checkout_id or not stored_checkout_id or not status:
              return False
          if checkout_id != stored_checkout_id:
              return False
          if not isinstance(ts, int) or isinstance(ts, bool):
              return False
          if abs(now - ts) > tolerance_seconds:
              return False
          if not _SIGNATURE_HEX_PATTERN.fullmatch(sig):
              return False

          payload = f"{checkout_id}|{status}|{transaction_id or ''}|{ts}"
          expected_hex = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()

          return hmac.compare_digest(bytes.fromhex(expected_hex), bytes.fromhex(sig))


      # Example: verifying a success_url visit (Flask-style).
      # def _parse_ts(raw):
      #     # Never let a malformed query param crash the handler -- an unparsable
      #     # ts must reach verify_redirect_signature as a non-int (it rejects any
      #     # non-int/bool ts), not raise before the fail-closed check even runs.
      #     try:
      #         return int(raw)
      #     except (TypeError, ValueError):
      #         return None
      #
      # ok = verify_redirect_signature(
      #     secret=os.environ["RADIUMONE_REDIRECT_SECRET"],
      #     checkout_id=request.args.get("checkout_id"),
      #     status=request.args.get("status"),
      #     transaction_id=request.args.get("transaction_id"),
      #     ts=_parse_ts(request.args.get("ts")),
      #     sig=request.args.get("sig"),
      #     stored_checkout_id=session_record.checkout_id,
      # )
      # # The signature is a UX hint only. Always confirm amount + status via
      # # GET /api/v1/checkout/sessions/{id} (X-Api-Key) or a gateway webhook.
      ```

      ```php PHP theme={null}
      <?php
      declare(strict_types=1);

      /**
       * Verify an HPP redirect signature. PHP 8.1+, no framework.
       *
       * Key: the FULL `rsec_...` secret STRING, used directly as the HMAC key
       * (this differs from the webhook secret, which is hex-decoded after
       * stripping its prefix -- see verify-webhook-signature).
       * Payload: "{checkout_id}|{status}|{transaction_id_or_empty}|{ts}".
       * `state` is NOT part of the signed payload.
       * Fail closed: if a secret is configured and `sig` is missing (or invalid),
       * treat the redirect as UNVERIFIED -- never as proof of payment. Always
       * confirm fulfilment via an authenticated GET or a webhook.
       *
       * `$ts` is `int|string|null` -- a non-numeric or missing value is rejected
       * rather than coerced, so a malformed query param never silently becomes 0.
       */
      function verifyRedirectSignature(
          string $secret,
          string $checkoutId,
          string $status,
          ?string $transactionId,
          mixed $ts,
          ?string $sig,
          string $storedCheckoutId,
          int $toleranceSeconds = 300,
          ?int $now = null
      ): bool {
          $now ??= time();

          // Fail closed: an empty, missing, or wrong-shaped secret is never a
          // valid signing key -- never fall through to HMAC-ing with an empty string.
          // `D` anchors `$` to the absolute end of the subject -- without it PCRE
          // lets `$` match just before a single trailing "\n", so a secret/signature
          // sourced from a file with a trailing newline would wrongly pass shape
          // validation here.
          if (!preg_match('/^rsec_[0-9a-fA-F]{48}$/D', $secret)) {
              return false;
          }
          if ($sig === null || $sig === '') {
              return false;
          }
          if ($checkoutId === '' || $storedCheckoutId === '' || $status === '') {
              return false;
          }
          if ($checkoutId !== $storedCheckoutId) {
              return false;
          }
          if (is_int($ts)) {
              $timestamp = $ts;
          } elseif (is_string($ts) && ctype_digit($ts)) {
              $timestamp = (int) $ts;
          } else {
              return false;
          }
          if (abs($now - $timestamp) > $toleranceSeconds) {
              return false;
          }
          if (!preg_match('/^[0-9a-fA-F]{64}$/D', $sig)) {
              return false;
          }

          $payload = "{$checkoutId}|{$status}|" . ($transactionId ?? '') . "|{$timestamp}";
          $expectedHex = hash_hmac('sha256', $payload, $secret);

          return hash_equals($expectedHex, strtolower($sig));
      }

      // Example: verifying a success_url visit.
      // $ok = verifyRedirectSignature(
      //     getenv('RADIUMONE_REDIRECT_SECRET'),
      //     $_GET['checkout_id'] ?? '',
      //     $_GET['status'] ?? '',
      //     $_GET['transaction_id'] ?? null,
      //     $_GET['ts'] ?? null,
      //     $_GET['sig'] ?? null,
      //     $sessionRecord['checkout_id'],
      // );
      // // The signature is a UX hint only. Always confirm amount + status via
      // // GET /api/v1/checkout/sessions/{id} (X-Api-Key) or a gateway webhook.
      ```

      ```java Java theme={null}
      import java.nio.charset.StandardCharsets;
      import java.security.MessageDigest;
      import java.util.regex.Pattern;
      import javax.crypto.Mac;
      import javax.crypto.spec.SecretKeySpec;

      /**
       * Verify an HPP redirect signature. Java 17+, no framework
       * ({@code javax.crypto.Mac} only).
       *
       * Key: the FULL {@code rsec_...} secret STRING, used directly as the HMAC
       * key (this differs from the webhook secret, which is hex-decoded after
       * stripping its prefix -- see verify-webhook-signature).
       * Payload: {@code "{checkout_id}|{status}|{transaction_id_or_empty}|{ts}"}.
       * {@code state} is NOT part of the signed payload.
       * Fail closed: if a secret is configured and {@code sig} is missing (or
       * invalid), treat the redirect as UNVERIFIED -- never as proof of payment.
       * Always confirm fulfilment via an authenticated GET or a webhook.
       *
       * {@code ts} is passed as a {@code String} so a malformed/non-numeric
       * timestamp can be rejected instead of failing {@code Long.parseLong} at
       * the caller.
       */
      public final class VerifyRedirectSignature {

          private static final Pattern SECRET_PATTERN = Pattern.compile("^rsec_[0-9a-fA-F]{48}$");
          private static final Pattern SIGNATURE_HEX_PATTERN = Pattern.compile("^[0-9a-fA-F]{64}$");
          private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("^-?\\d+$");

          private VerifyRedirectSignature() {
          }

          public static boolean verify(
                  String secret,
                  String checkoutId,
                  String status,
                  String transactionId,
                  String ts,
                  String sig,
                  String storedCheckoutId,
                  int toleranceSeconds,
                  Long now) {
              long nowSeconds = now != null ? now : System.currentTimeMillis() / 1000L;

              // Fail closed: an empty, missing, or wrong-shaped secret is never a
              // valid signing key -- never fall through to HMAC-ing with an empty string.
              if (secret == null || !SECRET_PATTERN.matcher(secret).matches()) {
                  return false;
              }
              if (sig == null || sig.isEmpty()) {
                  return false;
              }
              if (checkoutId == null || checkoutId.isEmpty() || storedCheckoutId == null || storedCheckoutId.isEmpty()
                      || status == null || status.isEmpty()) {
                  return false;
              }
              if (!checkoutId.equals(storedCheckoutId)) {
                  return false;
              }
              if (ts == null || !TIMESTAMP_PATTERN.matcher(ts).matches()) {
                  return false;
              }
              long timestamp;
              try {
                  timestamp = Long.parseLong(ts);
              } catch (NumberFormatException e) {
                  return false;
              }
              if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
                  return false;
              }
              if (!SIGNATURE_HEX_PATTERN.matcher(sig).matches()) {
                  return false;
              }

              String payload = checkoutId + "|" + status + "|" + (transactionId != null ? transactionId : "") + "|" + timestamp;

              byte[] expected;
              try {
                  Mac mac = Mac.getInstance("HmacSHA256");
                  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
                  expected = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
              } catch (Exception e) {
                  return false;
              }

              byte[] actual = hexToBytes(sig);
              return MessageDigest.isEqual(expected, actual);
          }

          private static byte[] hexToBytes(String hex) {
              byte[] out = new byte[hex.length() / 2];
              for (int i = 0; i < out.length; i++) {
                  out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
              }
              return out;
          }

          // Example: verifying a success_url visit (plain servlet).
          // boolean ok = VerifyRedirectSignature.verify(
          //     System.getenv("RADIUMONE_REDIRECT_SECRET"),
          //     request.getParameter("checkout_id"),
          //     request.getParameter("status"),
          //     request.getParameter("transaction_id"),
          //     request.getParameter("ts"),
          //     request.getParameter("sig"),
          //     sessionRecord.getCheckoutId(),
          //     300,
          //     null);
          // // The signature is a UX hint only. Always confirm amount + status via
          // // GET /api/v1/checkout/sessions/{id} (X-Api-Key) or a gateway webhook.
      }
      ```
    </CodeGroup>

    Also confirm that `checkout_id` in the URL matches the ID your server stored for that order before trusting anything else in the URL — `state` is not part of the signed payload, so verify it independently if you rely on it.
  </Step>

  <Step title="Confirm the real outcome regardless">
    Whether or not `sig` verifies, get the authoritative result before fulfilling ([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>
  </Step>
</Steps>

## Prevent it

* Keep the previous redirect secret in your own verifier for up to 65 minutes after rotating one (the 60-minute maximum session lifetime, plus the 5-minute `ts` tolerance) — see [Redirect secret: rotate, don't delete](/hosted-checkout/verify-payment-result#redirect-secret-rotate-dont-delete).
* Don't delete your redirect secret as a routine operation — doing so removes `sig` from every redirect going forward, not just the ones you intend.

## Related

<Columns cols={2}>
  <Card title="Verify the payment result" icon="shield-check" href="/hosted-checkout/verify-payment-result">
    The full decision table and the redirect secret's rotation semantics.
  </Card>

  <Card title="Redirect and signature errors" icon="triangle-alert" href="/hosted-checkout/errors/redirect-and-signature-errors">
    Every signal reference, with a stable anchor per case.
  </Card>

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