3. API Reference
The complete HTTP contract. All request/response bodies are JSON.
- Base URL:
https://api.zuuppa.com(use your assigned base URL if different). - Authentication: every payments endpoint requires an API key. Send
Authorization: Bearer sk_live_...(orsk_test_...). Create the key in the dashboard (API keys). Requests without a valid key get401. Keep the key server-side.GET /healthis open.
Conventions#
- Amounts are integers in the asset's base units (lamports for SOL, token base units for a mint). See Concepts.
index=derivation_index, the integer identifying a payment intent.- Errors return a non-2xx status and a body of the form:
json
{ "error": "human-readable message" }
Error status codes#
| Code | Meaning |
|---|---|
400 Bad Request | Invalid input (bad mint, non-positive amount, nothing to sweep). |
401 Unauthorized | Missing or invalid API key. |
404 Not Found | No intent for the given index/reference. |
500 Internal Server Error | Internal error. |
502 Bad Gateway | Upstream Solana RPC error. |
POST /intents#
Auth: requires Authorization: Bearer sk_....
Create a payment intent. Derives a unique deposit address and returns it.
Pricing modes#
An intent has a mode that determines how its amount is set. Both modes are
priced in USD. Every intent carries a USD price and the buyer picks a pay-in
token at checkout:
custom(default): you set a USD price withamount_usd_cents, plus theaccepted_tokensthe buyer may pay in.order: you don't pass an amount. Send acartof catalog items (created in the dashboard, priced in USD); the server prices the order from the stored items. Requiresaccepted_tokens.
Every intent leaves mint, mint_decimals, and expected_lamports null at
creation. The buyer picks one of accepted_tokens via
POST /intents/select-token, which converts the USD
price to that token's base units at spot and locks the asset. Settlement is always
single-asset.
Request body:
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "custom" | "custom" or "order". |
amount_usd_cents | integer | — | custom mode: required. USD price in integer cents (e.g. 1250 = $12.50). Must be > 0. Rejected in order mode. |
accepted_tokens | array | — | Required (both modes). Pay-in tokens the buyer may choose among. Each element is { "kind": "sol" } or { "kind": "spl", "mint": "<mint>" }. Decimals/symbol are resolved server-side (the buyer can't inject them). |
cart | array | — | order mode: required. Line items: [{ "item_id": "<uuid>", "quantity": <n> }]. Priced from your account's catalog items. |
reference | string | null | Your opaque id (order id, user id). Stored and returned; never interpreted. |
expires_in_secs | integer | null | Ignored. Accepted for backward compatibility, but every checkout is a fixed 10-minute window (see Concepts). |
Note: the legacy fixed-token custom path (expected_lamports / mint set at
create time, no USD price) has been removed. Custom payments are always
USD-priced so they can be valued for volume stats; passing expected_lamports or
mint on create is now rejected.
Examples
USD-priced $12.50 custom payment, buyer pays in SOL or USDC (expires 10 minutes after creation):
{
"amount_usd_cents": 1250,
"accepted_tokens": [
{ "kind": "sol" },
{ "kind": "spl", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }
],
"reference": "order-1003"
}Custom payment priced at $5.00, SOL only:
{
"amount_usd_cents": 500,
"accepted_tokens": [ { "kind": "sol" } ],
"reference": "order-1001"
}Order priced from catalog items, buyer pays in SOL:
{
"mode": "order",
"cart": [
{ "item_id": "7b9c...e1", "quantity": 2 },
{ "item_id": "a4f0...92", "quantity": 1 }
],
"accepted_tokens": [ { "kind": "sol" } ],
"reference": "order-1004"
}Response 200 OK: the created intent (see the object reference):
{
"id": "d0714125-193a-4706-91d1-80854d829214",
"derivation_index": 42,
"address": "9xQe...pump",
"client_secret": "cs_7pKf...9dQ2",
"mint": null,
"mint_decimals": null,
"expected_lamports": null,
"status": "pending",
"received_lamports": 0,
"reference": "order-1003",
"mode": "custom",
"price_usd_cents": 1250,
"accepted_tokens": [
{ "kind": "sol" },
{ "kind": "spl", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "decimals": 6, "symbol": "USDC" }
],
"expires_at": "2026-07-26T13:10:00Z",
"created_at": "2026-07-26T13:00:00Z",
"updated_at": "2026-07-26T13:00:00Z",
"refund_sender": null
}For a USD-priced intent the asset fields stay null until the buyer selects a
token. The create response instead carries the price and options:
{
"mode": "custom",
"mint": null,
"mint_decimals": null,
"expected_lamports": null,
"price_usd_cents": 1250,
"accepted_tokens": [
{ "kind": "sol" },
{ "kind": "spl", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "decimals": 6, "symbol": "USDC" }
],
"status": "pending"
}Save derivation_index. You'll poll status with it. Show address to the payer.
client_secret (cs_...) is a one-time, single-intent token returned
only on the first create (only its hash is stored, so it is never shown
again; an idempotent-reference retry returns client_secret: null). It lets
an untrusted client (a mobile app / browser) read only this one intent's
status and attach buyer details without your secret sk_ key. See
Client-facing endpoints. Forward it to your client if
you use those; otherwise ignore it. Treat it like a password for that one intent:
a leak exposes only that single payment, never your account or other intents.
Errors
400mode must be 'custom' or 'order'400amount_usd_cents is required for custom payments400amount_usd_cents must be > 0400custom payments are priced in USD; pass amount_usd_cents, not expected_lamports/mint400accepted_tokens is required for USD-priced intents400accepted_tokens must not be empty400invalid mint address/invalid mint: ...(anaccepted_tokensSPL mint not found on-chain)400cart is only valid for order mode400cart is required for order mode400order mode prices from the cart; do not pass an amount or mint400quantity must be > 0400unknown or inactive item(a cartitem_idisn't one of your active catalog items)400order total exceeds maximum
Idempotency (recommended)#
If you pass a reference, POST /intents is idempotent on it: retrying
the same reference (network retry, crash, double-tap) returns the same intent
(same derivation_index and address) instead of creating a second deposit
address. Always pass a stable unique reference (e.g. your order id).
GET /intents?reference={reference}#
Auth: requires Authorization: Bearer sk_....
Recover an intent by its reference. For example, if the POST /intents response
was lost, look it up instead of creating a new one.
Response 200 OK: the same PaymentIntent object as POST /intents.
Errors: 404 no intent for that reference.
curl -H 'Authorization: Bearer sk_live_...' \
"https://api.zuuppa.com/intents?reference=order-1001"GET /status?index={index}#
Auth: requires Authorization: Bearer sk_....
The endpoint your app polls. Returns the intent's full state plus a human-readable message, the exact settled amounts (once swept), and any wrong-token refunds.
Query params
index(required): thederivation_indexfromPOST /intents.
Response 200 OK:
{
"id": "d0714125-...",
"derivation_index": 42,
"address": "9xQe...pump",
"mint": null,
"mint_decimals": null,
"expected_lamports": 500000000,
"status": "swept",
"received_lamports": 500000000,
"reference": "order-1001",
"expires_at": "2026-07-26T13:10:00Z",
"created_at": "2026-07-26T13:00:00Z",
"updated_at": "2026-07-26T13:02:11Z",
"refund_sender": "Fx3X...gsy",
"action": "swept",
"message": "Payment received and settled.",
"settlement": {
"asset": "SOL", "decimals": 9,
"destination_amount": 499995000, "destination_ui": 0.499995,
"platform_fee_amount": 0, "platform_fee_ui": 0.0,
"signatures": ["4bd..."]
}
}Response fields: the flattened PaymentIntent (see below) plus:
| Field | Type | Present | Description |
|---|---|---|---|
action | string | always | Machine-friendly state: waiting, underpaid, paid, overpaid, swept, refunding, refunded, cancelled, refund_failed. |
message | string | always | Human-readable status message, safe to show a payer. |
shortfall_lamports | integer | only when underpaid | How many more base units are needed to complete the payment. |
token_refunds | array | only if non-empty | Wrong-token refunds for this address: [{ "mint": "...", "status": "pending|settling|refunded|failed" }]. Independent of the SOL status. |
settlement | object | only after first sweep | Exact settled amounts. See below. |
customer_details | object | only if collected | Buyer details submitted via POST /intents/details. See the object reference. |
settlement object (source of truth for accounting):
| Field | Type | Description |
|---|---|---|
asset | string | "SOL" or the SPL mint address. |
decimals | integer | 9 for SOL, else mint decimals. |
destination_amount | integer | Base units delivered to your destination wallet (net of platform fee / refunded excess). |
destination_ui | number | destination_amount ÷ 10^decimals, for display. |
platform_fee_amount | integer | Base units sent to the platform wallet (0 if fees off). |
platform_fee_ui | number | Decimal-adjusted platform fee. |
signatures | string[] | On-chain sweep transaction signature(s). |
Errors
404no intent for that index
POST /sweep#
Auth: requires Authorization: Bearer sk_....
Manually trigger a sweep of an intent's balance to your destination. Usually unnecessary, since settlement is automatic. Use it only to force a retry.
⚠️ This moves funds. Treat it as a privileged backend-only operation.
Request body
{ "index": 42 }Response 200 OK:
{
"signature": "4bd...",
"lamports_swept": 499995000,
"platform_fee_lamports": 0,
"from": "9xQe...pump",
"to": "BLTpUS6b..."
}Errors
400nothing to sweep (balance ...)404no intent for that index502on RPC failure.
POST /cancel#
Auth: requires Authorization: Bearer sk_....
Cancel one of your intents before it's paid, for example when the buyer abandoned checkout. See the cancellation lifecycle for what happens to any partial funds.
Request body
{ "index": 42 }Response 200 OK: the intent's status (same shape as
GET /status), now cancelled (or refunding/refunded
if a partial SOL balance is being returned).
Behavior
- Only acts while the intent is
pending/underpaid. If a payment already advanced it, the cancel is refused with409. - Idempotent: cancelling an already-
cancelled/refunding/refundedintent returns200with its current status. - Race-safe: if a payment confirms at the same instant, the payment wins and
the cancel returns
409.
Errors
404no intent for that index409payment already received; cannot cancel
A public POST /intents/cancel takes a client_secret instead of your sk_
key, for cancelling from an untrusted client. See
POST /intents/cancel.
Client-facing endpoints#
These two endpoints are authenticated by a per-intent client_secret
(cs_...) instead of your secret sk_ key, so they are safe to call from an
untrusted client such as a mobile app or browser. Each is scoped to the single
intent whose client_secret is presented: it can never read or modify your
account or any other intent. Get the client_secret from the POST /intents
response and forward it to your client.
Your sk_ key must stay server-side. These endpoints exist precisely so a
client can show live status and collect buyer details without it.
GET /intents/status?client_secret={cs_...}#
Auth: none. The client_secret is the credential.
Read a single intent's status. Returns the same body as
GET /status (the flattened PaymentIntent plus
action, message, shortfall_lamports, token_refunds, settlement).
Query params
client_secret(required): the intent'scs_...token.
curl "https://api.zuuppa.com/intents/status?client_secret=cs_7pKf...9dQ2"Errors
400invalid client_secret(missing/malformed).404no intent for that client_secret.
POST /intents/cancel#
Auth: none. The client_secret is the credential.
Cancel this one intent from the client (used by the checkout SDK when the buyer
closes the sheet). Same lifecycle, idempotency, and race-safety as the sk_
POST /cancel.
Request body
{ "client_secret": "cs_7pKf...9dQ2" }Response 200 OK: the intent's status, now cancelled (or
refunding/refunded).
Errors
400invalid client_secret.404no intent for that client_secret.409payment already received; cannot cancel.
POST /intents/details#
Auth: none. The client_secret is the credential.
Attach optional buyer details (name, email, international address) to the intent,
e.g. for a receipt or shipping. All fields are optional; send only the ones you
collect. The details are stored on the intent and surfaced back on
GET /status, GET /intents/status, the dashboard, and
webhook payloads as customer_details.
Only accepted while the intent is still open (pending/underpaid). A later
submission replaces the previously stored details.
Request body
| Field | Type | Description |
|---|---|---|
client_secret | string | Required. The intent's cs_... token. |
first_name | string | Optional. |
last_name | string | Optional. |
email | string | Optional. Validated as an email address when present. |
address | object | Optional. International address (all parts optional; see below). |
address object
| Field | Type | Description |
|---|---|---|
country | string | ISO 3166-1 alpha-2 code (e.g. US, GB, JP). Stored upper-cased. |
line1 | string | Street address. |
line2 | string | Apartment, suite, etc. |
city | string | City / locality. |
state | string | State / province / region. |
postal_code | string | Postal / ZIP code. |
{
"client_secret": "cs_7pKf...9dQ2",
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"address": {
"country": "GB",
"line1": "12 Baker St",
"city": "London",
"postal_code": "NW1 6XE"
}
}Response 200 OK: the updated status (same shape as GET /intents/status),
now including customer_details.
Errors
400invalid client_secret/invalid email/country must be an ISO 3166-1 alpha-2 code/no details provided.404no open intent for that client_secret(unknown, or already past payment).
POST /intents/select-token#
Auth: none. The client_secret is the credential.
For a USD-priced intent (created with amount_usd_cents or in order mode),
the buyer picks one of the merchant's accepted_tokens. The server converts the
USD price to that token's base units at the current spot rate (ceil-rounded, never
under-collecting) and locks the asset, setting mint, mint_decimals, and
expected_lamports on the intent. The buyer can only choose among the merchant's
tokens; they can never set the amount.
Re-selectable while the intent is still pending (each pick re-converts at a fresh
rate); refused once a payment lands. Selecting does not extend expires_at.
Request body
| Field | Type | Description |
|---|---|---|
client_secret | string | Required. The intent's cs_... token. |
mint | string | null | The chosen pay-in mint. Omit or send null for native SOL. Must match one of the intent's accepted_tokens. |
{ "client_secret": "cs_7pKf...9dQ2", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }Response 200 OK: the updated status (same shape as GET /intents/status),
now with mint/mint_decimals/expected_lamports populated for the locked asset.
Errors
400invalid client_secret.400this intent's amount is already fixed(not a USD-priced intent).400token not accepted for this intent(mint isn't one ofaccepted_tokens).404no intent for that client_secret.409payment already in progress(intent is pastpending, so it can't re-lock).
GET /intents/quote?client_secret={cs_...}#
Auth: none. The client_secret is the credential.
Preview what each accepted token would cost right now, without locking anything (pure read). Use it to show the buyer per-token amounts before they pick.
Query params
client_secret(required): the intent'scs_...token.
Response 200 OK:
{
"price_usd_cents": 1250,
"expires_in_seconds": 600,
"quotes": [
{ "symbol": "SOL", "decimals": 9, "expected_lamports": 83333334 },
{ "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "symbol": "USDC", "decimals": 6, "expected_lamports": 12500000 }
]
}| Field | Type | Description |
|---|---|---|
price_usd_cents | integer | The intent's USD price. |
expires_in_seconds | integer | How long a quote is considered fresh (indicative). |
quotes | array | One entry per accepted token: mint (omitted for SOL), symbol, decimals, expected_lamports (amount in that token's base units). |
Amounts are indicative. The authoritative amount is set when the buyer calls
POST /intents/select-token.
Errors
400invalid client_secret.400this intent's amount is already fixed(not a USD-priced intent).404no intent for that client_secret.
GET /health#
Liveness probe. Returns 200 OK with body ok. No auth, no JSON.
PaymentIntent object#
The core object returned by /intents and flattened into /status. Fields:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Internal unique id. |
derivation_index | integer | The index / stable id. Poll with this. |
address | string | Deposit address to show the payer. |
client_secret | string | null | One-time single-intent token for client-facing endpoints. Returned only on the first POST /intents; omitted elsewhere. |
mint | string | null | Accepted asset: null = SOL, else SPL mint. |
mint_decimals | integer | null | Token decimals (null for SOL). |
expected_lamports | integer | null | Expected amount, base units. null = any, or a USD-priced intent whose token isn't locked yet. |
received_lamports | integer | Amount received so far, base units (gross). |
status | string | Lifecycle state (see Concepts). |
reference | string | null | Your opaque id. |
mode | string | Pricing mode: "custom" or "order". |
price_usd_cents | integer | null | USD price in cents for a USD-denominated intent; null for a fixed-token amount. |
accepted_tokens | array | null | Pay-in tokens the buyer may choose among (USD-priced intents). Each: { "kind": "sol" } or { "kind": "spl", "mint", "decimals", "symbol" }. null once the asset is fixed/locked. |
expires_at | string (ISO 8601) | null | Payment window close time. |
created_at | string (ISO 8601) | Creation time. |
updated_at | string (ISO 8601) | Last state change. |
refund_sender | string | null | Address we will/did refund to (the payer's address, once known). |
customer_details | object | null | Buyer details submitted via POST /intents/details (name, email, address). Omitted when none were collected. |
customer_details object (present only when details were submitted; each
sub-field appears only if provided):
| Field | Type | Description |
|---|---|---|
first_name | string | Buyer's first name. |
last_name | string | Buyer's last name. |
email | string | Buyer's email. |
address | object | International address: country (ISO 3166-1 alpha-2), line1, line2, city, state, postal_code. |
Next: Integration guide →