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); 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:

client.js
// After receiving the hosted URL from your server
window.location.href = hostedUrl

The customer will:

  1. Connect their wallet (Exodus, Grateful, 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.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:

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

  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