Subscriptions
Recurring stablecoin billing. Complete the Prerequisites (API key and signing keys) first.
Create a subscription checkout
Subscriptions start with a single-use Subscription Checkout intent. The customer signs an on-chain authorization on the hosted page, and the first charge is atomic with the subscribe — there is no separate “activate” step.
const response = await fetch('https://checkout-api.exodus-int.com/subscription-checkouts', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
token_symbol: 'USDC', // settlement token the subscriber pays in
price: '9990000', // $9.99 USDC (6 decimals); omit price_currency = token-denominated
recommended_cap: '120000000', // suggested cap (the subscriber sets the on-chain cap when signing)
period_duration: 2592000, // 30 days in seconds
supported_chains: ['eip155:1', 'eip155:137'], // CAIP-2 ids the subscriber picks from at sign time
external_customer_id: 'cus_42',
success_url: 'https://yoursite.com/welcome',
cancel_url: 'https://yoursite.com/pricing',
metadata: { external_plan_ref: 'pro_monthly' },
}),
})
const intent = await response.json()
// Redirect your customer to intent.hosted_url
console.log(intent.hosted_url) // https://checkout.exodus-int.com/subscribe/schk_...cap_amount is the on-chain ceiling, set by the subscriber. They authorize the contract for
any amount up to cap_amount, so each cycle charge can vary (e.g. a FIAT-priced plan re-rated to
USDC) as long as it stays under the cap. Pass a generous recommended_cap up front to seed it;
raising the cap later is a subscriber-authorized action on the hosted page.
Redirect the customer
Send your customer to the hosted_url returned in the response:
// After receiving the hosted URL from your server
window.location.href = hostedUrlThe customer will:
- Connect their wallet (Exodus, Grateful, MetaMask, Phantom, etc.)
- Pick the chain to subscribe on (out of
supported_chains) - Sign the on-chain
subscribeAndCharge. The first charge clears in the same transaction - Be redirected to your
success_urlonce confirmed
Handle webhooks
Verify each event against your webhook secret (see Webhooks), then handle the subscription lifecycle:
import express from 'express'
import crypto from 'crypto'
const app = express()
// Use express.raw() so we can verify the signature over the unparsed body
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
// Constant-time compare; normalize the header to a string first
const received = Buffer.from(String(req.headers['x-signature'] ?? ''))
const expectedBuffer = Buffer.from(expectedSignature)
if (received.length !== expectedBuffer.length || !crypto.timingSafeEqual(received, expectedBuffer)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(req.body)
switch (event.type) {
case 'subscription_checkout.completed': {
// Customer subscribed AND the first charge cleared on-chain.
// Payload bundles the intent, the subscription, and the first charge.
const { object: intent, subscription, first_charge } = event.data
console.log('Subscription started:', subscription.id)
// Grant access, keyed on intent.external_customer_id ("cus_42")
break
}
case 'subscription.charged': {
const charge = event.data.object
if (charge.status === 'succeeded') {
console.log('Cycle charge succeeded:', charge.id)
} else {
// On a failed attempt, `failure_reason` is a typed contract error
// (e.g., "InsufficientBalance"). Drive your dunning flow from here.
console.log('Cycle charge failed:', charge.id, charge.failure_reason)
}
break
}
case 'subscription.cancelled': {
console.log('Subscription cancelled:', event.data.object.id)
// Revoke access
break
}
}
res.status(200).send('OK')
})
app.listen(3000)Charging subsequent cycles
Subscriptions don’t auto-charge on a schedule. Your scheduler decides when to call the API. For each cycle, sign a charge with your private key and POST it:
import { signCharge } from '@exodus/checkout-signer/evm'
import { signingKeysFromMnemonic } from '@exodus/checkout-signer'
const headers = { Authorization: `Bearer ${process.env.API_KEY}` }
const { privateKey } = signingKeysFromMnemonic(process.env.SIGNING_MNEMONIC).evm
// The subscription's on-chain id, from the subscription_checkout.completed webhook
const subscriptionId = '0x9f3a2b...'
// Read the latest nonce & on-chain ids from the subscription
const sub = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${subscriptionId}`, {
headers,
}).then((r) => r.json())
// Quote the per-cycle amount (fixed price for token plans, a live FX quote for FIAT plans)
const quote = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${sub.id}/charge-quote`, {
method: 'POST',
headers,
}).then((r) => r.json())
const signature = signCharge(
sub.id,
quote.amount,
sub.charge_nonce,
sub.subscription_manager_address,
sub.chain,
privateKey,
)
await fetch(`https://checkout-api.exodus-int.com/subscriptions/${sub.id}/charge`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'X-Signature': signature },
body: JSON.stringify({ amount: quote.amount }),
})Listen for the subscription.charged webhook to learn the on-chain outcome. Both succeeded and failed attempts fire it.
Beyond cycle charges, you can also run off-cycle charges (signChargeAdHoc), change the cycle
amount (signUpdateChargeAmount), and cancel as the merchant (signCancelSubscription). See
Signed Requests and the
Subscriptions reference.
Test your integration
- Use your test API key (
sk_test_...) - Create a test subscription checkout
- Complete the subscribe flow using a testnet wallet
- Verify your webhook receives
subscription_checkout.completed
Test mode transactions use testnet networks, so no real funds are transferred.
