Guides
Custom Checkout
You price the order in USD cents and list the assets the buyer may pay in. This is the most common flow and the foundation for the other three.
- 1
Step 1 — create an intent
Price in USD cents and list the assets the buyer may pay in.
TypeScriptconst SERVER = "https://api.zuuppa.com"; const API_KEY = process.env.ZUUPPA_API_KEY!; // sk_live_... from the dashboard async function createInvoice(orderId: string, usd: number) { const res = await fetch(`${SERVER}/intents`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${API_KEY}`, }, body: JSON.stringify({ amount_usd_cents: Math.round(usd * 100), accepted_tokens: [ { kind: "sol" }, { kind: "spl", mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, // USDC ], reference: orderId, // your order id; idempotency key }), }); if (!res.ok) throw new Error((await res.json()).error); const intent = await res.json(); // Persist: order_id -> { index: intent.derivation_index, address: intent.address } return intent; }Store
derivation_indexagainst your order, storeaddressto display, and forwardclient_secretto your client if it will select the token. - 2
Step 2 — let the buyer pick an asset
Nothing is payable until the buyer chooses one of
accepted_tokens. This call is authorized by thecs_client secret, so an untrusted client can make it directly:TypeScript// In the browser, with the cs_ token your backend forwarded. const res = await fetch(`${SERVER}/intents/select-token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_secret, mint: null }), // null = native SOL }); const status = await res.json(); // now has expected_lamports + payment_uriTo show what each option costs first, read
GET /intents/quote?client_secret=cs_.... Selection is re-runnable whilependingand refused with409once a payment lands. - 3
Step 3 — show the deposit address
Render
status.payment_urias the QR code and showaddressas text beside it. Don't assemble the URI yourself. It's omitted before token selection (fall back to a QR of the bare address) and once the intent can no longer be paid, and on anunderpaidintent it asks for the remaining shortfall. Re-render from the latest status response. - 4
Step 4 — learn the outcome
Prefer webhooks for fulfillment; use polling for live UI.
TypeScriptasync function pollStatus(index: number, onUpdate: (s: any) => void) { const terminal = new Set(["swept", "refunded", "cancelled", "refund_failed"]); while (true) { const res = await fetch(`${SERVER}/status?index=${index}`, { headers: { "Authorization": `Bearer ${API_KEY}` }, }); if (res.ok) { const s = await res.json(); onUpdate(s); // show s.message directly if (terminal.has(s.status)) return s; } await new Promise((r) => setTimeout(r, 3000)); } } - 5
Step 5 — fulfill on swept
TypeScriptconst s = await pollStatus(index, updateUi); if (s.status === "swept" && s.settlement) { markOrderPaid(s.reference, { amount: s.settlement.destination_amount, // base units to YOUR wallet asset: s.settlement.asset, txSignatures: s.settlement.signatures, }); } else if (s.status === "refunded" || s.status === "cancelled") { markOrderUnpaid(s.reference, s.status); } else if (s.status === "refund_failed") { alertOps(s.reference); }Accounting note
settlement.destination_amountis what actually landed in your wallet, which may be less thanreceived_lamportsbecause of the network fee, the platform fee, and/or a refunded overpayment. Simplest robust check: requirereceived_lamports >= expected_lamports(they paid enough), then usedestination_amountfor your books.
Cancelling / expiry#
Every checkout lasts a fixed 10 minutes and auto-cancels if unpaid, so nothing sits pending forever. You can also cancel early:
async function cancelOrder(index: number) {
const res = await fetch(`${SERVER}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${API_KEY}` },
body: JSON.stringify({ index }),
});
if (res.status === 409) return "already_paid"; // a payment landed first
if (!res.ok) throw new Error((await res.json()).error);
return (await res.json()).status; // "cancelled" | "refunding" | ...
}Both an explicit cancel and a timeout emit a webhook (intent.cancelled, or intent.refunding then intent.refunded), so you don't need to poll an abandoned checkout — release inventory on that. From the client, the SDK can cancel its own intent with POST /intents/cancel { client_secret }.
Full endpoint reference: Payments API. For the push-based alternative to polling, see Webhooks.