How it works
- Your server creates an access token, then a payment session, and passes
session_id,session_secret, andpubkey_jwsto the browser. - The browser mounts a card field and the shopper types their card details directly into it.
- The browser calls
elements.submit(). The card iframe encrypts the card data and tokenizes it with RadiumOne directly — your JavaScript never sees the raw card number. - The browser sends the resulting token to your server.
- Your server charges the token with
POST /v1/transactions/purchase. - Your server returns the result; your page shows success or a decline message.
Before you begin
Complete Install and load Elements first. You’ll need a sandbox secret key (
r1sk_test_…) on your server and a publishable key (r1pk_test_…) in the browser.Amounts are always integers in the currency’s minor unit. For example,
5000 for SGD means SGD 50.00.Steps
1
Create an access token on your server
Exchange your secret key for a short-lived access token (API reference). Cache it server-side and reuse it until it expires.
#!/usr/bin/env bash
# Exchange a secret key for a short-lived access token (300s) and a refresh
# token (3900s, single-use rotation). Never expose the secret key to a browser.
set -euo pipefail
API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
curl -sS -X POST "$API_BASE/v1/auth/token" \
-H "Content-Type: application/json" \
-d @request.json
#!/usr/bin/env node
// Exchange a secret key for a short-lived access token. Node 18+ ESM fetch.
// Env: RADIUMONE_SECRET_KEY (server-side only), RADIUMONE_API_BASE (optional override).
import { readFileSync } from "node:fs";
const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));
if (process.env.RADIUMONE_SECRET_KEY) body.api_key = process.env.RADIUMONE_SECRET_KEY;
async function createAccessToken() {
const res = await fetch(`${API_BASE}/v1/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok) {
throw new Error(`auth/token failed: ${payload.type ?? payload.code} (${res.status})`);
}
// Cache access_token server-side for up to expires_in seconds; use
// refresh_token to get a new pair before it lapses.
return payload;
}
createAccessToken().then((r) => console.log(JSON.stringify(r, null, 2)));
#!/usr/bin/env python3
"""Exchange a secret key for a short-lived access token. Python 3.10+, requests."""
import json
import os
from pathlib import Path
import requests
API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")
def create_access_token() -> dict:
body = json.loads((Path(__file__).parent / "request.json").read_text())
if os.environ.get("RADIUMONE_SECRET_KEY"):
body["api_key"] = os.environ["RADIUMONE_SECRET_KEY"]
resp = requests.post(f"{API_BASE}/v1/auth/token", json=body, timeout=30)
payload = resp.json()
if not resp.ok:
raise RuntimeError(f"auth/token failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
# Cache access_token server-side for up to expires_in seconds; use
# refresh_token to get a new pair before it lapses.
return payload
if __name__ == "__main__":
print(json.dumps(create_access_token(), indent=2))
2
Create a payment session on your server
Create a session (API reference) and return
session_id, session_secret, and pubkey_jws to the browser as-is — never re-serialize pubkey_jws. If this call fails, see Handle expired sessions and card tokens.#!/usr/bin/env bash
# Create a tokenization session for Elements. Pass session_id/session_secret/
# pubkey_jws to the browser unchanged — never re-serialize pubkey_jws.
set -euo pipefail
API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
: "${RADIUMONE_ACCESS_TOKEN:?set RADIUMONE_ACCESS_TOKEN to a Bearer access token}"
curl -sS -X POST "$API_BASE/v1/sessions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
-d @request.json
#!/usr/bin/env node
// Create a tokenization/checkout session for Elements. Node 18+ ESM fetch.
// Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
import { readFileSync } from "node:fs";
const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));
async function createPaymentSession() {
const res = await fetch(`${API_BASE}/v1/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok) {
throw new Error(`sessions create failed: ${payload.type ?? payload.code} (${res.status})`);
}
// Pass session_id, session_secret and pubkey_jws to the browser byte-for-byte.
return payload;
}
createPaymentSession().then((r) => console.log(JSON.stringify(r, null, 2)));
#!/usr/bin/env python3
"""Create a tokenization/checkout session for Elements. Python 3.10+, requests."""
import json
import os
from pathlib import Path
import requests
API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")
def create_payment_session() -> dict:
body = json.loads((Path(__file__).parent / "request.json").read_text())
resp = requests.post(
f"{API_BASE}/v1/sessions",
json=body,
headers={"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"},
timeout=30,
)
payload = resp.json()
if not resp.ok:
raise RuntimeError(f"sessions create failed: {payload.get('type') or payload.get('code')} ({resp.status_code})")
# Pass session_id, session_secret and pubkey_jws to the browser byte-for-byte.
return payload
if __name__ == "__main__":
print(json.dumps(create_payment_session(), indent=2))
3
Mount a card field in the browser
- Vanilla JS
- React
<div id="card-field"></div>
<button id="pay" type="button" disabled>Pay</button>
const elements = radiumone.elements();
const card = elements.create("card");
card.mount("#card-field");
const payButton = document.getElementById("pay");
card.on("change", (event) => {
// Gate on `valid`, not `complete` — `complete` only turns true after
// blur validation runs, so `valid` is the earlier, more reliable signal.
payButton.disabled = !event.valid;
});
import { CardElement, useElements } from "@cubepay/react-radiumone-js";
import { useState } from "react";
function CheckoutForm() {
const elements = useElements();
const [ready, setReady] = useState(false);
const [submitting, setSubmitting] = useState(false); // guards Pay for the whole submit + charge call
async function pay() {
// see the next step — fetch a session, call elements.submit(), then charge()
}
return (
<>
<CardElement onChange={(e) => setReady(e.valid)} />
<button disabled={!ready || submitting} onClick={pay}>Pay</button>
</>
);
}
4
Tokenize the card on submit
Call your server for a session (steps 1–2), pass its response straight into
elements.submit(), then charge the result — all under one guard so the Pay button stays disabled for the entire attempt, not just the submit() call. This is the pattern every failure guide on this site assumes; see Prevent double submission for why the guard matters.- Vanilla JS
- React
payButton.addEventListener("click", async () => {
payButton.disabled = true; // disable before the first network call, not after
try {
const session = await fetch("/api/create-session", { method: "POST" }).then((r) => r.json());
const { token } = await elements.submit({
sessionId: session.session_id,
sessionSecret: session.session_secret,
pubkeyJws: session.pubkey_jws,
});
await charge(token); // your server call to POST /v1/transactions/purchase — see the next step
} catch (err) {
if (err.name === "ElementsError") {
showError(err.customerMessage ?? "Payment could not be processed.");
} else {
throw err;
}
} finally {
payButton.disabled = false; // re-enable only once your server has responded, success or failure
}
});
async function pay() {
setSubmitting(true); // disable before the first network call, not after
try {
const session = await fetch("/api/create-session", { method: "POST" }).then((r) => r.json());
const { token } = await elements.submit({
sessionId: session.session_id,
sessionSecret: session.session_secret,
pubkeyJws: session.pubkey_jws,
});
await charge(token); // your server call to POST /v1/transactions/purchase — see the next step
} catch (err) {
if (err.name === "ElementsError") {
showError(err.customerMessage ?? "Payment could not be processed.");
} else {
throw err;
}
} finally {
setSubmitting(false); // re-enable only once your server has responded, success or failure
}
}
submit() throws an ElementsError for validation failures, tokenization failures, and gateway bind errors. Always show customerMessage (never the raw message, which is developer-facing) and check retryAllowed before offering a retry. See Handle tokenization failures and Handle browser network errors for the specific failure paths.5
Charge the token on your server
Send the token from your server — never from the browser — to
POST /v1/transactions/purchase (API reference). If the token has expired, see Handle expired sessions and card tokens.#!/usr/bin/env bash
# Purchase (authorise + capture in one call). Any 2xx is a response — branch
# on data.status. On a timeout/5xx/PENDING, retry with the SAME request_id;
# never mint a new one for the same order attempt.
set -euo pipefail
API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
: "${RADIUMONE_ACCESS_TOKEN:?set RADIUMONE_ACCESS_TOKEN to a Bearer access token}"
curl -sS -X POST "$API_BASE/v1/transactions/purchase" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
-d @request.json
#!/usr/bin/env node
// Purchase (authorise + capture in one call). Node 18+ ESM fetch.
// Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
//
// Shared result pattern: any 2xx is a response you branch on `data.status`.
// On a network timeout, a 5xx, or `status:"PENDING"`, retry with the SAME
// request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
// request_id for the same order attempt.
import { readFileSync } from "node:fs";
const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
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 createPurchase(maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let res;
try {
res = await fetch(`${API_BASE}/v1/transactions/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body), // same request_id every attempt
});
} catch (networkErr) {
if (attempt === maxAttempts) throw networkErr;
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
continue; // network timeout: retry with the same body/request_id
}
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; // retry with the same request_id
}
const payload = await res.json();
if (!res.ok) {
// 4xx: not retryable by re-sending — fix the request, or handle
// urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
throw new Error(`purchase failed: ${payload.type ?? payload.code} (${res.status})`);
}
if (payload.data.status === "PENDING") {
if (attempt === maxAttempts) return payload; // caller should poll GET status / wait for webhook
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
continue; // retry the same request_id
}
// Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
return payload;
}
throw new Error("unreachable");
}
createPurchase().then((r) => console.log(JSON.stringify(r, null, 2)));
#!/usr/bin/env python3
"""Purchase (authorise + capture in one call). Python 3.10+, requests.
Shared result pattern: any 2xx is a response you branch on ``status``. On a
network timeout, a 5xx, or ``status: "PENDING"``, retry with the SAME
request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
request_id for the same order attempt.
"""
import json
import os
import random
import time
from pathlib import Path
import requests
API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")
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_purchase(max_attempts: int = 3) -> dict:
body = json.loads((Path(__file__).parent / "request.json").read_text())
headers = {"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"}
for attempt in range(1, max_attempts + 1):
try:
resp = requests.post(f"{API_BASE}/v1/transactions/purchase", json=body, headers=headers, timeout=30)
except requests.exceptions.Timeout:
if attempt == max_attempts:
raise
time.sleep(backoff_seconds(attempt))
continue # network timeout: retry with the same body/request_id
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 # retry with the same request_id
payload = resp.json()
if not resp.ok:
# 4xx: not retryable by re-sending — fix the request, or handle
# urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
code = payload.get("type") or payload.get("code")
raise RuntimeError(f"purchase failed: {code} ({resp.status_code})")
if payload["data"]["status"] == "PENDING":
if attempt == max_attempts:
return payload # caller should poll GET status / wait for webhook
time.sleep(backoff_seconds(attempt))
continue # retry the same request_id
# Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
return payload
raise RuntimeError("unreachable")
if __name__ == "__main__":
print(json.dumps(create_purchase(), indent=2))
Create and persist one
request_id per payment attempt on your server before calling purchase, and reuse it if you retry — Elements sends no idempotency key of its own, so your server is the only thing standing between a retried charge call and a duplicate payment. See Prevent duplicate payments for the full pattern.See the full pattern: persist request_id, then retry safely on a timeout
See the full pattern: persist request_id, then retry safely on a timeout
#!/usr/bin/env node
// Persist request_id BEFORE sending, so a retry after a timeout reuses the
// SAME key and body instead of risking a duplicate charge. This is the
// pattern behind every "safe to retry" claim elsewhere in these docs: the
// idempotency key only protects you if it existed before the first network
// call, not if you mint a fresh one on every attempt.
//
// The database layer below is an in-memory STUB for this sample only --
// replace `ordersDb` / `attemptsDb` with your real table. Everything else
// (timeout handling, backoff, status branching) is the pattern to copy.
const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
// --- STUB: replace with your real database ---------------------------------
const ordersDb = new Map(); // order_id -> { paid }
const attemptsDb = new Map(); // order_id -> { attempt, request_id, body, final }
function getOrder(orderId) {
return ordersDb.get(orderId) ?? { paid: false };
}
function markOrderPaid(orderId) {
ordersDb.set(orderId, { paid: true });
}
// Loads the in-flight attempt row for this order, or creates the next one --
// all inside a single database transaction (this Map write stands in for
// `SELECT ... FOR UPDATE` + `INSERT`). A retry of an in-flight attempt reuses
// THIS row's request_id and body; only a brand-new attempt (after the prior
// one went final) gets a new row and a new key.
function loadOrCreateAttempt(orderId, buildBody) {
const existing = attemptsDb.get(orderId);
if (existing && !existing.final) return existing; // in-flight: reuse it, don't touch request_id
const attempt = (existing?.attempt ?? 0) + 1;
// ✗ don't: uuid() inside the retry loop -- request_id must be generated
// ONCE per attempt, here, before the row is persisted.
const requestId = `ord-${orderId}-pay-${attempt}`;
const row = { attempt, request_id: requestId, body: buildBody(requestId), final: false };
attemptsDb.set(orderId, row); // persisted BEFORE the purchase call below
return row;
}
function markAttemptFinal(orderId) {
const row = attemptsDb.get(orderId);
if (row) row.final = true; // DECLINED: this key is done; the next attempt gets attempt+1
}
// --- end STUB ----------------------------------------------------------------
// 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 sendWithTimeout(body, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(`${API_BASE}/v1/transactions/purchase`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
body: JSON.stringify(body), // identical bytes on every retry
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
async function purchaseWithPersistedRequestId(orderId, maxAttempts = 4) {
const order = getOrder(orderId);
if (order.paid) return { skipped: true, reason: "order already paid" }; // fail-closed: never re-send for a paid order
const attempt = loadOrCreateAttempt(orderId, (requestId) => ({
request_id: requestId,
amount: { currency: "SGD", value: "5000" },
card: { token: "tok_from_elements" },
channel: "ECOMMERCE",
order_reference: `ORD-${orderId}`,
}));
for (let i = 1; i <= maxAttempts; i += 1) {
let res;
try {
res = await sendWithTimeout(attempt.body);
} catch (networkErrOrTimeout) {
if (i === maxAttempts) throw networkErrOrTimeout; // fail-closed: surface it, don't guess
await new Promise((r) => setTimeout(r, backoffMs(i)));
continue; // timeout: retry the SAME stored body/request_id, never a new one
}
if (res.status >= 500) {
if (i === maxAttempts) throw new Error(`server error ${res.status} after ${i} attempts`);
await new Promise((r) => setTimeout(r, backoffMs(i)));
continue; // 5xx: retry the SAME stored body/request_id
}
const payload = await res.json();
if (!res.ok) {
// A 4xx here (other than a replayed body-mismatch you triggered
// yourself) is a bug in this code, not a retryable state.
throw new Error(`purchase failed: ${payload.type ?? payload.code} (${res.status})`);
}
if (payload.data.status === "PENDING") {
attempt.pendingTransactionId = payload.data.id; // you'll need this id even without a webhook
return { status: "PENDING", transactionId: payload.data.id }; // defer to webhook/status inquiry, don't loop here
}
if (payload.data.status === "DECLINED") {
markAttemptFinal(orderId); // this key is done; a NEW shopper attempt gets attempt+1, a new request_id
return { status: "DECLINED", transactionId: payload.data.id };
}
// CAPTURED (or any other final success status).
markOrderPaid(orderId);
markAttemptFinal(orderId);
return { status: payload.data.status, transactionId: payload.data.id };
}
throw new Error("unreachable");
}
purchaseWithPersistedRequestId("1001").then((r) => console.log(JSON.stringify(r, null, 2)));
#!/usr/bin/env python3
"""Persist request_id BEFORE sending, so a retry after a timeout reuses the
SAME key and body instead of risking a duplicate charge. This is the pattern
behind every "safe to retry" claim elsewhere in these docs: the idempotency
key only protects you if it existed before the first network call, not if
you mint a fresh one on every attempt.
The database layer below is an in-memory STUB for this sample only --
replace ``ORDERS_DB`` / ``ATTEMPTS_DB`` with your real table. Everything
else (timeout handling, backoff, status branching) is the pattern to copy.
"""
import os
import random
import time
from dataclasses import dataclass
import requests
API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")
ACCESS_TOKEN = os.environ.get("RADIUMONE_ACCESS_TOKEN", "")
# --- STUB: replace with your real database ----------------------------------
@dataclass
class Attempt:
attempt: int
request_id: str
body: dict
final: bool = False
pending_transaction_id: str | None = None
ORDERS_DB: dict[str, bool] = {} # order_id -> paid
ATTEMPTS_DB: dict[str, Attempt] = {} # order_id -> current attempt row
def get_order_paid(order_id: str) -> bool:
return ORDERS_DB.get(order_id, False)
def mark_order_paid(order_id: str) -> None:
ORDERS_DB[order_id] = True
def load_or_create_attempt(order_id: str) -> Attempt:
"""Loads the in-flight attempt row for this order, or creates the next
one -- all inside a single database transaction (this dict write stands
in for ``SELECT ... FOR UPDATE`` + ``INSERT``). A retry of an in-flight
attempt reuses THIS row's request_id and body; only a brand-new attempt
(after the prior one went final) gets a new row and a new key."""
existing = ATTEMPTS_DB.get(order_id)
if existing is not None and not existing.final:
return existing # in-flight: reuse it, don't touch request_id
attempt_number = (existing.attempt if existing else 0) + 1
# ✗ don't: uuid4() inside the retry loop -- request_id must be generated
# ONCE per attempt, here, before the row is persisted.
request_id = f"ord-{order_id}-pay-{attempt_number}"
body = {
"request_id": request_id,
"amount": {"currency": "SGD", "value": "5000"},
"card": {"token": "tok_from_elements"},
"channel": "ECOMMERCE",
"order_reference": f"ORD-{order_id}",
}
row = Attempt(attempt=attempt_number, request_id=request_id, body=body)
ATTEMPTS_DB[order_id] = row # persisted BEFORE the purchase call below
return row
def mark_attempt_final(order_id: str) -> None:
row = ATTEMPTS_DB.get(order_id)
if row:
row.final = True # DECLINED: this key is done; the next attempt gets attempt+1
# --- end STUB -----------------------------------------------------------------
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 purchase_with_persisted_request_id(order_id: str, max_attempts: int = 4) -> dict:
if get_order_paid(order_id):
return {"skipped": True, "reason": "order already paid"} # fail-closed: never re-send for a paid order
attempt = load_or_create_attempt(order_id)
headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
for i in range(1, max_attempts + 1):
try:
resp = requests.post(
f"{API_BASE}/v1/transactions/purchase",
json=attempt.body, # identical bytes on every retry
headers=headers,
timeout=8,
)
except requests.exceptions.Timeout:
if i == max_attempts:
raise # fail-closed: surface it, don't guess
time.sleep(backoff_seconds(i))
continue # timeout: retry the SAME stored body/request_id, never a new one
if resp.status_code >= 500:
if i == max_attempts:
raise RuntimeError(f"server error {resp.status_code} after {i} attempts")
time.sleep(backoff_seconds(i))
continue # 5xx: retry the SAME stored body/request_id
payload = resp.json()
if not resp.ok:
# A 4xx here (other than a replayed body-mismatch you triggered
# yourself) is a bug in this code, not a retryable state.
code = payload.get("type") or payload.get("code")
raise RuntimeError(f"purchase failed: {code} ({resp.status_code})")
status = payload["data"]["status"]
if status == "PENDING":
attempt.pending_transaction_id = payload["data"]["id"] # you'll need this id even without a webhook
return {"status": "PENDING", "transaction_id": payload["data"]["id"]} # defer to webhook/status inquiry
if status == "DECLINED":
mark_attempt_final(order_id) # this key is done; a NEW shopper attempt gets attempt+1, a new request_id
return {"status": "DECLINED", "transaction_id": payload["data"]["id"]}
# CAPTURED (or any other final success status).
mark_order_paid(order_id)
mark_attempt_final(order_id)
return {"status": status, "transaction_id": payload["data"]["id"]}
raise RuntimeError("unreachable")
if __name__ == "__main__":
import json
print(json.dumps(purchase_with_persisted_request_id("1001"), indent=2))
Handle the result
Any 2xx response is a result you must branch onstatus — never on response_code (that’s the verbatim host/acquirer code; useful for support tickets, not for your app logic).
| Status | Meaning | What to do |
|---|---|---|
AUTHORIZED | Funds reserved (authorize only) | Capture within the capture window, or void to release |
CAPTURED | Funds captured (purchase, capture, or refund) | Fulfil the order (or process the refund) |
VOIDED | Authorization released | No funds moved |
DECLINED | Issuer or acquirer declined | Final for this attempt — don’t retry the same card without a new attempt from the shopper |
FAILED | The transaction didn’t complete — the acquirer returned a non-decline error code, or the gateway couldn’t place the request. Not a guarantee that no funds moved — VOIDED and REVERSED are the only statuses that positively assert that. | Confirm via GET /v1/transactions/{id}/status before retrying, then retry (a genuinely new attempt, not a replay of the same request_id) with a new request_id |
PENDING | Outcome not yet known (async) | Wait for a webhook, or poll GET /v1/transactions/{id}/status |
AUTH_EXPIRED | Authorization lapsed before capture | Create a new authorization |
REVERSAL_PENDING / REVERSED | Automatic compensating reversal after an upstream timeout left the outcome genuinely unknown (never left FAILED in this case) | No merchant action; webhook confirms the final state |
| Key | Used by | On replay |
|---|---|---|
request_id | Purchase, authorize, standalone and referenced refunds | Same body, same operation type → the original transaction, whatever its status — including PENDING, DECLINED, or FAILED. Changed body, or the same key reused for a different operation type → transaction:idempotency-body-mismatch. Purchase/authorize/standalone-refund compare amount, currency, payment_method_type, channel, the card’s pan_prefix (first 8 digits — not the full token), metadata, and order_reference; a referenced refund compares only the original transaction and amount (reason isn’t compared) and its replay check runs before the refund gates, so it always replays, even a DECLINED/FAILED one — mint a new request_id to retry after a decline. None of these compare three_ds or loyalty, so changing either on a retry replays the original silently instead of failing. |
operation_id | Capture, void | Same operation type on the same transaction → the original result (body is never compared, so a changed amount is silently ignored). A different operation type reusing the key → tx:duplicate-operation. |
Balance inquiry also takes a
request_id field, but it isn’t an idempotency key — there’s no dedup or replay store. Every call re-queries the rewards host, even with the same request_id.Keys are 8–64 characters,
[a-zA-Z0-9-] only, unique per merchant account. Generate one key per order attempt and persist it to your database before you send the first request — never mint a new key just to retry the same attempt. See Prevent duplicate payments.PENDING means the outcome isn’t known yet — most often after a processor timeout. Don’t assume success or failure. Recover it one of two ways:
- Wait for a webhook (
payment.*,authorization.*,refund.*— see Webhook event types). - Call
GET /v1/transactions/{id}/statusfor a live inquiry against the acquirer.
id yet — a client-side timeout before the first response arrived — replay the same request with the same request_id and body. The replay returns the stored transaction and its id, whatever status it’s reached. Never re-submit with a new idempotency key just because the first attempt is slow — that risks a second charge for the same order.
token from elements.submit() is single-use context tied to that session; treat it as opaque and never branch on its shape.
Test your integration
Use a sandbox publishable key and a test card. See Test your integration for sandbox test cards and scenarios.Go-live notes
- Complete Install and load Elements and Content Security Policy before switching to production keys.
- Review the go-live checklist.
- Confirm the payment result from your server (webhook or authenticated
GET) — never from the browser alone.
Next steps
Add 3D Secure
Authenticate the card with RadiumOne 3D Secure before charging it.
Card fields and events
Split fields, validation states, and events in depth.