Skip to Content
CheckoutAPI ReferenceSubscriptionsOverview

Subscriptions

A Subscription is the on-chain record of an active recurring stablecoin agreement. It is materialized by the indexer when a customer completes a subscription checkout and the first subscribeAndCharge transaction confirms on-chain.

Creation

Subscriptions are not created via a direct API call. To start one:

  1. Call POST /subscription-checkouts to create an intent.
  2. Redirect the customer to the returned checkout_url.
  3. The customer signs subscribeAndCharge from their wallet.
  4. The indexer materializes the Subscription and dispatches the subscription.created and subscription.charge_succeeded webhooks.

Pricing

A plan’s recurring price is price, declared on the originating subscription checkout — in the settlement token’s smallest units for token-priced plans (price_currency: null), or in fiat minor units when price_currency is set. The per-charge amount is dynamic: at each charge the merchant chooses the amount, signs it with signCharge, and submits it in the request body — bounded by cap_amount per call and by the per-window budget. Simple plans pass the subscription’s price; metered billing may charge several times within one window.

Fiat-priced plans convert the fiat price to a token amount at the live rate via POST /subscriptions/:id/charge-quote, then pass the quoted amount and quote code to charge. The quote mechanism depends on settlement type: a fiat-settled business gets a settlement-provider-minted price lock (price_lock_code), redeemed at ramp-off after the charge; a crypto-settled business gets an Exodus-minted reference quote (refq_ code, 120s TTL) that only proves the charged amount matched what the subscriber was shown — funds stay in stablecoin, nothing converts, no settlement configuration needed.

Merchant-Driven Actions

After creation, the merchant drives all subsequent state changes via the API. Each action is a signed request: the merchant uses @exodus/checkout-signer to produce a signature, posts it to the Exodus API, and Exodus submits the on-chain transaction.

ActionSigner helperEndpoint
Charge a subscriber for a cyclesignChargePOST /subscriptions/:id/charge
Cancel a subscriptionsignCancelSubscriptionPOST /subscriptions/:id/cancel
🔐

All merchant actions require a signing key. See Signed Requests for setup, and the per-action pages for the exact signer call and request body.

Raising the cap is subscriber-authorized — the subscriber calls updateCap on-chain (observed by the indexer as the subscription.cap_updated webhook). A merchant key can only charge within the subscriber’s cap and budget, and cancel; it can never raise either ceiling.

Customer-Initiated Cancel

Customers cancel their own subscriptions from a hosted page:

https://checkout.exodus-int.com/cancel/:subscription_id

The page reads the on-chain subscription_id, requires the customer’s subscribing wallet to be connected, and lets them sign cancel(subscriptionId) directly. Surface this URL in your customer account UI for self-service cancellation. The customer pays the on-chain gas.

If the customer has lost access to their subscribing wallet, the page surfaces a “Lost wallet?” link. Exodus does not perform key recovery — recovery routes back to the merchant via the intent’s cancel_url, and the merchant cancels via POST /subscriptions/:id/cancel.

Statuses

StatusDescription
activeSubscription is live. Eligible for charge within the window’s remaining budget.
cancellingCancel transaction has been submitted on-chain and confirmed. No further charges are eligible. Transitions to cancelled once the indexer ingests the SubscriptionCancelled event.
cancelledSubscription cancelled on-chain. No further charges possible.

Paused (in-band sub-state of active)

Subscriptions also carry a separate paused: boolean field — an in-band brake on the active state, not a status enum value. Recurring charges are merchant-driven (POST /subscriptions/:id/charge); Exodus does not auto-charge. paused is a dunning signal for your scheduler to skip or defer charges until the subscriber’s payment issue is resolved.

TriggerEffect
charge reverts pre-flight with InsufficientBalance or InsufficientAllowance (subscriber fault)paused = true
charge receipt reverts on-chain (any code)paused = true
Next successful charge confirms on-chainpaused = false

paused is visible on every GET /subscriptions/:id and GET /merchants/:merchantId/subscriptions response. Surface it in your dunning UI alongside the failed-charge ledger.

Cap Semantics

Every subscription has a cap_amount — the on-chain maximum the contract will allow per call. It is declared by the merchant as the intent’s cap (defaulting to budget when omitted), and the subscriber signs those exact values when they subscribe. After that it cannot be raised by the merchant — only the subscriber can change it, via the on-chain updateCap.

  • charge enforces amount <= cap_amount per call. The API preflight rejects an over-cap charge with amount_exceeds_cap; one that races past the preflight reverts on-chain with ChargeAmountExceedsCap. Either way nothing is pulled until the subscriber raises the cap.
  • The cap is the subscriber’s security dial: a per-charge amount past it is rejected rather than silently pulling more.

A merchant who wants headroom for future price increases should declare a generous cap (a multiple of price) at intent creation — the subscriber signs that exact value when they subscribe.

