Charge a Subscription
POST/subscriptions/:id/chargeDescription
Submit a recurring cycle charge against an active subscription. The per-cycle amount is dynamic — nothing is stored on-chain — so the merchant first quotes the amount, signs it, then submits it: Exodus submits the on-chain SubscriptionManager.charge() transaction and pays gas.
The signer binds the amount and the current charge_nonce into the EIP-191 digest, and the request body carries that same amount. A successful call advances the on-chain period clock; last_charged_at and next_charge_at are updated by the indexer once the transaction confirms.
Succeeded charges are persisted in the Subscription Charges
ledger and dispatched via the subscription.charged webhook. Contract reverts return a 4xx
response (see Errors) and do not fire the webhook; receipts that reverted on-chain
are still persisted in the ledger with status: "failed" and a typed failure_reason. Pre-flight
reverts (rejected before submission) are not persisted.
Headers
| Header | Description | Required |
|---|---|---|
| Authorization | Bearer token with your API key | yes |
| Content-Type | application/json | yes |
| X-Signature | Signature from `signCharge(...)` | yes |
Path Parameters
| Name | Type | Description | Required |
|---|---|---|---|
| id | string | The on-chain subscription ID (bytes32 hex, e.g. `0x9f3a...`). Obtain it from `GET /subscriptions`, the `subscription.created` webhook, or the `subscription_checkout.completed` webhook. | yes |
Step 1 — Quote the cycle amount
POST/subscriptions/:id/charge-quoteFetch the settlement-token amount to charge for the current cycle:
- Token-denominated plans return the fixed
price, with no expiry. - FIAT-denominated plans return a short-lived exact-out quote — the FIAT
priceconverted to the settlement token at the live rate, rounded up — with anexpires_at. Sign and submit before it expires, otherwise re-quote.
{
"amount": "9990000",
"expires_at": "2026-05-19T12:02:48Z"
}Step 2 — Request Body
POST /subscriptions/:id/charge carries the quoted, signed amount:
| Name | Type | Description | Required |
|---|---|---|---|
| amount | string | Settlement-token amount for this cycle, in smallest units. MUST equal the value from charge-quote and the value passed to signCharge. | yes |
Charging a cycle
import { signCharge } from '@exodus/checkout-signer/evm'
import { signingKeysFromMnemonic } from '@exodus/checkout-signer'
const id = '0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a'
const headers = { Authorization: `Bearer ${process.env.API_KEY}` }
const { privateKey } = signingKeysFromMnemonic(process.env.SIGNING_MNEMONIC).evm
const sub = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${id}`, { headers }).then((r) =>
r.json(),
)
// 1. Quote the current cycle amount
const quote = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${id}/charge-quote`, {
method: 'POST',
headers,
}).then((r) => r.json())
// 2. Sign the exact quoted amount
const signature = signCharge(
sub.id,
quote.amount,
sub.charge_nonce,
sub.subscription_manager_address,
sub.chain,
privateKey,
)
// 3. Submit the amount + signature within the quote window
const response = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${id}/charge`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: JSON.stringify({ amount: quote.amount }),
})
const charge = await response.json()
console.log(charge.subscription_id, charge.status, charge.tx_hash)Response
{
"subscription_id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"charge_nonce": 3,
"amount": "9990000",
"tx_hash": "0xabc...",
"status": "succeeded"
}The full per-charge ledger row — including subscriber, chain, fee, charged_at, and failure_reason on failures — is written to the Subscription Charges collection and dispatched via the subscription.charged webhook.
Errors
The API rejects calls with a structured error response ({ error: { type, code?, message, data? } }). Errors fall into two categories.
API-side preflight rejections
Returned before any on-chain submission, so no gas is spent.
| Code | Meaning |
|---|---|
subscription_cancelled | Subscription is cancelled or cancelling. |
period_not_elapsed | next_charge_at > now. Response data includes next_charge_at. |
quote_expired | The signed amount no longer matches a valid quote (the FIAT quote window elapsed). Re-quote and re-sign. |
Signature failures return error.type: "authentication_error" (no code). Malformed X-Signature headers return error.type: "validation_error".
Contract reverts
If the on-chain charge() call reverts (either pre-flight simulation or after the receipt), the API returns a 400 with error.code set to the decoded revert name. Subscriber-fault reverts (InsufficientBalance, InsufficientAllowance) also flip subscription.paused = true so the scheduler skips further attempts.
code | Cause |
|---|---|
InsufficientBalance | Subscriber’s wallet balance is below amount. |
InsufficientAllowance | Subscriber revoked the ERC-20 allowance. |
ChargeAmountExceedsCap | amount > cap_amount. |
PeriodNotElapsed | On-chain block time was earlier than next_charge_at (race with preflight). |
SubscriptionNotActive | Subscription is no longer active on-chain. |
OnChainRevert | Receipt reverted with an unrecognized code. The failed attempt is persisted to the ledger row. |
Read-your-writes guarantee. When the subscription.charged webhook fires, the corresponding
SubscriptionCharge row is durable. An immediate GET /charges with no cursor or with starting_after
pointing to a row older than the new charge is guaranteed to include the new charge.
