Skip to Content

Charge a Subscription

POST/subscriptions/:id/charge

Description

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

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

Path Parameters

NameTypeDescriptionRequired
idstringThe subscription ID (e.g. `sub_abc123def456`).yes

Body Parameters

NameTypeDescriptionRequired
charge_amountstringAmount to charge in token's smallest unit. MUST equal the value passed to `signCharge`. MUST be `<= cap_amount`.yes
charge_noncenumberPre-increment `charge_nonce` from the latest `GET /subscriptions/:id` read. MUST equal the value passed to `signCharge`.yes

Producing the Signature

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

CHARGE 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)

CHARGE 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.

CodeMeaning
subscription_cancelledSubscription is cancelled or cancelling with the honor flag honored.
period_not_elapsedSubscription.next_charge_at > now. Wait until eligible.
invalid_signatureSignature does not recover to your registered signing address.
nonce_mismatchcharge_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_reasonCause
InsufficientBalanceSubscriber’s wallet balance is below charge_amount.
InsufficientAllowanceSubscriber revoked the ERC-20 allowance.
ChargeAmountExceedsCapcharge_amount > cap_amount.
PeriodNotElapsedOn-chain block time was earlier than next_charge_at (race with preflight).
SubscriptionNotFoundThe 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.

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