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 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.
| 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 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_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 within the window’s remaining budget. |
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. 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.
| 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 /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.
chargeenforcesamount <= cap_amountper call. The API preflight rejects an over-cap charge withamount_exceeds_cap; one that races past the preflight reverts on-chain withChargeAmountExceedsCap. 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 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 merchant declares it as the intent’sbudget, the customer signs that exact value at subscribe, and can lower it anytime via the on-chainupdateBudget(observed as thesubscription.budget_updatedwebhook). 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.
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.
| Field | Description |
|---|---|
id | subc_... resource id, stable across redeliveries. |
subscription_id | The parent subscription’s on-chain id (bytes32 hex). |
subscriber | On-chain subscriber address (denormalized for filtering). |
amount | Charged amount in token’s smallest unit. For failed charges this is the attempted amount. |
fee | Network fee deducted, in token’s smallest unit. "0" on failed charges (no fee was collected). |
tx_hash | On-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). |
chain | CAIP-2 identifier (e.g. eip155:1). |
charge_nonce | Monotonic per-subscription counter. 0 for the first charge from subscribeAndCharge, incremented by each charge. |
charged_at | Block timestamp on success; API submission time on failure. |
block_number | Block number the charge was included in, as a string. "0" on failed charges (never mined). |
created_at | ISO 8601 timestamp of when the row was written to the ledger. |
status | succeeded or failed. |
kind | cycle — the only kind currently produced by any charge path; adhoc exists in the schema but is not currently emitted. |
failure_reason | Typed 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_reason | Cause |
|---|---|
InsufficientBalance | Customer’s wallet balance is below amount. |
InsufficientAllowance | Customer revoked the ERC-20 allowance to the SubscriptionManager. |
ChargeAmountExceedsCap | The charged amount exceeded cap_amount. |
BudgetExceeded | The charge would have pushed the window’s total past budget. |
SubscriptionNotActive | Subscription 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
- Retrieve a subscription
- List a merchant’s subscriptions
- Charge a subscription for the current period
- Quote the next charge for a subscription
- Cancel a subscription
- List a merchant’s subscription charges
- List a merchant’s deployed subscription managers
See also: Subscription Checkouts for the intent that creates a subscription.
