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. Every plan created today sets price_currency, and price is an integer in hundredths of that currency unit ("150000" is 1,500.00 ARS). Subscriptions predating fiat-only pricing can still carry price_currency: null, with price in the settlement token’s smallest units instead; they keep charging on those terms. 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. The merchant declares it as the intent’s cap in fiat (defaulting to budget when omitted); at subscribe time the quote converts it to the settlement token’s smallest units at the same rate as the charge amount, and the subscriber signs that converted figure. cap_amount is therefore always a token amount, fixed at subscribe and unaffected by later rate moves. 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 — its converted value is what the subscriber signs, and it cannot be raised afterwards. Headroom also absorbs adverse rate moves: the fiat price converts at the live rate on every charge, while cap_amount stays pinned at the rate that applied when the subscriber signed.
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’sbudgetin fiat, the quote converts it to token units at the same rate ascap_amount, the customer signs that converted figure 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.
