Skip to content
Zuuppa
Dashboard

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.com with your sk_ 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.
Warning

Availability

Subscriptions must be enabled on your deployment and your account must be live (not test). When disabled, subscription routes answer 503 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 active only 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#

Text
unit_base_units                = price_usd_cents × 10^(decimals-2)   // e.g. USDC has 6 decimals
total_authorization_base_units = unit_base_units × authorization_periods

The tier response returns both unit_base_units and total_authorization_base_units so you don't compute them yourself.

Required setup order (merchant)#

Text
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 management

Products#

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 + PathRequest bodyResponse
POST /subscription-productsname (req, ≤200), description? (≤2000), external_id? (≤255, idempotency key), metadata? (≤50 keys)201 product (200 if external_id already exists)
GET /subscription-productsquery: limit, cursor, status, search/q{ products[], next_cursor }
GET /subscription-products/:idproduct
PATCH /subscription-products/:idname?, description?, status?, metadata? (replaced wholesale). external_id is immutable.product
POST /subscription-products/:id/archiveproduct

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.

Tier object (render_tier)
id, product_idstring
Identity.
name, descriptionstring
Presentation.
price_usd_centsinteger
Per-period price in whole USD cents.
interval, interval_countstring, integer
Billing cadence, e.g. every 1 month.
authorization_periodsinteger
How many periods the wallet authorizes up front.
total_authorization_usd_cents, total_authorization_base_unitsinteger
The total authorization, in cents and base units.
mint, token_program, decimalsstring, integer
The settlement stablecoin.
unit_base_unitsinteger
One period's charge in base units.
payout_owner, payout_atastring
Where charges settle.
platform_fee_bps, platform_walletinteger, string
Platform fee configuration.
status, metadatastring, object
Lifecycle + opaque JSON.
archived_at, created_at, updated_attimestamp
Lifecycle.
Method + PathRequest bodyResponse
POST /subscription-products/:id/tiersname (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/tierslimit, cursor{ tiers[], next_cursor, product_id }
GET /subscription-tiers/:idtier
PATCH /subscription-tiers/:idmutable: name?, description?, status?, metadata?. Pricing fields are immutable.tier
POST /subscription-tiers/:id/archivetier

Interval ceilings: interval_count ≤ 365 (day), 52 (week), 12 (month), 1 (year). authorization_periods ≥ 1.

Note

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

Warning

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 + PathRequest bodyResponse
POST /subscription-domainshost (req, ≤253)201 domain
GET /subscription-domainslimit, cursor, active (bool){ domains[], next_cursor }
GET /subscription-domains/:iddomain
POST /subscription-domains/:id/archivedomain

Errors: invalid_host, invalid_redirect_url (+field), domain_not_found.

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 + PathRequest bodyResponse
POST /subscription-intentstier_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-intentslimit, cursor, status, tier_id{ intents[], next_cursor }
GET /subscription-intents/:idintent
POST /subscription-intents/:id/cancel200 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 an approveChecked transaction 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 and activate broadcasts 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) and confirm completes 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 + PathPurpose
GET /s/:tokenRender 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/retryRe-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.

Subscription object (render_subscription)
id, tier_id, statusstring
Identity + lifecycle state.
subscriber_wallet, mintstring
The paying wallet and settlement asset.
unit_amount, periodsinteger
Per-period charge and authorized period count.
authorized_amount, spent_amount, remaining_amountinteger
The allowance drawdown, in base units.
delegation_epoch, period_indexinteger
On-chain delegation epoch and current period.
current_period_start, current_period_endtimestamp
The active billing window.
cancel_at_period_end, manageable, billing_stoppedboolean
Lifecycle flags.
next_charge_attimestamp
When the next pull is scheduled.
approve_signaturestring
The on-chain approve that authorized the allowance.
metadata, created_at, updated_at, activated_at, canceled_at, ended_atobject, timestamp
Opaque JSON + lifecycle timestamps.
Method + PathRequest bodyResponse
GET /subscriptionslimit, cursor, status, tier_id, subscriber_wallet{ subscriptions[], next_cursor }
GET /subscriptions/:id{ subscription, delegation }
PATCH /subscriptions/:idtier_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/cancelat_period_end? (default true)200 { subscription }. Stops billing; no chain write.
POST /subscriptions/:id/resume200 { subscription }
POST /subscriptions/:id/charge-now202 { subscription }. Resumes charging a stopped subscription; never bills early.
POST /subscriptions/:id/portal-link201 { subscription_id, portal_url, expires_at, origin: "merchant", may_increase: false }
GET /subscriptions/:id/chargeslimit, 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.

Note

Merchant links cannot raise

A portal link minted by the merchant has may_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-signed approveChecked for the new total is handed over; POST /m/:token/activate broadcasts it.
Method + PathRequest bodyPurpose
GET /m/:tokenRender 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/cancelat_period_end? (default true)Cancel (stops billing; no chain write).
POST /m/:token/resumeResume.
POST /m/:token/change-tiertier_id (req), nonce? + signature? (both or neither)Change tier. Downgrade applies inline (changed); upgrade returns proof_required or approval_required.
POST /m/:token/renewnonce? + signature?Re-authorize / renew; needs a signature to raise the allowance.
POST /m/:token/activateapproval_id, transactionBroadcast 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.
Note

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#

ActionWallet signature?
Initial checkout when no allowance existsYes — owner-signed approve (broadcast via activate)
Initial checkout when an allowance already covers itYes — SIWS consent (via confirm), no broadcast
Cancel / pauseNo
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 chainYes — SIWS proof (proof_required)
Upgrade / renew that raises the amount, no room on chainYes — owner-signed approve (approval_requiredactivate)

© 2026 Zuuppa. All rights reserved.