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 amount is charge_amount, set on the originating subscription checkout in the settlement token’s smallest units. It is stored on the subscription and charged each cycle: the merchant signs that stored amount with signCharge and submits the charge (there is no quote step).

Subscriptions are token-denominated today. FIAT-denominated pricing — where the cycle amount is converted from a fiat price to the settlement token at each cycle’s live rate, bounded by cap_amount — will soon be enabled in production.

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 (with optional proration) is subscriber-authorized — the subscriber consents on the hosted page (modifySubscription). A merchant key can only charge within the subscriber’s cap and cancel; it can never raise the cap or pull off-cycle.

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 once next_charge_at has elapsed.
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. The Exodus scheduler skips charges while paused is true.

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 /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 set by the customer when they subscribe and cannot be raised by the merchant — only the subscriber can change it, via the hosted upgrade flow (modifySubscription) or a standalone cap change.

  • charge enforces amount <= cap_amount per call. A charge whose amount exceeds the cap reverts with ChargeAmountExceedsCap until the subscriber raises the cap.
  • The cap is the subscriber’s security dial: a price increase past it reverts charges rather than silently pulling more.

A merchant who wants headroom for future increases should pass a generous recommended_cap (a multiple of charge_amount) at intent creation — the customer sets the actual cap_amount when they subscribe, seeded by that suggestion.

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 customer sets it at subscribe (seeded by the intent’s recommended_budget) and can lower it anytime via the hosted updateBudget flow. 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.

Failed Charges

The Exodus API persists every charge that reached the chain — succeeded or receipt-reverted — in the Subscription Charges ledger. Pre-flight reverts (the contract rejected the simulation before submission) 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.
ChargeAmountExceedsCapAttempted to charge more than cap_amount.
PeriodNotElapsedTried to call charge before next_charge_at elapsed.
SubscriptionNotActiveSubscription is no longer active on-chain.

Succeeded charges fire the subscription.charge_succeeded webhook; failed charges that reached the chain 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, and Subscription Charges for the per-charge ledger.

Last updated on

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