Purchase vs. authorize
| Use | Endpoint | Result status on success | When to use it |
|---|---|---|---|
| Purchase | POST /v1/transactions/purchase | CAPTURED | Card-present-equivalent flows where you fulfil immediately: digital goods, most e-commerce checkouts |
| Authorize | POST /v1/transactions/auth | AUTHORIZED | You need to reserve funds before you know the final amount or can fulfil — ship-later goods, pre-orders, tabs |
AUTH_EXPIRED. See Payment lifecycle for the full state diagram.
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.This request requires a valid access token. See Authentication to obtain one with
POST /v1/auth/token before you continue.Request fields
| Field | Required | Notes |
|---|---|---|
request_id | Yes | 8–64 characters, [a-zA-Z0-9-] only. Your idempotency key — see Idempotency and replay |
amount | Yes | {currency, value} — currency is a 3-letter ISO 4217 code, value is the amount as a minor-units string ("5000" = 50.00 in a 2-decimal currency), up to 12 digits, non-zero |
card | Yes | {token} — the tokenized card from RadiumOne Elements or your bind flow, never a raw PAN |
channel | Yes | One of CARD_PRESENT, ECOMMERCE, MOTO, PAYMENT_LINK, IN_APP, RECURRING |
order_reference | No | Up to 128 characters. Appears on transaction lookups and some issuer-facing records |
three_ds | No | One of three mutually exclusive shapes — an existing 3DS ref, an explicit {mode:"non_payer_auth"} opt-out, or your own 3DS provider’s evidence. See 3D Secure overview. Sending more than one shape, or an unrecognized key inside it, is rejected |
metadata | No | Your own key/value data, up to 10 KB serialized, 5 levels deep. Never put card numbers or other PANs here |
loyalty | No | Loyalty redemption details — see Pay with points |
Amounts are always integers in the currency’s minor unit. For example,
5000 for SGD means SGD 50.00.order_reference accepts up to 128 characters, but issuer statements, receipts, and some reports display far fewer. Put the part a human needs to recognize — your own order number — in the first 20–30 characters.Steps
1
Charge the card immediately (purchase)
Use this when you can fulfil the order right away. API reference.If the call times out or you get no response, see Handle timeouts and unknown outcomes — never mint a new
#!/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))
request_id for the same attempt.2
Or reserve funds for later (authorize)
Use this when you’ll capture a (possibly different, smaller) amount after the fact. API reference.If the operation is disabled or no terminal is available for your outlet, see Fix operations unavailable for your outlet.
#!/usr/bin/env bash
# Authorize only (reserve funds, capture later). 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/auth" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
-d @request.json
#!/usr/bin/env node
// Authorize only (reserve funds, capture later). 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.
// 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 — never mint a new one for the same 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 createAuthorization(maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let res;
try {
res = await fetch(`${API_BASE}/v1/transactions/auth`, {
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;
}
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(`request failed: ${payload.type ?? payload.code} (${res.status})`);
}
if (payload.data.status === "PENDING") {
if (attempt === maxAttempts) return payload;
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
continue;
}
return payload; // branch on data.status
}
throw new Error("unreachable");
}
createAuthorization().then((r) => console.log(JSON.stringify(r, null, 2)));
#!/usr/bin/env python3
"""Authorize only (reserve funds, capture later). 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.
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 -- never mint a new one for the same 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_authorization(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/auth", 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("type") or payload.get("code")
raise RuntimeError(f"request failed: {code} ({resp.status_code})")
if payload["data"]["status"] == "PENDING":
if attempt == max_attempts:
return payload
time.sleep(backoff_seconds(attempt))
continue
return payload # branch on data.status
raise RuntimeError("unreachable")
if __name__ == "__main__":
print(json.dumps(create_authorization(), 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 |
201 with data.status: "DECLINED" in the body, not an error response. Always branch on status, never on the HTTP status code alone:
{
"status": "ok",
"data": {
"id": "txn_8f2a1c",
"status": "DECLINED",
"response_code": "05",
"amount": 5000,
"request_id": "ord-1001-pay-1"
}
}
request_id hoping for a different outcome; ask the shopper for another card instead. See Handle declined payments for the full walkthrough.
Idempotency and replay
| 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.request_id to your database before you send the first request, so a retry after a timeout reuses the same key and body instead of minting a new one:
#!/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))
Test your integration
Use the approval, decline, and timeout scenarios in Test your integration before you go live.Go-live notes
- Confirm your integration handles
PENDINGby pollingGET /v1/transactions/{id}/statusor waiting for a webhook — never by guessing. - Never mint a new
request_idto “retry faster” after a timeout; it risks a duplicate charge. - If you take 3D Secure evidence, verify it server-side per 3D Secure overview before charging.
- Work through the full go-live checklist.
Next steps
Payment lifecycle
See every status a transaction can reach and how it gets there.
Capture an authorization
Capture a reserved amount within the capture window.
3D Secure overview
Add strong customer authentication to your charges.
Webhooks
Get notified asynchronously instead of polling.