Skip to main content
BillingSubscriptions

Subscriptions.

OpenSettle handles the full recurring-billing lifecycle: renewal, invoicing, dunning, refunds, and self-service cancellation. Invoice-and-pay works on every wallet and chain; ERC-20 allowance autopay is live on Base, Polygon, and Arbitrum (EOA wallets approve a cap once, renewals auto-pull). ERC-4337 smart-wallet session keys are on the roadmap.

Invoice-and-pay (live today)

The renewal-worker emits a renewal invoice ahead of each billing period. The customer receives an email with a hosted-invoice link, signs the transfer from their wallet, and the subscription renews when the chain-reader observes the deposit. Works on every wallet on every supported chain.

create-subscription.ts
const sub = await os.subscriptions.create({
  customerId: "cus_9fX0a2E1",
  priceId: "price_9fX0a2E1",
  chain: "base",
  token: "USDC",
  autopay: "manual",            // "manual" or "allowance" (live on Base/Polygon/Arbitrum); smart-wallet is roadmap
});
create_subscription.py
sub = client.subscriptions.create(
    customer_id="cus_9fX0a2E1",
    price_id="price_9fX0a2E1",
    chain="base",
    token="USDC",
    autopay="manual",
)
create_subscription.go
sub, err := client.Subscriptions.Create(ctx, &opensettle.CreateSubscriptionRequest{
  CustomerID: "cus_9fX0a2E1",
  PriceID:    "price_9fX0a2E1",
  Chain:      "base",
  Token:      "USDC",
  Autopay:    "manual",
})
create_subscription.rs
let sub = client
    .subscriptions()
    .create(CreateSubscriptionRequest {
        customer_id: "cus_9fX0a2E1".into(),
        price_id: "price_9fX0a2E1".into(),
        chain: Some("base".into()),
        token: Some("USDC".into()),
        autopay: Some("manual".into()),
        ..Default::default()
    })
    .await?;

Attributing the first subscription

When a subscription is born from a checkout, the subscription.created event carries top-level checkoutId and paymentId, and any merchant metadata you set on the originating checkout is snapshotted onto subscription.metadata (minus reserved internal keys like source and checkoutId). That gives you the link back to the visit that converted without a second lookup. For subscriptions you create directly through the API, set metadata on the create call to attribute them the same way.

Dunning cascade

When a renewal invoice goes unpaid, the renewal-worker emits a subscription.past_due webhook and runs the retry cascade: 1, 3, 7, and 14 days. If the customer hasn’t paid by day 14, the subscription is canceled and the customer is notified. Dunning behavior (cadence, copy, webhook fanout) will be configurable under Settings → Dunning once the dashboard surface ships.

Subscription lifecycle

  • trialing — created with a trial period; no charge yet.
  • active — current and paid.
  • past_due — renewal failed; dunning cascade engaged.
  • paused — merchant-initiated hold; renewal worker skips this row until you resume it.
  • canceled — ended after dunning exhausted (reason="dunning_exhausted") or after self-service cancellation. Terminal state.

Cancel: at period end vs immediately

cancel takes a mode. The default, mode: "at_period_end", keeps the subscription active until nextBillingDate — the customer keeps access through the period they already paid for, and the subscription simply doesn’t renew. It’s reversible: clear a pending at-period-end cancellation any time before the period ends with POST /subscriptions/{id}/uncancel — the same “Keep subscription” action exposed in the dashboard. mode: "immediately" ends it now and moves it to canceled straight away. An optional reason is recorded on the audit log either way.

cancel-subscription.ts
// Default: stays active until nextBillingDate, then stops renewing
await os.subscriptions.cancel(sub.id);

// Or end it right now
await os.subscriptions.cancel(sub.id, { mode: "immediately" });

ERC-20 allowance autopay (live on Base, Polygon, Arbitrum)

On Base, Polygon, and Arbitrum, an EOA customer approves a spending cap once and the renewal-worker pulls each renewal directly from the on-chain allowance — revocable any time. It’s the closest thing to card-on-file in stablecoins. On Ethereum L1, gas makes auto-pull impractical, so Ethereum subscriptions stay invoice-and-pay today. Solana has no ERC-20 allowance equivalent and stays invoice-and-pay; Tron allowance autopay is on the roadmap.

Enabling it — there’s no approval UI to build

You don’t build the approve-and-sign flow. Create a normal subscription checkout and the hosted page does the rest: on a supported rail (Base, Polygon, or Arbitrum with an EOA wallet) it offers the customer a one-time “approve autopay” step — the on-chain allowance approval plus the EIP-712 authorization — then creates the subscription with autopay: "allowance". Every bound parameter (cap ≥ charge, signer = payer, merchant = your verified wallet) is re-verified server-side. Where autopay isn’t available — a smart-contract wallet, another chain, or autopay not yet wired for your workspace — the same checkout falls back to invoice-and-pay automatically, so the customer is never stranded and you never ship a half-built subscription.

subscription-checkout.ts
// A normal subscription checkout. The hosted page offers the
// one-time autopay approval automatically on supported rails —
// you don't collect the signature or render the approve flow.
const checkout = await os.checkouts.create({
  mode: "subscription",
  priceId: "price_9fX0a2E1",
  customerEmail: "ada@lovelace.dev",
  chain: "base",
  token: "USDC",
  successUrl: "https://yourapp.com/billing/done",
});

return Response.redirect(checkout.hostedUrl, 303);

Collecting the signature yourself instead? The direct subscriptions.create path accepts autopayAuthorization + autopaySignature for advanced integrations — the same server-side verification applies.

On the roadmap

Smart-wallet autopay (ERC-4337). For customers on Safe, Coinbase Smart Wallet, Privy, etc., a scoped session key (cap + duration + allowed target) is the natural fit. Implementation follows the allowance work and is sequenced after first paying merchants ship in production.