Skip to content
Zuuppa
Dashboard

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. 1

    Step 1 — create an intent

    Price in USD cents and list the assets the buyer may pay in.

    TypeScript
    const 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_index against your order, store address to display, and forward client_secret to your client if it will select the token.

  2. 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 the cs_ 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_uri

    To show what each option costs first, read GET /intents/quote?client_secret=cs_.... Selection is re-runnable while pending and refused with 409 once a payment lands.

  3. 3

    Step 3 — show the deposit address

    Render status.payment_uri as the QR code and show address as 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 an underpaid intent it asks for the remaining shortfall. Re-render from the latest status response.

  4. 4

    Step 4 — learn the outcome

    Prefer webhooks for fulfillment; use polling for live UI.

    TypeScript
    async 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. 5

    Step 5 — fulfill on swept

    TypeScript
    const 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);
    }
    Note

    Accounting note

    settlement.destination_amount is what actually landed in your wallet, which may be less than received_lamports because of the network fee, the platform fee, and/or a refunded overpayment. Simplest robust check: require received_lamports >= expected_lamports (they paid enough), then use destination_amount for 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:

TypeScript
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.

© 2026 Zuuppa. All rights reserved.