Charge a Subscription
POST/subscriptions/:id/chargeDescription
Submit a scheduled recurring charge against an active subscription. The merchant produces an EIP-191 signature with signCharge from @exodus/checkout-signer and posts it to the API; Exodus submits the on-chain SubscriptionManager.charge() transaction and pays gas.
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. The charge_nonce advances by 1 regardless of success or failure.
Both succeeded and failed charges are persisted in the Subscription Charges
ledger and emit a subscription.charged webhook (with status: "succeeded" or "failed" plus a typed
failure_reason on failure).
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 subscription ID (e.g. `sub_abc123def456`). | yes |
Body Parameters
| Name | Type | Description | Required |
|---|---|---|---|
| charge_amount | string | Amount to charge in token's smallest unit. MUST equal the value passed to `signCharge`. MUST be `<= cap_amount`. | yes |
| charge_nonce | number | Pre-increment `charge_nonce` from the latest `GET /subscriptions/:id` read. MUST equal the value passed to `signCharge`. | yes |
Producing the Signature
import { signCharge } from '@exodus/checkout-signer'
const sub = await fetch('https://checkout.exodus.com/subscriptions/sub_abc123def456', {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
}).then((r) => r.json())
const chargeAmount = sub.charge_amount
const signature = signCharge(
sub.onchain_id,
chargeAmount,
sub.charge_nonce,
sub.subscription_manager_address,
sub.chain,
process.env.SIGNING_PRIVATE_KEY,
)
const response = await fetch(`https://checkout.exodus.com/subscriptions/${sub.id}/charge`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: JSON.stringify({
charge_amount: chargeAmount,
charge_nonce: sub.charge_nonce,
}),
})
const charge = await response.json()
console.log(charge.id, charge.status, charge.tx_hash)Response (succeeded)
{
"object": "subscription_charge",
"id": "subc_7890abcdef123456",
"subscription_id": "sub_abc123def456",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": "0",
"tx_hash": "0xabc...",
"chain": "eip155:1",
"charge_nonce": 3,
"charged_at": "2026-05-19T12:02:18Z",
"status": "succeeded",
"kind": "cycle",
"failure_reason": null
}Response (failed)
{
"object": "subscription_charge",
"id": "subc_failed7890abcdef",
"subscription_id": "sub_abc123def456",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": null,
"tx_hash": "0xdef...",
"chain": "eip155:1",
"charge_nonce": 4,
"charged_at": "2026-06-19T12:00:00Z",
"status": "failed",
"kind": "cycle",
"failure_reason": "InsufficientBalance"
}Errors
The API rejects calls with a structured error response ({ error: { 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 with the honor flag honored. |
period_not_elapsed | Subscription.next_charge_at > now. Wait until eligible. |
invalid_signature | Signature does not recover to your registered signing address. |
nonce_mismatch | charge_nonce in the body or signature is stale. Re-fetch and re-sign. |
Contract reverts (decoded from receipt)
The API submits the transaction and decodes typed reverts from the receipt. The response includes a failure_reason matching the typed contract error. These are also persisted as SubscriptionCharge rows with status: "failed".
failure_reason | Cause |
|---|---|
InsufficientBalance | Subscriber’s wallet balance is below charge_amount. |
InsufficientAllowance | Subscriber revoked the ERC-20 allowance. |
ChargeAmountExceedsCap | charge_amount > cap_amount. |
PeriodNotElapsed | On-chain block time was earlier than next_charge_at (race with preflight). |
SubscriptionNotFound | The on-chain subscription doesn’t exist (recovered/migrated). |
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.
