Core Concepts
Core Concepts
Read this before integrating. These concepts appear throughout the API — how payments are represented, priced, settled, and accounted for.
Unique deposit addresses#
Zuuppa derives a unique Solana deposit address for every payment. You never reuse an address, and you never handle private keys — the platform holds one master seed and re-derives the signing key for any address on demand to sign sweeps and refunds.
- Each payment intent is allocated a stable integer
index(itsderivation_index), which maps to exactly one deposit address. - You reference a payment by its
index(to poll/status, sweep, or cancel).
You never see or handle private keys.
Payment intent#
A payment intent is the central object. It represents “I expect a payment to this address.” Creating one (POST /intents) does three things:
- Allocates the next
index(atomic, no collisions even under high concurrency). - Derives the unique deposit
addressfor that index. - Persists the intent.
An intent records:
| Field | Meaning |
|---|---|
derivation_index | The stable integer id. Use this to poll status and reference the payment. |
address | The Solana deposit address to show the payer. |
price_usd_cents | The USD price in integer cents. Every intent is USD-priced. |
mint | The pay-in asset: null = native SOL, else an SPL mint address. null until the buyer selects a token. |
mint_decimals | Decimals of the token (null for SOL, and null until selection). |
expected_lamports | Expected amount in base units. null until the buyer selects a token, which is when the USD price is converted and the amount locks. |
received_lamports | Amount received so far, in base units. |
status | Lifecycle state — see Payment lifecycle. |
reference | Your own id (order id, user id, memo). Optional, opaque to the server. |
expires_at | When the payment window closes. Always 10 minutes after creation. |
Fixed 10-minute window
Every intent expires exactly 10 minutes after it's created. This is a fixed product guarantee: the expires_in_secs request field is accepted for backward compatibility but ignored — you cannot lengthen or shorten the window. An intent that isn't paid in time is auto-cancelled (partial funds are refunded where possible; see the lifecycle).
Naming note
The fields are called expected_lamports / received_lamports for historical reasons, but they hold base units of the intent's asset: lamports for SOL, token base units for an SPL token. Treat them as generic integer amounts.
Pricing#
Every intent is priced in USD: you set the amount in integer USD cents with amount_usd_cents — anything from a checkout total to a tip — and the buyer chooses which asset to pay in.
Creating an intent requires accepted_tokens: the list of assets the buyer may pay in. Each must be a token the platform accepts — see GET /allowed-tokens for the current set (SOL and USDC today).
At creation, mint, mint_decimals, and expected_lamports are all null. The buyer picks one of accepted_tokens (POST /intents/select-token, authorized by the intent's client_secret), the server converts the USD price to that asset's base units at spot, and the amount locks. From that point the intent is single-asset. GET /intents/quote previews every option's amount without locking anything.
The legacy fixed-token path was removed
The path where the caller set the amount directly in a token's base units (expected_lamports / mint at create) now returns 400. Every payment carries a USD price so it can be valued in stats and so an account can accept several assets for one payment.
Assets: the platform allowlist (SOL + USDC today)#
An intent may only accept tokens on the platform's curated allowlist — today SOL and USDC. The set is managed by the operator, so support for a new token is added centrally (no code change) and every merchant can then use it; read the live set from GET /allowed-tokens.
Each intent settles in exactly one asset, fixed once the buyer selects it:
- No
mint→ native SOL. Amounts are in lamports (1 SOL = 1,000,000,000 lamports). mintset → that SPL token. Its decimals come from the allowlist entry (captured on-chain when the token was added), and amounts are in the token's base units.
accepted_tokens may list several allowed assets; exactly one of them becomes the intent's asset at selection time.
Base units = the smallest indivisible unit. To convert:
base_units = ui_amount × 10^decimals
ui_amount = base_units ÷ 10^decimalsExamples:
- 0.5 SOL (9 decimals) →
500000000lamports. - 100 USDC (6 decimals) →
100000000base units. - 100 units of a 9-decimal token →
100000000000base units.
Both classic SPL Token and Token-2022 mints are supported automatically.
The settlement lifecycle#
Once a payment arrives, the server settles it automatically: it sweeps the funds to your destination wallet, refunds any excess, and so on. You don't trigger this. You just observe the status.
The full status table and every branch of the flow — happy path, overpayment, underpayment, expiry, and cancellation — live on their own page:
See the full lifecycle
Payment lifecycle → covers all status values, the action aliases, the visual flow diagrams, and cancellation behavior. The status you care about most is swept: the money is yours and settlement tells you exactly how much.
What “settled” means and the settlement object#
When an intent reaches swept, /status includes a settlement object: the exact on-chain amounts that were delivered. This is your source of truth for accounting:
"settlement": {
"asset": "SOL",
"decimals": 9,
"destination_amount": 499995000,
"destination_ui": 0.499995,
"platform_fee_amount": 0,
"platform_fee_ui": 0.0,
"signatures": ["4bd..."]
}"SOL" or the mint).A payment taken for a seller adds a third leg, marketplace_fee_amount, plus marketplace_fee_cents. See Businesses, sellers, and marketplaces.
Use base units for accounting, not the floats
destination_amount can be less than received_lamports, and that's expected. received_lamports is the gross amount the payer sent; destination_amount is what you actually net after fees/refunds. For a ledger, prefer the integer base-unit fields or the exact decimal strings that webhooks carry — the _ui floats are for display only.
Fees, refunds, and edge cases#
The server handles these automatically:
- Network fee. Every sweep pays the tiny Solana network fee (~5000 lamports). For SOL it comes out of the swept amount; for tokens the platform's fee-payer wallet fronts it (the deposit address holds no SOL).
- Platform fee (optional). The platform's own cut: a percentage plus a flat amount of each sweep, diverted to the platform wallet in the same transaction (no extra network fee). You keep 100% of your revenue minus this fee. The fee in force is published at
GET /config. - Marketplace fee (marketplace payments only). A third leg in the same sweep, paying the marketplace its cut of a seller's payment.
- Overpayment. The expected amount is swept to the destination and the excess is refunded to the sender, in one atomic transaction, for SOL and SPL tokens. (SOL takes the network fee out of the excess; a token refund is fronted by the fee-payer, so a token overpayer gets the full excess back.)
- Underpayment + expiry. SOL: the partial amount is refunded to the sender. Token: held for manual handling.
- Wrong asset. A token sent to an intent expecting a different asset is automatically refunded to the sender on an independent track (reported in
token_refunds). SOL sent to a token intent is held for manual review. - Funds sent after the order finished. A deposit address is used once and then goes dead; nothing keeps watching it. Don't reuse an address or show it again after checkout completes — generate a new intent. Anything that lands there is visible to the platform and can be returned on request.
Businesses, sellers, and marketplaces#
One login can own several businesses (accounts). Each business has its own deposit index space, API keys, sweep destination, platform-fee settings, webhook, and stats. On the API, the sk_ key you present decides which business you are acting as.
A business can be approved as a marketplace, which lets it create sellers: accounts it takes payments for and pays out to directly, without ever holding their funds. (You apply to become a marketplace from your dashboard.)
| Business | Seller | |
|---|---|---|
| Created by | The owner, in the dashboard | A marketplace, via POST /marketplace/sellers |
| Can sign in | Yes | No. No login, no dashboard, no API key. |
| Own webhook | Yes | No. Its events go to the marketplace's URL, signed with the marketplace's secret. |
| Payout address | Its own | Its own, set and changed by the marketplace |
A payment for a seller is an ordinary POST /intents plus one header, X-Zuuppa-On-Behalf-Of: <seller_id>, and one extra requirement: customer.email is mandatory, because a seller has no login and the platform sends no email. Every failure mode for that header answers a uniform 404 seller not found, so a caller can never probe for the existence of accounts it does not own.
The sweep then splits three ways in one transaction:
| Leg | Where it lands |
|---|---|
| The seller's share | the seller's sweep_destination |
| The marketplace's cut | the marketplace's fee_wallet, falling back to the marketplace account's own sweep_destination |
| The platform fee | the platform wallet |
The cut rate resolves once at create time and is frozen onto the intent as marketplace_fee_bps: the request's application_fee_bps, else the seller's marketplace_fee_bps override, else the marketplace's default_fee_bps. The cap is 5000 bps. If neither a fee_wallet nor a marketplace sweep_destination is set, the cut is forgone rather than the payment failing. A fee leg that would land below the rent-exempt minimum is dropped and rolled into the seller's leg, and marketplace_fee_cents records 0. Marketplace splitting is SOL only today.
Full endpoints are in the Marketplace API.
Idempotency & safety guarantees#
- Idempotent creates. Pass a stable
referencetoPOST /intentsand a retry returns the same intent instead of a second deposit address. - Detection is idempotent. The same on-chain transaction is never counted twice (keyed by
(address, signature)). - Crash-safe & race-safe settlement. No two workers ever sweep the same intent; a settlement interrupted mid-flight is retried automatically.
- Retries with backoff. Transient failures retry with exponential backoff, then dead-letter to a
*_failedstatus for manual review (never silently lost).
Next
Continue to Authentication for the three credentials, or jump to the Payments API.