Skip to Content

Charge a Subscription

POST/subscriptions/:id/charge

Description

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

HeaderDescriptionRequired
AuthorizationBearer token with your API keyyes
Content-Typeapplication/jsonyes
X-SignatureSignature from `signCharge(...)`yes

Path Parameters

NameTypeDescriptionRequired
idstringThe 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-quote

Fetch 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 price converted to the settlement token at the live rate, rounded up — with an expires_at. Sign and submit before it expires, otherwise re-quote.
CHARGE QUOTE
{
  "amount": "9990000",
  "expires_at": "2026-05-19T12:02:48Z"
}

Step 2 — Request Body

POST /subscriptions/:id/charge carries the quoted, signed amount:

NameTypeDescriptionRequired
amountstringSettlement-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

charge-subscription.js
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

SUCCESSFUL CHARGE
{
  "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.

CodeMeaning
subscription_cancelledSubscription is cancelled or cancelling.
period_not_elapsednext_charge_at > now. Response data includes next_charge_at.
quote_expiredThe 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.

codeCause
InsufficientBalanceSubscriber’s wallet balance is below amount.
InsufficientAllowanceSubscriber revoked the ERC-20 allowance.
ChargeAmountExceedsCapamount > cap_amount.
PeriodNotElapsedOn-chain block time was earlier than next_charge_at (race with preflight).
SubscriptionNotActiveSubscription is no longer active on-chain.
OnChainRevertReceipt 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.

Start building

XO

Request Demo

Schedule a call with our team

Select a product
Arrow right

Start building
Grateful

Contact Us

We're here to help