Skip to Content

Charge a subscription

POST/subscriptions/{subscriptionId}/charge

Charge an active subscription. Sign the charge amount together with the subscription's current charge_nonce; Exodus submits the on-chain charge and pays the gas. Quote first: amount is the token amount the charge quote returned and price_lock_code is its code. Each charge is bounded by the per-call cap_amount and the per-cycle budget, so multiple charges may run within a period until the budget is exhausted. Succeeded charges are recorded in the charges ledger and dispatched via the subscription.charge_succeeded webhook.

Headers

HeaderDescriptionRequired
AuthorizationBearer token with your secret API keyyes

Path Parameters

NameTypeRequiredDescription
subscriptionIdstringyes

Request Body

FieldTypeRequiredDescription
amountstringyesThe amount to charge, in the settlement token's smallest units, identical to the amount signed. This is the amount the charge quote returned, verbatim. On a legacy token-denominated plan (no price_currency) it is any amount within cap_amount and the remaining per-window budget.
price_lock_codestring—The price_lock_code of the charge quote whose amount is being charged. Required on fiat-priced plans, which is every plan that can be created today; the schema keeps it optional only for legacy token-denominated plans. The quote is bound to this subscription and to amount, and a missing, mismatched, expired or already-used code is rejected. A code minted for another subscription is reported as not found. Ignored on a legacy token-denominated plan.
metadataobject—Flat map of merchant-defined references. Up to 50 keys; keys up to 40 characters; values are strings up to 500 characters (nested values are rejected).

Example Request

Request

const response = await fetch('https://checkout-api.exodus.com/subscriptions/<subscriptionId>/charge', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk_live_xxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "amount": "9990000",
    "price_lock_code": "refq_3f9a1c7d2e5b8064",
    "metadata": {
      "invoice_id": "inv_202605"
    }
  }),
});

Responses

StatusDescription
200The charge result — status is always succeeded on a 200 (failures throw error responses). Fetch the subscription for the updated budget and scheduling state.
400Validation error
401Missing or invalid API key, missing X-Signature header, or the X-Signature was not produced by the merchant signer registered for the subscription's chain ("Invalid merchant signature"). A present but wrong-size X-Signature is rejected as a 400 with param: X-Signature; it must be a 64-byte (Solana) or 65-byte (EVM) hex string.
403API key lacks the required scope
404Resource not found
409The subscription or the quote is in a state that refuses the charge. subscription_cancelled: the subscription is cancelled or cancelling. quote_expired: the price_lock_code passed its expires_at. quote_already_used: the price_lock_code was already reserved or consumed by another request. Re-quote and re-sign for either quote code.
422The charge cannot be processed as sent. Configuration and request-shape rejections carry no code (type: invalid_request): the message starts with price_currency_not_quotable:, fiat settlement not configured, price_currency differs from the settlement currency, price_lock_code missing on a fiat-priced plan, merchant signer not configured. Ceilings: amount_exceeds_cap, budget_exceeded, missing_budget_state. Quote binding: quote_amount_mismatch, quote_asset_mismatch, quote_not_found; re-quote and re-sign. Compliance screening (type: cannot_process): flagged, either because the subscriber failed screening or because the merchant's settlement address is under a compliance hold after an out-of-band rotation; the hold clears when the merchant rotates to a different address that passes screening. Revert decoded before submission: code is the contract error name (BudgetExceeded, ChargeAmountExceedsCap, ChargeAmountExceedsBudget, InsufficientBalance, InsufficientAllowance, SubscriptionNotActive, SubscriptionNotFound, InvalidSignature, InvalidNonce, SignatureExpired); the failed charge is recorded in the charges ledger. A failed charge consumes no charge_nonce and no budget, so the subscription stays chargeable; retry once the cause is resolved. Post-submission revert on EVM, and the Solana program errors the API recognises: OnChainRevert; the failed charge is recorded with its tx_hash. Any other chain error returns the 500 described below.
500Relayer wallet not configured on this environment (no code); the quote is released, so once the environment is fixed the same amount, price_lock_code and signature stay valid until expires_at. charge_persist_failed: the charge landed on-chain but its receipt could not be recorded; data carries subscription_id, charge_nonce and tx_hash. Do not resubmit: the charge appears once indexed, and metadata can be attached afterwards through the charge metadata endpoint. Any other unexpected failure inside the charge relay, including a chain error the API cannot decode, returns "Internal server error" with no code; the quote is not released on that path, so re-quote before retrying.
503type: service_unavailable. No code: the relayer is paused by operators or, on EVM, cannot pay gas right now, or the merchant's settlement address is awaiting a compliance re-screen after an out-of-band rotation, or the address the merchant's payment factory holds on-chain differs from the one Exodus has screened (message The settlement address on this chain differs from the one Exodus has screened; the relay refuses until the rotation monitor screens it or the merchant rotates that chain through the settlement endpoint), or the subscription manager points at a payment factory that is neither the screened one nor the target of a signer rotation in progress (message The subscription manager on this chain points at a payment factory Exodus has not screened; retry the signer rotation or rotate the manager back), or one of those on-chain reads failed (message Settlement address check is temporarily unavailable; retry later). The quote is released, so retry later with the same amount, price_lock_code and signature until expires_at. code: subscription_manager_paused: the merchant's subscription manager is paused on-chain; retrying will not succeed until the manager's pauser unpauses it. The pause may be Exodus-initiated as an incident response, so confirm the cause with support before unpausing.

