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({
price: '150000', // 1,500.00 ARS per cycle — fiat amounts are integers in hundredths of the currency unit
price_currency: 'ARS', // the plan's pricing currency; the subscriber picks the token at subscribe time
budget: '600000', // 6,000.00 ARS ceiling on the total charged within one window (defaults to 10x price)
cap: '300000', // 3,000.00 ARS per-charge maximum (defaults to budget when omitted)
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',
return_url: 'https://yoursite.com/plans',
metadata: { external_plan_ref: 'pro_monthly' },
}),
});
const intent = await response.json();
// Redirect your customer to intent.checkout_url
console.log(intent.checkout_url); // https://checkout.exodus-int.com/subscribe/schk_...You declare the plan limits in fiat; the subscriber signs them converted to their chosen
settlement token at one quote rate. price is the recurring per-cycle amount, budget bounds
the total charged within one billing window (so metered plans can charge more than once per
window), and cap bounds a single charge. Declare a generous cap and budget up front for
future price headroom — after subscribing, only the subscriber can raise them (on-chain
updateCap / updateBudget), never the merchant.
Redirect the customer
Send your customer to the checkout_url returned in the response:
// After receiving the checkout URL from your server
window.location.href = checkoutUrl;The customer will:
- Connect their wallet (Exodus, 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
return_url drives the persistent header link (falling back to cancel_url) shown on every screen, plus a secondary link alongside the success_url CTA on the completed screen. Purely navigational, no side effects.
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.charge_succeeded': {
// data.object is the subscription enriched with charge fields (amount, fee, tx_hash, ...).
const charge = event.data.object;
console.log('Cycle charge succeeded:', charge.id, charge.amount);
break;
}
case 'subscription.charge_failed': {
// `failure_reason` is a typed contract error (e.g. "InsufficientBalance").
// `id` is the subscription id. Drive your dunning flow from here.
const charge = event.data.object;
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. Because the plan is priced in fiat, each cycle starts by converting that price to a token amount with POST /subscriptions/:id/charge-quote, then signs and submits the quoted amount. Never sign sub.price directly: it’s in fiat minor units, not token units.
import { CheckoutSigner } from '@exodus/checkout-signer';
const headers = { Authorization: `Bearer ${process.env.API_KEY}` };
const signer = new CheckoutSigner();
// The subscription's on-chain id, from the subscription_checkout.completed webhook
const subscriptionId = '0x9f3a2b...';
// Read the subscription
const sub = await fetch(`https://checkout-api.exodus-int.com/subscriptions/${subscriptionId}`, {
headers,
}).then((r) => r.json());
// Convert the fiat price to a token amount at the live rate
const quote = await fetch(
`https://checkout-api.exodus-int.com/subscriptions/${sub.id}/charge-quote`,
{ method: 'POST', headers },
).then((r) => r.json());
// Sign the quoted token amount, never sub.price (that one is in fiat minor units)
const { signature } = signer.signCharge(sub, { amount: BigInt(quote.amount) });
// Submit the same amount in the body — the signature authorizes that exact amount
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, price_lock_code: quote.price_lock_code }),
});Quotes are short-lived and carry their own expires_at, so sign and submit right after fetching one rather than caching it between cycles.
Listen for the subscription.charge_succeeded and subscription.charge_failed webhooks to learn the on-chain outcome.
Beyond cycle charges, you can cancel as the merchant with signer.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.
