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) per cycle
budget: '300000000', // per-cycle ceiling on the total charged within one window
cap: '120000000', // 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',
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; the subscriber signs those exact values. 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
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. For each cycle, sign a charge with your CheckoutSigner and POST it:
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());
// Sign the amount you want to charge — the plan price here
const { signature } = signer.signCharge(sub, { amount: BigInt(sub.price) });
// 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: sub.price }),
});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.
