API Reference
Subscriptions API
Recurring billing on Solana. A subscriber authorizes an on-chain token allowance once; Zuuppa then pulls each period's charge automatically, with no further action from the subscriber. You never hold the subscriber's funds, and the subscriber keeps custody — they can revoke the allowance from their own wallet at any time.
- Merchant endpoints are called at
https://api.zuuppa.comwith yoursk_API key (or dashboard session). They're identical whether reached by key or session. - Subscribers complete checkout and manage their subscription on Zuuppa-hosted pages at
https://pay.zuuppa.com— you send them a link, they never call the API directly.
Availability
Subscriptions must be enabled on your deployment and your account must be live (not test). When disabled, subscription routes answer503 subscription_service_unavailable. Check GET /config — the subscriptions block is null when the surface is off.How it works#
- A charge is priced in USD cents on a tier, but settled in a stablecoin (USDC) that the platform configures. The tier response tells you the
mint,decimals, and the computed base-unit amounts. - At checkout the subscriber's wallet grants an allowance for the total authorization — one period's price times
authorization_periods(default 12) — not just one period. Zuuppa draws down that allowance one charge at a time. - The subscription becomes
activeonly when the first charge confirms on-chain, so "subscribed" means "paid", not merely "signed". - Subscription and charge state changes reach you as webhooks (
subscription.*,charge.*) through the same signed pipeline as payment events.
Charge math#
unit_base_units = price_usd_cents × 10^(decimals-2) // e.g. USDC has 6 decimals
total_authorization_base_units = unit_base_units × authorization_periodsThe tier response returns both unit_base_units and total_authorization_base_units so you don't compute them yourself.
Required setup order (merchant)#
1. POST /subscription-products → create a product
2. POST /subscription-products/:id/tiers → create a tier (price + interval)
3. POST /subscription-domains → register redirect domain(s) [if using success_url/cancel_url]
4. POST /subscription-intents → returns checkout_url (a pay.zuuppa.com link)
5. send the subscriber to checkout_url → they complete the hosted checkout
6. (later) POST /subscriptions/:id/portal-link → mint a portal link for managementProducts#
A product groups one or more pricing tiers. Statuses: active, archived.
Product object (render_product): id, name, description, external_id, status, metadata, archived_at, created_at, updated_at.
| Method + Path | Request body | Response |
|---|---|---|
POST /subscription-products | name (req, ≤200), description? (≤2000), external_id? (≤255, idempotency key), metadata? (≤50 keys) | 201 product (200 if external_id already exists) |
GET /subscription-products | query: limit, cursor, status, search/q | { products[], next_cursor } |
GET /subscription-products/:id | — | product |
PATCH /subscription-products/:id | name?, description?, status?, metadata? (replaced wholesale). external_id is immutable. | product |
POST /subscription-products/:id/archive | — | product |
Errors: invalid_name, invalid_description, invalid_external_id, invalid_status, invalid_metadata, product_exists, product_field_immutable (+field), product_not_found.
Tiers#
A tier is a price + billing interval under a product. Statuses: active, archived.
| Method + Path | Request body | Response |
|---|---|---|
POST /subscription-products/:id/tiers | name (req, ≤120), description?, price_usd_cents (req, whole cents), interval (req: day|week|month|year), interval_count? (default 1), authorization_periods? (default 12), metadata? | 201 tier |
GET /subscription-products/:id/tiers | limit, cursor | { tiers[], next_cursor, product_id } |
GET /subscription-tiers/:id | — | tier |
PATCH /subscription-tiers/:id | mutable: name?, description?, status?, metadata?. Pricing fields are immutable. | tier |
POST /subscription-tiers/:id/archive | — | tier |
Interval ceilings: interval_count ≤ 365 (day), 52 (week), 12 (month), 1 (year). authorization_periods ≥ 1.
Pricing fields are immutable
price_usd_cents, interval, interval_count, authorization_periods, product_id, mint, decimals, platform_fee_bps, and payout fields cannot be edited — create a new tier to change a price.Errors: invalid_price, invalid_interval, invalid_interval_count, invalid_authorization_periods, authorization_total_too_large, tier_price_above_ceiling (+max_price_usd_cents), tier_field_immutable (+field), product_archived, payout_destination_missing, payout_destination_invalid, platform_wallet_unconfigured (503), tier_not_found, product_not_found, marketplace_fee_unsupported (+marketplace_fee_bps).
marketplace_fee_unsupported
A marketplace seller cannot create subscription tiers while a non-zero marketplace fee is configured — marketplace subscriptions are a later milestone.Redirect domains#
If you set a success_url or cancel_url on an intent, its host must first be registered here. URLs must be https and non-loopback.
Domain object (render_domain): id, host, active, created_at, updated_at.
| Method + Path | Request body | Response |
|---|---|---|
POST /subscription-domains | host (req, ≤253) | 201 domain |
GET /subscription-domains | limit, cursor, active (bool) | { domains[], next_cursor } |
GET /subscription-domains/:id | — | domain |
POST /subscription-domains/:id/archive | — | domain |
Errors: invalid_host, invalid_redirect_url (+field), domain_not_found.
Subscription intents (hosted checkout link)#
Create an intent for a tier; the response carries the hosted checkout link you send the subscriber. Statuses: open, completed, canceled, expired.
Intent object (render_intent): id, tier_id, status, subscriber_wallet, wallet_pinned, success_url, cancel_url, subscription_id, metadata, expires_at, created_at, updated_at, completed_at, canceled_at.
| Method + Path | Request body | Response |
|---|---|---|
POST /subscription-intents | tier_id (req), subscriber_wallet? (pins the checkout to one wallet), success_url?, cancel_url?, expires_in? (seconds, default 86400, min 300, max 604800), metadata? | 201 intent plus checkout_url and checkout_token |
GET /subscription-intents | limit, cursor, status, tier_id | { intents[], next_cursor } |
GET /subscription-intents/:id | — | intent |
POST /subscription-intents/:id/cancel | — | 200 intent |
checkout_url is a https://pay.zuuppa.com/s/<checkout_token> link — this is the only response that returns it. Redirect the subscriber there.
Errors: tier_not_found, tier_archived, tier_mint_mismatch (+tier_mint, +platform_mint), tier_price_above_ceiling (+max_price_usd_cents), invalid_expires_in (+min_expires_in, +max_expires_in), invalid_subscriber_wallet (+field), invalid_redirect_url (+field), invalid_tier_id (+field), intent_not_found, intent_not_open (+status, +subscription_id), marketplace_fee_unsupported (+marketplace_fee_bps).
Hosted checkout (pay.zuuppa.com/s/:token)#
The subscriber completes checkout on the hosted page. The page drives these endpoints for you — you don't call them — but here's the flow so you understand what the subscriber goes through.
The page first GETs the checkout to render product/tier/price, then calls prepare, which chooses one of two completion paths by arithmetic, not by configuration:
requires_signature: true— the wallet's existing on-chain allowance can't cover this subscription, so a new allowance must be granted. The server builds anapproveCheckedtransaction for the new total, partially signs it with the platform fee-payer (so the subscriber needs zero SOL), and hands it over. The wallet adds the owner signature andactivatebroadcasts it.requires_signature: false— the allowance already on chain covers it, so nothing needs to be broadcast. The wallet signs a SIWS message (Sign-In With Solana) andconfirmcompletes it; that signature is the entire consent record.
Either way, the subscription ends up incomplete and armed for its first pull. Nothing here moves money — the charger does, and the subscription flips to active only when that first pull confirms. The page polls GET /s/:token for first_charge, and retry re-arms a first charge that definitely failed.
| Method + Path | Purpose |
|---|---|
GET /s/:token | Render the checkout and poll first-charge status. Returns status, subscription_id, first_charge, merchant{name}, product, tier, total_authorization, mint, subscriber_wallet, wallet_pinned, success_url, cancel_url, expires_at. |
POST /s/:token/prepare | { wallet }. Returns either the SIWS challenge (requires_signature:false) or a partially-signed transaction to sign (requires_signature:true). |
POST /s/:token/activate | { subscription_id, transaction } (the owner-signed approve). Broadcasts it and arms the first charge. May answer 202 confirmation_pending. |
POST /s/:token/confirm | { wallet, nonce, signature } — the signature-free path (SIWS consent). |
POST /s/:token/retry | Re-arm a first charge that failed. |
first_charge shape: { status ("pending"|"succeeded"|"failed"), failure_code, failure_message, recovery_action ("retry"|"reapprove"|"none"), signature, explorer_url }.
Common refusal codes (the page owns the wording): checkout_not_found, checkout_completed, checkout_expired, checkout_closed, wallet_not_permitted (a pinned wallet mismatch), rate_limited (+retry_after_seconds), no_token_account, account_frozen, authorization_required, blockhash_expired, invalid_signature, charge_in_progress.
Subscription management (merchant)#
Statuses: incomplete, active, renewal_required, past_due, paused, canceled, revoked, exhausted, failed.
| Method + Path | Request body | Response |
|---|---|---|
GET /subscriptions | limit, cursor, status, tier_id, subscriber_wallet | { subscriptions[], next_cursor } |
GET /subscriptions/:id | — | { subscription, delegation } |
PATCH /subscriptions/:id | tier_id (req) | Applies a tier change that only lowers or keeps what is pulled. A tier that would raise the amount is refused with 403 wallet_proof_required — a raise needs the subscriber's wallet signature, done through the portal. |
POST /subscriptions/:id/cancel | at_period_end? (default true) | 200 { subscription }. Stops billing; no chain write. |
POST /subscriptions/:id/resume | — | 200 { subscription } |
POST /subscriptions/:id/charge-now | — | 202 { subscription }. Resumes charging a stopped subscription; never bills early. |
POST /subscriptions/:id/portal-link | — | 201 { subscription_id, portal_url, expires_at, origin: "merchant", may_increase: false } |
GET /subscriptions/:id/charges | limit, cursor | { charges[], next_cursor } |
GET /subscription-delegations/:wallet | — | { delegation, subscriptions[], count, limit } — the on-chain allowance state for a wallet. |
Charge object (render_charge): id, subscription_id, period_index, delegation_epoch, mint, amount, fee_amount, net_amount, destination_ata, fee_destination, status, signature, explorer_url, failure_code, recovery_action, attempts, created_at, settled_at.
Delegation object (render_delegation): subscriber_wallet, mint, epoch, status, authorized_total, token_account, chain_delegate, chain_allowance, chain_checked_at, cleared_at, updated_at.
Merchant links cannot raise
A portal link minted by the merchant hasmay_increase: false — a subscriber reaching the portal via a merchant link can cancel or downgrade but cannot be moved to a pricier tier without their own wallet signature. This is what stops a merchant raising what it pulls from a wallet with no wallet signature in the loop.Errors: subscription_not_found, subscription_incomplete, subscription_ended (+status), subscription_paused, subscription_canceling, charge_in_progress (+charge_id), charge_already_scheduled (+next_charge_at), authorization_exhausted (+status), wallet_proof_required, delegation_not_found, permission_denied, chain_unavailable (502).
Hosted portal (pay.zuuppa.com/m/:token)#
Where a subscriber manages their subscription. As with checkout, the hosted page drives these endpoints; you mint the link via POST /subscriptions/:id/portal-link (or the subscriber recovers it by proving wallet ownership at /manage).
Reducing is always allowed; raising must be earned. Cancelling and downgrading reduce what can be pulled, so any portal credential may do them. Raising (a pricier tier, or renewing with a larger allowance) requires a wallet signature behind the portal session — a merchant-minted link cannot raise.
Every change of terms goes through one core that returns one of three outcomes, decided by the chain and the ledger, not by a parameter:
changed— the new slice claims no more than the old one, or the room was already there and a signature was supplied. Applied inline; nothing broadcast.proof_required— the existing approval covers the increase, but someone is asking to be charged more, so a SIWS signature is required. A challenge is issued and nothing is written. Re-post the same request carrying the signature.approval_required— the room isn't there. A partially-signedapproveCheckedfor the new total is handed over;POST /m/:token/activatebroadcasts it.
| Method + Path | Request body | Purpose |
|---|---|---|
GET /m/:token | — | Render the manage page: subscription, merchant, product, tier, options[] (tiers the subscriber may switch to), mint, session{expires_at, may_increase}, recovery_url, total_authorization. |
POST /m/:token/cancel | at_period_end? (default true) | Cancel (stops billing; no chain write). |
POST /m/:token/resume | — | Resume. |
POST /m/:token/change-tier | tier_id (req), nonce? + signature? (both or neither) | Change tier. Downgrade applies inline (changed); upgrade returns proof_required or approval_required. |
POST /m/:token/renew | nonce? + signature? | Re-authorize / renew; needs a signature to raise the allowance. |
POST /m/:token/activate | approval_id, transaction | Broadcast the owner-signed approve for a raise/renew. May answer 202 confirmation_pending. |
POST /manage/challenge | { wallet } | Issue a SIWS challenge (link recovery, no token). |
POST /manage/verify | { wallet, nonce, signature } | Verify the signature and return the wallet's subscriptions with fresh portal links. |
No revoke endpoint, by design
Revoking an allowance is something a wallet does to its own token account, and every wallet UI offers it. The portal tells the subscriber that control exists.Errors: portal_not_found (+recovery_url), portal_session_expired (+recovery_url), wallet_proof_required (raising via a link that can't raise), same_tier, tier_not_found, different_product, tier_archived, nothing_to_renew, authorization_pending, subscription_changed, incomplete_proof, blockhash_expired, invalid_signature.
What requires a wallet signature — summary#
| Action | Wallet signature? |
|---|---|
| Initial checkout when no allowance exists | Yes — owner-signed approve (broadcast via activate) |
| Initial checkout when an allowance already covers it | Yes — SIWS consent (via confirm), no broadcast |
| Cancel / pause | No |
| Downgrade (lower or equal amount) | No |
Merchant tier change that lowers/keeps the amount (PATCH) | No |
| Upgrade / renew that raises the amount, room already on chain | Yes — SIWS proof (proof_required) |
| Upgrade / renew that raises the amount, no room on chain | Yes — owner-signed approve (approval_required → activate) |