Subscriptions
Manage StableOps subscription plans, wallet-paid invoices, renewals, grace periods, cancellations, and plan changes with verified events and reconciliation.
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
- Create one or more plans with an interval and amount. A plan's amount is a USD-denominated number.
- Create a subscription for your
merchantUserId. - Read the first open invoice and create a Portal session for that user.
- 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.
- 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.
Renewals, payment windows, and overdue invoices
Automatic invoicing is not automatic wallet debiting. Users must actively pay each invoice through their wallet. On end_user_invoice.open, use your own notification channels to direct the user to payment; do not assume StableOps can pull funds from their wallet.
Merchant settings are isolated by organization and environment. Use get settings and update settings:
| Setting | Default | Meaning |
|---|---|---|
renewal_lead_days | 3 days | Opens the renewal window before period end for active subscriptions without scheduled cancellation |
pay_window_days | 7 days | Calculates invoice due_at from the time of issuance |
grace_days | 3 days | Allows a renewal grace period after the current period ends |
The invoice payment window, subscription grace period, and Payment Order expiration are separate. A payment window does not promise the same number of extra service days. Read the invoice due date together with the subscription period and status. Background checks are periodic, not guaranteed to run at the exact cutoff second.
- First payment: an unpaid subscription is
incomplete. Once its first invoice is overdue with no in-flight payment, it becomesexpiredand open invoices becomeuncollectible. - Trial end: the worker issues the first invoice and moves an elapsed trial to
past_due; billing-period and grace rules then apply. - Unpaid renewal: after period end, the subscription becomes
past_dueduring the grace period, thenexpiredafter grace when no payment is in flight. - In-flight payments: normal overdue checks defer handling invoices linked to
created,detected, orconfirmedPayment Orders while awaiting the outcome. This does not guarantee success or override explicit cancellation.
Use retrieved state to decide whether to retain or suspend access, and verified subscription events to synchronize it. A browser return or local countdown is not sufficient.
Cancellation and resumption
Cancel subscription has two modes; Portal cancellation uses the same rules:
immediate: false(default): setscancel_at_period_endwithout ending the current period immediately. Period-end processing expires applicable subscriptions and marks open invoicesuncollectible.immediate: true: immediately setscanceledand marks open invoicesuncollectible. It cannot undo an on-chain transfer and does not automatically refund payments.
Resume subscription only clears scheduled cancellation for non-terminal subscriptions. It neither pays overdue invoices nor reactivates a canceled or expired subscription. Create a new subscription when needed and reconcile the old invoices and payments separately.
Plan changes
After changing a plan, inspect the returned subscription, invoice, and pending plan rather than immediately changing access in your own system.
- Higher price: creates an
upgrade_prorationinvoice for the target plan amount minus the current plan amount. The current implementation does not prorate by remaining days. The plan changes only after the payment isfinalizedand the invoice is settled; the current period boundaries stay unchanged. Creating an upgrade invoice clears scheduled cancellation. Another upgrade conflicts while an upgrade invoice remains open for the same period. - Lower or equal price: schedules a pending plan for a subsequent renewal invoice; settlement of that invoice applies the change. There is no automatic refund for the current period. An existing renewal invoice is not rewritten when the pending plan changes later, so verify its amount and target plan before payment.
Late payments and reconciliation
A linked Payment Order can complete after an invoice becomes uncollectible. Settlement checks then emit end_user_invoice.payment_late without settling that invoice or restoring the subscription. Verify the actual transfer, invoice, and subscription before deciding on manual compensation, a new subscription, or a refund. Avoid granting access twice.
Invoice Payment Orders also emit ordinary payment events. Do not activate or upgrade a subscription solely from payment.finalized: check that the invoice is paid and the subscription has the expected state. See Webhooks for duplicate and out-of-order event handling.
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.
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
Checkout
Use StableOps Checkout to create one-time sessions and reusable payment links, customize branding, analyze conversion, and fulfill finalized payments safely.
MCP Server
Install the StableOps MCP Server, connect an AI agent with scoped credentials, and expose controlled payment queries and low-risk actions behind policy gates.