StableOps

Subscriptions

Create plans, subscribe merchant users, issue invoices, and let end users pay through the hosted checkout page or your own wallet flow.

StableOps subscriptions split merchant-side management from end-user payment. Your backend uses a secret key to manage plans, subscriptions, billing settings, and Portal sessions; the end user receives a Portal token, reads only their own invoices, and pays them through the hosted checkout page or your own wallet payment flow.

Lifecycle

  1. Create one or more plans with an interval and amount. A plan's amount is a USD-denominated number.
  2. Create a subscription for your merchantUserId.
  3. Read the first open invoice and create a Portal session for that user.
  4. In the Portal context, create a checkout session for the invoice and redirect the user to the checkout page, or get payment instructions and hand them to your own wallet flow.
  5. Treat Webhooks and invoice status as the source of truth. The success URL is only a browser return path.

Settlement asset

Plans and invoices are not tied to a single stablecoin. When an invoice starts payment, StableOps creates a payment order for the invoice amount. The pay request passes acceptedAssets, and StableOps allocates receiving addresses for those chain and asset pairs. The payer picks one chain and coin in the checkout page, or your wallet UI picks one returned payment instruction; the coin they actually send becomes the invoice's settled asset. Until that happens, the invoice asset is null because there is no settled coin yet.

acceptedAssets must match assets you can receive with your imported address pool. If no address is available for a requested pair, the payment order creation fails the same way standalone payment orders fail.

Payment UI options

The hosted checkout page is the fastest integration: call portal.invoices.checkoutSession(invoice.id, { acceptedAssets, ... }) and redirect the user to the returned checkoutUrl.

If you already have a wallet flow, call portal.invoices.pay(invoice.id, { acceptedAssets }) instead. The response contains paymentOrder.paymentInstructions, the same shape used by standalone payment orders. Pick one instruction, ask the user's wallet to transfer the invoice amount to that address on that chain and asset, then rely on StableOps webhooks and invoice status for final settlement.

Settlement model

Invoice checkout sessions wrap the invoice's own payment order. The payment order metadata includes invoice_id, and the invoice row points back to paymentOrderId. When the payment order reaches FINALIZED, StableOps settles that invoice through the same pair, backfills the invoice asset with the stablecoin the payer actually sent, and then advances the subscription to active or completes the plan upgrade.

Roles

  • Use a secret key from your backend or sandbox tooling to create plans, subscriptions, and Portal sessions.
  • Use a Portal token in the end-user browser to read that user's subscription and invoices.
  • Use Dashboard to manage plans, subscriptions, invoices, and merchant-level subscription settings.

Online testing

The panel below uses your sandbox API key in the browser to prepare demo plans, create a demo subscription, create a Portal session, and open the invoice in the hosted checkout page.

When on, a deterministic burner sandbox address is imported for this invoice checkout before the session is created. Useful when your org has no addresses yet. Turn it off to use only the addresses you manage yourself.

The Subscription component source code is hosted on GitHub: github.com/StableOps/stableops-playground.

SDK example

import { StableOps } from '@stableops/api-sdk'

const stableops = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY!,
})

const plan = await stableops.merchantSubscriptions.plans.create(
  {
    code: 'starter',
    name: 'Starter',
    groupKey: 'starter',
    // Amount is USD-denominated. No asset field; the end user picks USDC or USDT in Checkout.
    amount: '9.00',
    interval: 'month',
    intervalCount: 1,
  },
  { idempotencyKey: 'plan_starter' },
)

const created = await stableops.merchantSubscriptions.subscriptions.create(
  {
    planId: plan.id,
    merchantUserId: 'user_123',
  },
  { idempotencyKey: 'sub_user_123' },
)

const portalSession = await stableops.merchantSubscriptions.portalSessions.create({
  merchantUserId: 'user_123',
})

const portal = stableops.portal(portalSession.portalToken)
const invoice = created.invoice!
const acceptedAssets = [{ chain: 'base-sepolia', asset: 'USDC' }] as const
const checkout = await portal.invoices.checkoutSession(invoice.id, {
  acceptedAssets,
  // Optional. Defaults to exact; auto micro-adjusts the payable amount on
  // shared-address collisions.
  amountMode: 'auto',
  successUrl: 'https://merchant.example/success',
  cancelUrl: 'https://merchant.example/cancel',
})

return Response.redirect(checkout.checkoutUrl, 303)

Or use your own wallet UI:

const payment = await portal.invoices.pay(invoice.id, {
  acceptedAssets,
  // Optional. Defaults to exact; with auto, always transfer the returned
  // paymentOrder.amount.
  amountMode: 'auto',
})
const instruction = payment.paymentOrder.paymentInstructions[0]

// Hand this to your wallet layer. It should send payment.paymentOrder.amount
// of instruction.asset on instruction.chain to instruction.address.
await payWithYourWallet({
  chain: instruction.chain,
  asset: instruction.asset,
  amount: payment.paymentOrder.amount,
  to: instruction.address,
})

How is this guide?

Last updated

On this page