Payment Reliability — Duplicate Prevention & Session Management
CROSS Pay currently does not provide refunds, so preventing duplicate charges and handling the checkout session correctly are essential for any production integration. The patterns and rules below are referenced from the Getting Started guide — read them before going live.
Duplicate-payment defense patterns
If your code combines the following four defensive layers, duplicate payments will effectively never happen.
Pattern 1 — Store idempotency_key on the order and reuse it
When re-requesting a payment session for the same order, reuse the previously stored key as-is. The CROSS Pay API always returns the same payment for the same key, so no duplicate is created.
View implementation (schema + Node.js)
ALTER TABLE orders ADD COLUMN idempotency_key UUID UNIQUE;
ALTER TABLE orders ADD COLUMN checkout_id TEXT;
ALTER TABLE orders ADD COLUMN payment_status TEXT DEFAULT 'NONE';
// POST /checkout — reuse the same key for the same order
async function getOrCreateCheckout(orderId) {
const order = await db.one('SELECT * FROM orders WHERE id = $1', [orderId]);
// Do not create a new session for an order that is already paid
if (order.payment_status === 'PAID') {
throw new Error('order already paid');
}
// First time: issue a new key. Re-call: reuse the stored key.
const idempotencyKey = order.idempotency_key ?? crypto.randomUUID();
const response = await fetch('https://api.crosspay.io/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MERCHANT_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ /* ... */ }),
});
const payment = await response.json();
// Persist the key and checkout_id on the order (later retries reuse the same values)
await db.none(
`UPDATE orders SET idempotency_key = $1, checkout_id = $2 WHERE id = $3`,
[idempotencyKey, payment.checkout_id, orderId]
);
return { checkoutId: payment.checkout_id, idempotencyKey };
}
Pattern 2 — Serialize concurrent requests with row locks
If a user double-clicks the pay button or enters from two tabs at once, payment session creation may be invoked concurrently for the same order. Serialize with SELECT ... FOR UPDATE.
View implementation (Node.js)
await db.tx(async (t) => {
// 1) Lock the order row — concurrent requests for the same order wait here
const order = await t.one(
'SELECT * FROM orders WHERE id = $1 FOR UPDATE',
[orderId]
);
// 2) If a payment is already in progress, return it as-is
if (order.idempotency_key && order.payment_status !== 'NONE') {
return { checkoutId: order.checkout_id, idempotencyKey: order.idempotency_key };
}
// 3) Only issue a new one while holding the lock
const key = crypto.randomUUID();
const payment = await createCrossPayPayment(key, order);
await t.none(
`UPDATE orders SET idempotency_key = $1, checkout_id = $2, payment_status = 'PENDING' WHERE id = $3`,
[key, payment.checkout_id, orderId]
);
return { checkoutId: payment.checkout_id, idempotencyKey: key };
});
Pattern 3 — Block further payments after finalizing PAID in the webhook handler
When you receive payment.completed via webhook, mark the order as PAID. Reject all subsequent payment session creation requests for that order.
View implementation (Node.js)
// /webhooks/crosspay handler (after signature verification and dedup)
if (event.type === 'payment.completed') {
await db.tx(async (t) => {
// webhook-id dedup
const inserted = await t.oneOrNone(
`INSERT INTO webhook_log (webhook_id, payment_id, event_type)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING webhook_id`,
[req.get('webhook-id'), event.data.payment.id, event.type]
);
if (!inserted) return; // webhook already processed
// Mark order as PAID (optimistic update: no-op if already PAID)
await t.none(
`UPDATE orders
SET payment_status = 'PAID',
payment_id = $1,
paid_amount = $2,
paid_at = $3
WHERE id = $4 AND payment_status <> 'PAID'`,
[event.data.payment.id, event.data.payment.amount,
event.data.payment.paid_at, event.data.payment.metadata.order_id]
);
});
}
// Subsequent /checkout calls are blocked by the first guard in Pattern 1:
// if (order.payment_status === 'PAID') throw new Error('order already paid');
Pattern 4 — Disable the client button and cache the response
Server-side defense is the core, but for UX and an extra race guard, also lock the button and cache the response on the client.
View implementation (React)
function CheckoutButton({ orderId }: { orderId: string }) {
const [busy, setBusy] = useState(false);
const cachedRef = useRef<{ checkoutId: string; idempotencyKey: string } | null>(null);
async function handlePay() {
if (busy) return; // block duplicate clicks
setBusy(true);
try {
// For the same-order retry, reuse the cached value
const session = cachedRef.current ?? await (
await fetch('/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId }),
})
).json();
cachedRef.current = session;
const crossPay = await loadCrossPay();
await crossPay.checkout(session);
} finally {
setBusy(false);
}
}
return <button onClick={handlePay} disabled={busy}>Pay</button>;
}
- Double click / concurrent entry from two tabs → blocked by Pattern 2 (row lock)
- Retry after a transient network failure → Pattern 1 (key reuse) returns the same payment
- Mistaken retry after payment completes → Pattern 3 (PAID guard) rejects it
- UI-level race / duplicate clicks → blocked by Pattern 4 (button lock)
Checkout session management
All of the patterns above come down to how you handle the checkout session. Here are the identifiers, lifecycle, and reuse rules.
Three key identifiers
| Identifier | Issued by | Scope | Purpose |
|---|---|---|---|
Idempotency-Key | Merchant server (UUID) | One merchant order | Dedupe re-requests for the same order. Store on the order row for permanent reuse. |
checkout_id (cs_...) | CROSS Pay API | One checkout session | Used by the SDK to open the checkout window. Persist on the merchant server too. |
payment_id (pay_...) | CROSS Pay API | One payment | The lookup key for webhooks and GET /v1/payments/:id. |
idempotency_key ↔ checkout_id mappingCalling POST /v1/payments multiple times with the same Idempotency-Key always returns the same checkout_id / payment_id. Your server only needs to store these three values on the order row once.
Session lifecycle
| State | What the merchant should do |
|---|---|
PENDING | It is safe to open the checkout window multiple times with the same checkoutId/idempotencyKey. The user can close it and try again. A PENDING session can still be cancelled. |
PROCESSING | The payment is being processed. The session can no longer be cancelled — wait for the terminal result delivered via webhook. |
PAID / FAILED / EXPIRED / CANCELLED | Discard the session. Do not reuse the same Idempotency-Key for a different order. If the same order needs a new payment, create a new session with a new key. |
A session can be cancelled only while it is PENDING. Once it transitions to PROCESSING, cancellation is no longer possible — wait for the PAID / FAILED outcome and reconcile from there.
An EXPIRED session cannot be reopened. When a user returns to the cart and tries to pay again, call POST /v1/payments with a new Idempotency-Key. You must also clear the idempotency_key column on the order row.
Resuming a session (user closed the checkout and came back)
This is the most common case. When the user closes the checkout window and clicks the "Pay" button again, the server should reuse the existing session instead of creating a new one.
async function resumeOrCreateCheckout(orderId) {
const order = await db.one('SELECT * FROM orders WHERE id = $1', [orderId]);
// 1) In a terminal state, start a new payment cycle
const terminal = ['PAID', 'FAILED', 'EXPIRED', 'CANCELLED'];
if (terminal.includes(order.payment_status)) {
if (order.payment_status === 'PAID') throw new Error('already paid');
// Terminal other than PAID — retry allowed. Clear columns and issue a new key.
await db.none(
`UPDATE orders SET idempotency_key = NULL, checkout_id = NULL, payment_status = 'NONE' WHERE id = $1`,
[orderId]
);
return createNewCheckout(orderId);
}
// 2) If PENDING, reuse the existing session (CROSS Pay returns the same result)
if (order.idempotency_key && order.checkout_id) {
// optional: for safety, also verify the state on the CROSS Pay side
const live = await fetch(
`https://api.crosspay.io/v1/payments/${order.payment_id}`,
{ headers: { Authorization: `Bearer ${process.env.MERCHANT_API_KEY}` } }
).then(r => r.json());
if (live.status === 'PENDING') {
return { checkoutId: order.checkout_id, idempotencyKey: order.idempotency_key };
}
// CROSS Pay state changed but the webhook hasn't arrived yet → sync DB and re-evaluate
await syncOrderStatus(orderId, live);
return resumeOrCreateCheckout(orderId);
}
return createNewCheckout(orderId);
}
Webhook miss fallback — polling
Webhooks are at-least-once, but delivery can be delayed by network issues. If a webhook does not arrive within a certain time after payment, supplement with a server-side lookup.
// Reconciliation job invoked right after the checkout window closes (recommended to delegate to a queue)
async function reconcilePayment(paymentId, orderId) {
for (let i = 0; i < 6; i++) { // up to 6 attempts * 5 seconds = 30 seconds
const order = await db.one('SELECT payment_status FROM orders WHERE id = $1', [orderId]);
if (order.payment_status !== 'PENDING') return; // webhook arrived first
const live = await fetch(
`https://api.crosspay.io/v1/payments/${paymentId}`,
{ headers: { Authorization: `Bearer ${process.env.MERCHANT_API_KEY}` } }
).then(r => r.json());
if (live.status !== 'PENDING') {
await syncOrderStatus(orderId, live); // reuse the same transactional logic as the webhook handler
return;
}
await sleep(5_000);
}
}
The default for order confirmation is webhook receipt. Polling is only a secondary safety net to catch delayed or missed webhooks. Have the webhook handler and the polling job call the same state-update function (syncOrderStatus) so the result is identical regardless of which arrives first.
Session FAQ
| Question | Answer |
|---|---|
Can I open the checkout window multiple times with the same checkoutId? | Yes, it is safe while in the PENDING state. You only get a new SDK result. |
| Can I cancel a payment session? | Yes, only while it is PENDING. Once it moves to PROCESSING, it can no longer be cancelled — wait for the terminal result. |
What is the TTL of an Idempotency-Key? | The same as the session TTL. Once the session is EXPIRED, you cannot create a new payment with the same key. |
What if I re-call with the same Idempotency-Key but a different amount? | It is rejected. For a different order or amount, always issue a new key. |
| What if the same order is paid again after completion? | Without the server guard (Pattern 3), a duplicate payment occurs. Since refunds are not available, always enforce the PAID check. |
| What if the user closed the checkout window and returned 30 minutes later? | Within the TTL, reuse the existing session; past the TTL (EXPIRED), issue a new session. See the resumeOrCreateCheckout pattern above. |