Charge a subscription
POST/subscriptions/{subscriptionId}/chargeCharge 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
| Header | Description | Required |
|---|---|---|
| Authorization | Bearer token with your secret API key | yes |
Path Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| subscriptionId | string | yes |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| amount | string | yes | The 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_code | string | — | 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. |
| metadata | object | — | 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
| Status | Description |
|---|---|
| 200 | The charge result — status is always succeeded on a 200 (failures throw error responses). Fetch the subscription for the updated budget and scheduling state. |
| 400 | Validation error |
| 401 | Missing 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. |
| 403 | API key lacks the required scope |
| 404 | Resource not found |
| 409 | The 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. |
| 422 | The 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. |
| 500 | Relayer 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. |
| 503 | type: 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
| Field | Type | Required | Description |
|---|---|---|---|
| subscription_id | string | yes | |
| charge_nonce | number | yes | |
| amount | string | yes | |
| tx_hash | string | yes | |
| status | enum: succeeded | yes | |
| metadata | object | null | yes |
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.