Budget Semantics

Beyond the per-call cap_amount, every subscription carries a per-cycle budget — the maximum a merchant may charge in total within one billing window. This is what lets one subscription support multiple charges per cycle (metered usage, threshold reloads, mid-cycle add-ons) instead of a single fixed pull. GET /subscriptions/:id surfaces the ceiling and its running totals as budget, spent_this_period, and remaining_budget.

  • The window is tumbling, anchored to the subscription’s immutable on-chain start (startedAt): currentWindow = floor((now - startedAt) / period_duration) (integer division — same as the contract). It is never anchored to last_charged_at — re-anchoring to the merchant-movable last-charge time would let a merchant collapse two windows and pull 2 x budget on demand.
  • cap_amount bounds each individual charge; budget bounds the sum of charges within a window. Both ceilings are enforced: a charge reverts if its amount exceeds cap_amount, or if it would push spent_this_period past budget.
  • remaining_budget = budget - spent_this_period. spent_this_period resets to 0 at each window boundary.
  • budget is subscriber-sovereign: the merchant declares it as the intent’s budget, the customer signs that exact value at subscribe, and can lower it anytime via the on-chain updateBudget (observed as the subscription.budget_updated webhook). The merchant can never raise it — same security dial as cap_amount.

The residual exposure is a single-boundary straddle: at most 2 x budget across one real window boundary, once per period_duration — irreducible for any tumbling window. A compromised merchant key is bounded by budget x periods-until-cancel, with cap_amount bounding any single pull and subscriber cancel / updateBudget as the escape.

Subscription Charges

A SubscriptionCharge is the per-attempt record of a charge against a subscription. Both succeeded and failed charges are persisted — failed attempts include a typed failure_reason for dunning, analytics, and CSV exports.

FieldDescription
idsubc_... resource id, stable across redeliveries.
subscription_idThe parent subscription’s on-chain id (bytes32 hex).
subscriberOn-chain subscriber address (denormalized for filtering).
amountCharged amount in token’s smallest unit. For failed charges this is the attempted amount.
feeNetwork fee deducted, in token’s smallest unit. "0" on failed charges (no fee was collected).
tx_hashOn-chain transaction hash. null when the transaction was never broadcast (the contract rejected the charge at simulation — the failed attempt is still persisted with its typed failure_reason).
chainCAIP-2 identifier (e.g. eip155:1).
charge_nonceMonotonic per-subscription counter. 0 for the first charge from subscribeAndCharge, incremented by each charge.
charged_atBlock timestamp on success; API submission time on failure.
block_numberBlock number the charge was included in, as a string. "0" on failed charges (never mined).
created_atISO 8601 timestamp of when the row was written to the ledger.
statussucceeded or failed.
kindcycle — the only kind currently produced by any charge path; adhoc exists in the schema but is not currently emitted.
failure_reasonTyped contract error name on failure (e.g. InsufficientBalance, ChargeAmountExceedsCap, BudgetExceeded). null on success.

The same charge can be written by two paths — the API write path (POST /subscriptions/:id/charge persists the receipt synchronously) and the indexer backup path (the indexer ingests the on-chain SubscriptionCharged event). Rows are deduplicated by tx_hash. After receiving a subscription.charge_succeeded or subscription.charge_failed webhook, an immediate GET /merchants/:id/charges is guaranteed to include the new row.

API-side preflight rejections are NOT persisted as charges. When the API rejects a call with subscription_cancelled, amount_exceeds_cap, budget_exceeded, missing_budget_state, or a signature failure, no on-chain transaction is submitted and no SubscriptionCharge row is written. Track these in your scheduler logs.

Failed Charges

The Exodus API persists every charge that reached the chain boundary — succeeded, receipt-reverted, or rejected by the contract simulation (persisted with tx_hash: null) — in the subscription charges ledger above. API-side preflight rejections (subscription_cancelled, amount_exceeds_cap, budget_exceeded, signature failures) return a 4xx response and are not persisted. Common typed contract errors:

failure_reasonCause
InsufficientBalanceCustomer’s wallet balance is below amount.
InsufficientAllowanceCustomer revoked the ERC-20 allowance to the SubscriptionManager.
ChargeAmountExceedsCapThe charged amount exceeded cap_amount.
BudgetExceededThe charge would have pushed the window’s total past budget.
SubscriptionNotActiveSubscription is no longer active on-chain.

Succeeded charges fire the subscription.charge_succeeded webhook; failed charge attempts — simulation rejections included — fire subscription.charge_failed. Failed attempts are also visible via GET /merchants/:id/charges with status=failed for dunning and analytics.

Available Endpoints

See also: Subscription Checkouts for the intent that creates a subscription.

Start building

XO

Request Demo

Schedule a call with our team

Select a product
Arrow right

Start building
Grateful

Contact Us

We're here to help