Skip to Content
CheckoutQuickstartSubscriptions

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.

server.js
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:

client.js
// After receiving the checkout URL from your server
window.location.href = checkoutUrl;

The customer will:

  1. Connect their wallet (Exodus, MetaMask, Phantom, etc.)
  2. Pick the chain to subscribe on (out of supported_chains)
  3. Sign the on-chain subscribeAndCharge. The first charge clears in the same transaction
  4. Be redirected to your success_url once confirmed

Handle webhooks

Verify each event against your webhook secret (see Webhooks), then handle the subscription lifecycle:

webhooks.js
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:

charge.js
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

  1. Use your test API key (sk_test_...)
  2. Create a test subscription checkout
  3. Complete the subscribe flow using a testnet wallet
  4. Verify your webhook receives subscription_checkout.completed

Test mode transactions use testnet networks, so no real funds are transferred.

Next steps

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