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#

text
┌─────────────┐        ┌──────────────┐        ┌────────────────────┐
│ 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.


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.

ts
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)#

ts
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:

text
solana:<address>?amount=<ui_amount>&spl-token=<mint-if-token>&label=Order%201001

Any 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.)

ts
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 see shortfall_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)

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:

ts
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 use destination_amount for your books.

Backend vs. frontend responsibilities#

ConcernWhere
POST /intentsBackend (you control amounts, store the index).
Show address / QR, poll for live UXFrontend (proxy /status through your backend so the API key stays server-side).
Decide "order is paid" & fulfillBackend, on status === "swept" (or the intent.swept webhook).
POST /sweep (manual)Backend only. It moves funds.

Handling each outcome (reference)#

Terminal statusWhat happenedYour action
sweptPaid & settled to your wallet.Fulfill. Use settlement for the amount.
refundedFunds returned to sender (cancelled-partial, or wrong asset).Mark unpaid. Optionally notify.
cancelledThe 10-minute window closed with no (complete) payment, or the checkout was cancelled.Mark unpaid.
refund_failedAuto-settlement exhausted retries.Contact support; funds are safe but need manual handling.

Multiple / partial payments#

  • If a payer underpays, the intent stays underpaid and shortfall_lamports tells you the remaining amount. If they top it up before expiry, it advances to paid and sweeps. received_lamports accumulates 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:

json
"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:

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

Next: Webhooks →