4. Integration Guide
A practical, end-to-end walkthrough of wiring Zuuppa into your app. Examples are in TypeScript/Node but the flow is language-agnostic. It's just HTTP.
The flow#
┌─────────────┐ ┌──────────────┐ ┌────────────────────┐
│ Your │ │ Your │ │ Zuuppa │
│ Frontend │ │ Backend │ │ │
└──────┬──────┘ └──────┬───────┘ └─────────┬──────────┘
│ checkout │ │
│─────────────────────▶│ POST /intents │
│ │─────────────────────────▶│
│ │ {index, address} │
│ address + QR │◀─────────────────────────│
│◀─────────────────────│ │
│ │
[payer sends funds on-chain] ────────────────────────▶ (we detect it)
│ │ (we auto-sweep)
│ poll status │ │
│─────────────────────▶│ GET /status?index=N │
│ │─────────────────────────▶ │
│ "swept" + amount │◀───────────────────────── │
│◀─────────────────────│ fulfill order │Golden rule: your backend calls POST /intents and reads /status (both
require your API key). Your frontend displays the address and can poll for
live UX via a thin proxy on your backend (so the key stays server-side), but treat
order fulfillment as a backend decision keyed off the swept status.
Get your API key first. Sign in, set your sweep destination in Settings, and create a key under API keys. Keep it server-side. Don't ship it to the browser. See Getting started.
Step 1: Create an intent when checkout starts#
Price the payment in USD (amount_usd_cents) and list the tokens the buyer may
pay in (accepted_tokens). The buyer picks one at checkout and the server converts
the USD price to that token's base units at spot.
const API = "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(`${API}/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" }], // buyer pays in SOL
reference: orderId, // your order id; you'll get it back
// Note: the payment window is a fixed 10 minutes; any `expires_in_secs` is ignored.
}),
});
if (!res.ok) throw new Error((await res.json()).error);
const intent = await res.json();
// Persist the mapping in YOUR database:
// order_id -> { index: intent.derivation_index, address: intent.address }
return intent;
}Store derivation_index against your order. It's how you'll look the payment
up later. Also store address to display.
Accept more than one pay-in token (e.g. SOL or USDC)#
body: JSON.stringify({
amount_usd_cents: 10_000, // $100.00
accepted_tokens: [
{ kind: "sol" },
{ kind: "spl", mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, // USDC
],
reference: orderId,
})Step 2: Show the deposit address to the payer#
Display intent.address. Render a QR code of it so wallets can scan. The
Solana Pay URI scheme also works and can pre-fill
the amount:
solana:<address>?amount=<ui_amount>&spl-token=<mint-if-token>&label=Order%201001Any Solana wallet (Phantom, Solflare, etc.) can pay to a raw address too.
Step 3: Poll status until terminal#
Poll GET /status?index=N. A 3–5 second interval is fine. Stop when you reach a
terminal state. (For production, prefer webhooks and use polling
only for live UI.)
async function pollStatus(index: number, onUpdate: (s: any) => void) {
const terminal = new Set(["swept", "refunded", "cancelled", "refund_failed"]);
while (true) {
const res = await fetch(`${API}/status?index=${index}`, {
headers: { "Authorization": `Bearer ${API_KEY}` },
});
if (res.ok) {
const s = await res.json();
onUpdate(s); // update UI with s.message
if (terminal.has(s.status)) return s;
}
await new Promise((r) => setTimeout(r, 3000));
}
}What to show the payer: use message directly; it's written for humans:
pending→ "Waiting for payment."underpaid→ "Underpaid. Please send X more…" (also seeshortfall_lamports).paid/sweeping→ "Payment received."swept→ "✓ Paid." (fulfill the order; see step 4)overpaid→ "Overpaid. The extra is being sent back."cancelled→ "Checkout cancelled." (timed out after 10 minutes, or cancelled)
Frontend polling is a UX convenience. The authoritative "order is paid" decision should be made by your backend (step 4), not trusted from the browser.
Step 4: Fulfill the order on swept#
When an intent reaches swept, the money is in your destination wallet.
Read settlement for the exact amount and reconcile:
const s = await pollStatus(index, updateUi);
if (s.status === "swept" && s.settlement) {
const received = s.settlement.destination_amount; // base units to YOUR wallet
const expected = s.expected_lamports; // what you asked for
// For fixed-price orders, verify you got what you expected (net of fees).
// The platform fee is deducted from `received`, so `received` = expected - fee;
// account for that.
markOrderPaid(s.reference, {
amount: received,
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); // needs manual attention
}Important accounting note#
settlement.destination_amount is what actually landed in your wallet, which
may be less than received_lamports because of:
- the network fee (SOL intents; ~5000 lamports), and/or
- the platform fee (deducted by Zuuppa), and/or
- an overpayment's refunded excess.
For fixed-price fulfillment, decide your tolerance:
- If you pass the platform fee on to the buyer → require
destination_amount >= expected - platform_fee. - Simplest robust check → require
received_lamports >= expected(they paid enough), then usedestination_amountfor your books.
Backend vs. frontend responsibilities#
| Concern | Where |
|---|---|
POST /intents | Backend (you control amounts, store the index). |
| Show address / QR, poll for live UX | Frontend (proxy /status through your backend so the API key stays server-side). |
| Decide "order is paid" & fulfill | Backend, on status === "swept" (or the intent.swept webhook). |
POST /sweep (manual) | Backend only. It moves funds. |
Handling each outcome (reference)#
| Terminal status | What happened | Your action |
|---|---|---|
swept | Paid & settled to your wallet. | Fulfill. Use settlement for the amount. |
refunded | Funds returned to sender (cancelled-partial, or wrong asset). | Mark unpaid. Optionally notify. |
cancelled | The 10-minute window closed with no (complete) payment, or the checkout was cancelled. | Mark unpaid. |
refund_failed | Auto-settlement exhausted retries. | Contact support; funds are safe but need manual handling. |
Multiple / partial payments#
- If a payer underpays, the intent stays
underpaidandshortfall_lamportstells you the remaining amount. If they top it up before expiry, it advances topaidand sweeps.received_lamportsaccumulates across payments. - If they overpay, you still get exactly
expected(SOL: excess auto-refunded; token: full balance swept to you).
Wrong-asset payments#
If a payer sends the wrong token (e.g. USDT to a USDC invoice, or any token to
a SOL invoice), we auto-refund it to the sender on an independent track. This
shows up in /status under token_refunds and does not block the correct
payment:
"token_refunds": [{ "mint": "Es9v...USDT", "status": "refunded" }]You generally don't need to act on this, but you can surface it ("we returned a token you sent by mistake").
Cancelling / expiry#
Every checkout lasts a fixed 10 minutes. An intent that isn't paid in that
window is cancelled automatically, so orders never sit pending forever. You can
also cancel early, for example when the buyer abandons checkout:
async function cancelOrder(index: number) {
const res = await fetch(`${API}/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" | ...
}- Idempotent: cancelling an already-cancelled intent returns
200. - Race-safe: if a payment confirmed first, you get
409; treat the order as paid, not cancelled. - Partial funds are handled per the cancellation lifecycle.
- No webhook fires on cancel/timeout. Detect it by polling
/status.
If your checkout runs in the browser/mobile via the checkout SDK,
the client can cancel its own intent with POST /intents/cancel { client_secret },
with no sk_ key needed. The SDK does this automatically when the buyer closes
the sheet.
Next: Webhooks →