Response body

FieldTypeRequiredDescription
subscription_idstringyes
charge_noncenumberyes
amountstringyes
tx_hashstringyes
statusenum: succeededyes
metadataobject | nullyes

Example Response

{
  "subscription_id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
  "charge_nonce": 3,
  "amount": "9990000",
  "tx_hash": "0x7e81c228fc4fcf0372a86ce6f8bbc457210a8b51fc30dee5557280e5153c9f06",
  "status": "succeeded",
  "metadata": {
    "invoice_id": "inv_202605"
  }
}

Error Response

Error (e.g. 400)

{
  "error": {
    "type": "validation_error",
    "message": "amount must be a positive number",
    "param": "amount"
  }
}

Quote first

Every plan created today is fiat-priced, so a charge is a two-call sequence. Fetch a charge quote for the plan’s price or for an ad-hoc fiat amount, sign the token amount it returns, then submit that same amount together with the quote’s price_lock_code. The quote page carries the full quote-and-sign example.

Do not sign the subscription’s price. That value is in price_currency’s minor units, not settlement-token units, so signing it produces an amount that is wrong by orders of magnitude.

⚠️

price_lock_code is listed as optional in the request schema because token-priced plans (price_currency: null) predate quoting and still charge without one. For a fiat-priced plan it is required, and omitting it is rejected with 422 price_lock_code is required for fiat-priced subscription charges.

Sign with the subscription’s current charge_nonce, read from GET /subscriptions/:id right before signing. Every succeeded charge increments it.

Rejections

The quote binds price_lock_code to this subscription, its token and chain, and the exact amount it returned. A charge that breaks the binding is rejected with one of the codes in the quote page’s quote errors on charge table; re-quote and re-sign after any of them. A charge that fails after the quote was accepted (a typed simulation failure or an on-chain revert) usually releases it, so a retry can resubmit the same amount, price_lock_code and signature until expires_at. If the retry is rejected with a quote error, re-quote and re-sign.

The subscriber’s ceilings are enforced before anything is relayed. amount_exceeds_cap returns cap_amount; budget_exceeded returns spent_this_period and budget. Both fire at quote time and again at charge time, since spend can accrue between the two calls. Neither ceiling can be raised by the merchant; see cap and budget semantics.

A charge the contract rejects at simulation, or that reverts on-chain, is persisted as a failed charge with a typed failure_reason and fires subscription.charge_failed; see failed charges. The API-side rejections above are not persisted.

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