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:
- Call
POST /subscription-checkoutsto create an intent. - Redirect the customer to the returned
checkout_url. - The customer signs
subscribeAndChargefrom their wallet. - The indexer materializes the
Subscriptionand dispatches thesubscription.createdandsubscription.charge_succeededwebhooks.
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.
| Action | Signer helper | Endpoint |
|---|---|---|
| Charge a subscriber for a cycle | signCharge | POST /subscriptions/:id/charge |
| Cancel a subscription | signCancelSubscription | POST /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_idThe 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
| Status | Description |
|---|---|
active | Subscription is live. Eligible for charge once next_charge_at has elapsed. |
cancelling | Cancel transaction has been submitted on-chain and confirmed. No further charges are eligible. Transitions to cancelled once the indexer ingests the SubscriptionCancelled event. |
cancelled | Subscription 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.
| Trigger | Effect |
|---|---|
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-chain | paused = 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.
chargeenforcesamount <= cap_amountper call. A charge whose amount exceeds the cap reverts withChargeAmountExceedsCapuntil 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 tolast_charged_at— re-anchoring to the merchant-movable last-charge time would let a merchant collapse two windows and pull2 x budgeton demand. cap_amountbounds each individualcharge;budgetbounds the sum of charges within a window. Both ceilings are enforced: a charge reverts if its amount exceedscap_amount, or if it would pushspent_this_periodpastbudget.remaining_budget = budget - spent_this_period.spent_this_periodresets to0at each window boundary.budgetis subscriber-sovereign: the customer sets it at subscribe (seeded by the intent’srecommended_budget) and can lower it anytime via the hostedupdateBudgetflow. The merchant can never raise it — same security dial ascap_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_reason | Cause |
|---|---|
InsufficientBalance | Customer’s wallet balance is below amount. |
InsufficientAllowance | Customer revoked the ERC-20 allowance to the SubscriptionManager. |
ChargeAmountExceedsCap | Attempted to charge more than cap_amount. |
PeriodNotElapsed | Tried to call charge before next_charge_at elapsed. |
SubscriptionNotActive | Subscription 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
- Get Subscription — Retrieve subscription details
- List Subscriptions — List all subscriptions
- Charge a Subscription — Sign the stored
charge_amountand submit the charge - Cancel Subscription — Cancel a subscription as the merchant
See also: Subscription Checkouts for the intent that creates a subscription, and Subscription Charges for the per-charge ledger.
