TL;DR: A missing or invalid
sig means don’t trust the redirect — confirm the result another way.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.
tsis older than 5 minutes when you verify it — treat this the same as an invalid signature, even ifsigitself 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 strippingwhsec_. 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 |
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.What to do
1
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.2
Verify with the correct key and payload
#!/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.
#!/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
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.
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.
}
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.3
Confirm the real outcome regardless
Whether or not
sig verifies, get the authoritative result before fulfilling (API reference):#!/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"
#!/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)));
#!/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))
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
tstolerance) — see Redirect secret: rotate, don’t delete. - Don’t delete your redirect secret as a routine operation — doing so removes
sigfrom every redirect going forward, not just the ones you intend.
Related
Verify the payment result
The full decision table and the redirect secret’s rotation semantics.
Redirect and signature errors
Every signal reference, with a stable anchor per case.
Handle failures
All ten failure scenarios, symptom → page.