Subscription#
Subscription payment lets a Buyer authorize once, after which the Seller's backend actively triggers each period's charge — no re-signing or top-up per period. Funds always stay in the Buyer's own wallet; how much is charged each period, how often, and to whom are all fixed when the Buyer signs — the Seller can't change them, and can't overcharge.
When it fits#
| Your business | Fits? |
|---|---|
| Recurring, fixed-amount billing (monthly / quarterly / yearly) | ✅ |
| Memberships / content subscriptions / API plans | ✅ |
| Needs tiers (e.g. Basic / Pro) with upgrade & downgrade | ✅ |
Prerequisites#
- API Key: Apply for an API Key on the OKX Developer Portal first (the API Key / Secret Key / Passphrase trio — request headers
OK-ACCESS-*, and in codeOKX_API_KEY/OKX_SECRET_KEY/OKX_PASSPHRASE). - Token: You may use any standard ERC-20 token as the payment currency; native coins are not supported (e.g. OKB).
Business flow#
The Buyer double-signs once (subscription terms + Permit2 authorization) to create the subscription; after that you trigger each period's charge, and the contract transfers the signed, locked amount directly to the payee.
Seller integration#
Integration comes in two flavors, chosen by whether you let users switch plans:
- Basic integration — fixed plan, users don't switch after subscribing; you define plans, receive payment, and auto-renew.
- Advanced: support plan changes — on top of basic, let users upgrade or downgrade within the subscription period.
Basic integration (fixed plan)#
Set up a subscription#
- 1Define your plans
Pick a billing mode first, then configure each tier's price, periods, and promotions.
TypeScriptRustbashpnpm add @okxweb3/app-x402-core @okxweb3/app-x402-evm @okxweb3/app-x402-express express viem pnpm add -D tsx typescript @types/express @types/nodetsimport type { PlanCatalogEntry } from "@okxweb3/app-x402-core/subscription"; const payTo = process.env.PAY_TO_ADDRESS!; // recipient address (EIP-55) // Amounts in token base units (USDT/USDC 6 decimals; "5000" = $0.005). export const basic: PlanCatalogEntry = { id: "basic_monthly", // business plan id (checked on access) tier: 1, // higher tier = higher plan (drives up/downgrade) payTo, // recipient address amountPerPeriod: "5000", // charged each period periodSec: 2_592_000, // week=604800 / month=2592000 / year=31536000 periodMode: 0, // 0 = fixed interval, 1 = calendar month maxPeriods: 12, // allowance cap = maxPeriods × amountPerPeriod initialCharge: { // first-charge policy (promotions) periodCount: 1, // covers this many leading periods totalAmount: "5000", // total (<= periodCount × amountPerPeriod) }, name: "Basic Monthly", // display metadata // asset: token address override; omit → SDK picks the network's default stablecoin. }; // Pro: higher tier + amount, same shape as `basic`. export const pro: PlanCatalogEntry = { id: "pro_monthly", tier: 2, payTo, amountPerPeriod: "20000", periodSec: 2_592_000, periodMode: 0, maxPeriods: 12, initialCharge: { periodCount: 1, totalAmount: "20000" }, name: "Pro Monthly", }; // The x402 route wants a full `PaymentRequirements` accept, not the raw plan; // wrap once so every route can reuse the same mapping. export const NETWORK = (process.env.CHAIN_NETWORK ?? "eip155:196") as `eip155:${string}`; export function toAccept(plan: PlanCatalogEntry) { return { scheme: "period", network: NETWORK, payTo: plan.payTo, asset: plan.asset, // undefined → SDK default stablecoin price: { amount: plan.amountPerPeriod, asset: plan.asset }, maxTimeoutSeconds: 600, // 402 challenge validity extra: { amountPerPeriod: plan.amountPerPeriod, periodMode: plan.periodMode ?? 0, periodSec: plan.periodSec, maxPeriods: plan.maxPeriods, initialCharge: plan.initialCharge, plan: { id: plan.id, tier: plan.tier, name: plan.name }, }, }; }Billing modes
By subscription date (same day each month, rolls forward at month-end) By fixed interval (every fixed span, e.g. every 30 days) User experience Subscribe 3/15 → charge first period that day, then 4/15, 5/15… Subscribe 3/15, 30-day period → charge first period that day, then 4/14, 5/14… (the date drifts) periodModeCalendar month Fixed interval periodSec0 Custom seconds startAt0 (start now) 0 (start now) billingAnchorAt= subscribe date N/A - Fixed-interval spans: week = 604800, month (30 days) = 2592000, year (365 days) = 31536000; to align to calendar dates, use calendar month.
- Month-end rule: charge on the subscribe-date's day-of-month each month; if a month lacks that day (e.g. February has no 31st), charge the last day of that month —
1/31 → 2/28 → 3/31.
Plan parameters — each tier's price and periods (example: two monthly tiers, billed by subscription date)
Plan planIdplanTieramountPerPeriodmaxPeriodsBasic basic_m1 10 USDC 12 Pro pro_m2 30 USDC 12 - Amounts are passed in the token's smallest unit (USDC has 6 decimals, 10 USDC =
10000000). planTier: the higher the value, the higher the tier; upgrade/downgrade direction is judged by it. Even if you're not offering plan switching now, set it to the real tier from the start.
Promotions — all done via the first-period params
initialChargePeriods/initialChargeAmount, independent of which billing mode you pick. WithamountPerPeriod=$30andmaxPeriods=12:Strategy initialChargePeriodsinitialChargeAmountEffect Standard monthly 0 0 Charge $30 each period First N periods free trial 3 0 Periods 1–3 free, $30 from period 4 First-period discount / coupon 1 Discounted amount (e.g. $15 for half off) First period at the discounted price; the chain doesn't recognize coupon codes — your backend computes the amount First N periods promo prepay 3 $30 Prepay $30 total for the first 3 periods Annual discount (pay 10, get 12) 12 $300 Charge $300 once, completes immediately Credit / one period free 1 Amount after credit (0 for a free period) Fold the credit into the first period: next period $30 minus $20 → enter $10 - Constraint:
initialChargeAmount ≤ initialChargePeriods × amountPerPeriod(no markup allowed). - Credits can only be set when the user signs (subscribe, plan change); once the subscription starts, each period charges the fixed amount — you can't grant an ad-hoc discount for a single period.
- 2Serve and gate the paid endpoint
Expose an endpoint for the paid resource: an unsubscribed Buyer gets 402 + optional plans; a subscribed one gets the content.
Whether to admit is a two-step check:
① Verify identity (pick one)
- Wallet credential: the Buyer signs a credential (accessProof) with their wallet; you verify it to obtain their wallet address.
- Your account system: bind the
subIdto a user in your system at subscribe time, then keep identifying via API Key / login session as usual (recommended if you already have an account system).
② Check permission: this user's subscription is active AND their plan (
planId) is allowed to access this endpoint → admit; otherwise return 402. (Within one system, an endpoint can be opened to only some tiers — e.g. a premium endpoint only for Pro.)After subscribing, every service request the Buyer makes goes through this:
Each service request after subscribingTypeScriptRusttsimport express from "express"; import { OKXFacilitatorClient } from "@okxweb3/app-x402-core"; import { x402HTTPResourceServer, x402ResourceServer } from "@okxweb3/app-x402-core/server"; import { InMemoryStore, SubscriptionClient, type OnBeforeAccessHook, } from "@okxweb3/app-x402-core/subscription"; import { PermitSubscriptionScheme } from "@okxweb3/app-x402-evm/subscription"; import { paymentMiddlewareFromHTTPServer } from "@okxweb3/app-x402-express"; import { basic, pro, toAccept, NETWORK } from "./plans"; function requireEnv(k: string): string { const v = process.env[k]; if (!v) throw new Error(`Missing env: ${k}`); return v; } // ── facilitator + scheme + store: same wiring every route reuses ───── const facilitator = new OKXFacilitatorClient({ apiKey: requireEnv("OKX_API_KEY"), secretKey: requireEnv("OKX_SECRET_KEY"), passphrase: requireEnv("OKX_PASSPHRASE"), baseUrl: process.env.OKX_BASE_URL, // omit → OKX production }); const store = new InMemoryStore(); // swap for a persistent store in prod const scheme = new PermitSubscriptionScheme({ facilitator, network: NETWORK, store, // shared with SubscriptionClient below }); const client = new SubscriptionClient({ scheme, store }); // used for charge / cancelBySeller / syncFromChain // register(network, scheme) then initialize() → fetches /supported and caches // the (facilitatorAddress, subscriptionContract, permit2Contract) triple. const server = new x402ResourceServer(facilitator).register(NETWORK, scheme); await server.initialize(); // ── seller-global onBeforeAccess (matches Rust `SubscriptionSupport::on_before_access`) ── // Fires AFTER `verifyAccess` (signature + payer + plan-allowlist + period math) // succeed, BEFORE the route handler runs. Called on every access-verified // request. Return `{ ok: false, error }` to deny → 402. // // Context fields on `ctx`: // ctx.subscription — full Subscription: subId / payer / merchant / // planId / planTier / amountPerPeriod / periodSec / // periodMode / maxPeriods / state / lastChargedPeriod / // elapsedPeriods / nextChargeableAt / pendingPlanChange / … // ctx.request.path — request pathname, e.g. "/premium" // ctx.request.method — real HTTP method (not hard-coded) // ctx.request.headers — lowercase-keyed request headers // ctx.route.acceptedPlanIds — plan ids listed in this route's `accepts` // ctx.route.accepts — full `PaymentRequirements[]`: each entry carries // plan metadata in `extra.plan` = { id, tier, name } // plus `extra.amountPerPeriod` / `extra.periodSec` / // `extra.periodMode` / `extra.maxPeriods`. Read these // when policy depends on catalog details (upgrade // offers, tier ceilings, per-plan feature flags) — // no separate catalog table needed on the seller. // // Multiple hooks stack: call `.onBeforeAccess(hook)` several times and they // run in registration order; the first `{ ok:false }` denies. // `RouteConfig.onBeforeAccess` (per-route) runs AFTER all global hooks. const denied = new Set<string>(); const quotaByPayer = new Map<string, number>(); // per-day counter, keyed by payer const DAILY_QUOTA = 10_000; const banGuard: OnBeforeAccessHook = async (ctx) => { if (denied.has(ctx.subscription.subId)) { return { ok: false, error: "access_denied_by_merchant" }; } return { ok: true }; }; const quotaGuard: OnBeforeAccessHook = async (ctx) => { const key = ctx.subscription.payer; const used = (quotaByPayer.get(key) ?? 0) + 1; quotaByPayer.set(key, used); if (used > DAILY_QUOTA) { return { ok: false, error: "quota_exhausted", retryAfter: 86_400 }; } return { ok: true }; }; const headerGuard: OnBeforeAccessHook = async (ctx) => { // Region gate driven by CDN header — arbitrary logic keyed on ctx.request. if (ctx.request.headers["x-region"] === "restricted") { return { ok: false, error: "region_blocked" }; } return { ok: true }; }; // Example: use ctx.route.accepts (full plan metadata) to log an upgrade hint // when the buyer's current plan doesn't reach the route's highest tier. const upgradeHint: OnBeforeAccessHook = async (ctx) => { const currentTier = ctx.subscription.planTier; const acceptTiers = (ctx.route.accepts ?? []) .map((r) => (r.extra?.plan as { tier?: number } | undefined)?.tier ?? 0); const maxAcceptTier = Math.max(0, ...acceptTiers); if (currentTier < maxAcceptTier) { // Not a deny — just log / metric. Business logic still allows. console.log(`sub ${ctx.subscription.subId}: on tier ${currentTier}, route accepts up to ${maxAcceptTier}`); } return { ok: true }; }; // routes.accepts = plan gating: only a sub whose planId matches one of the // listed accepts is admitted; otherwise 402. const routes = { "GET /weather": { accepts: [toAccept(basic)], // Basic only description: "Weather data (Basic plan)", mimeType: "application/json", }, "GET /premium": { accepts: [toAccept(pro)], // Pro only; a Basic sub → 402 description: "Premium analytics (Pro plan only)", mimeType: "application/json", }, }; // Builder-style wiring: attach seller-global onBeforeAccess hooks on the // HTTPResourceServer, then hand it to the express factory. const httpServer = new x402HTTPResourceServer(server, routes) .onBeforeAccess(banGuard) .onBeforeAccess(quotaGuard) .onBeforeAccess(headerGuard) .onBeforeAccess(upgradeHint); const app = express(); app.use(express.json()); app.use(paymentMiddlewareFromHTTPServer(httpServer)); app.get("/weather", (req, res) => { // req.x402.subscription is set once the middleware completes access verification. res.json({ report: { weather: "sunny", temperature: 23 } }); }); app.get("/premium", (_req, res) => res.json({ report: { premium: true } })); app.listen(4022, "0.0.0.0", () => console.log("listening on :4022")); - 3Create the subscription
The SDK forwards the Buyer's double signature to the Facilitator; the contract creates the subscription and charges the first period.
TypeScriptRustts// Subscribe is automatic: buyer POSTs the double-signed payload // (Permit2 PermitSingle + SubscriptionTerms) to any subscription route // whose `accepts` lists the target plan. Middleware verifies + settles // via the facilitator. No extra seller code. const routes = { "GET /weather": { accepts: [toAccept(basic)], // plan(s) a buyer can subscribe to here description: "Weather data (Basic plan)", mimeType: "application/json", }, }; const app = express(); app.use(express.json()); app.use(paymentMiddleware(routes, server)); // enables automatic subscribe app.get("/weather", (req, res) => { // On the FIRST successful request the middleware runs the subscribe flow // and attaches the subscription onto req. const x402 = (req as any).x402; res.json({ report: { weather: "sunny", temperature: 23 }, subId: x402?.subscription?.subId, }); }); // On success the middleware returns a `PAYMENT-RESPONSE` header with // { subId, txHash, state } (state===1 → active). Failure → HTTP 402. - 4Schedule the charges
Trigger charges on a schedule per
nextChargeableAt.TypeScriptRustts// ── Path A: use the SDK layer (SubscriptionClient) ── // list() reads every persisted sub; nextChargeableAt (populated on every // getSubscription / charge) marks the next due boundary. `client.charge` // deducts once AND reconciles the store (advance lastChargedPeriod, migrate // subId on downgrade activation). Missed periods aren't backfilled: one // period per call, contract-side. // // Reuse the `store` / `client` from step 02 — no re-wiring needed. const nowSec = () => Math.floor(Date.now() / 1000); const timer = setInterval(async () => { const subs = await store.list(); for (const sub of subs) { if (sub.state !== "active") continue; // canceled / completed / changed: skip if (sub.nextChargeableAt == null) continue; // uncharged snapshot; refresh via syncFromChain if (sub.nextChargeableAt > nowSec()) continue; // not due yet try { const r = await client.charge(sub.subId); // r.txHash / r.state / r.planChangeTriggered (downgrade activation: // when true, `sub.changedToSubId` becomes the new active subId — the // store row has already been migrated by scheme.charge). } catch (_e) { // dunning: retry with backoff, notify buyer, auto-cancel after N failures. } } }, 60_000); // ── Path B: fully custom orchestration + low-level charge ── // Drive scheduling / selection / state yourself; call the facilitator's // `chargeSubscription` directly and handle the raw ChargeResult // (period / state / txHash / planChangeTriggered) in your own store. import type { SubscriptionFacilitatorClient } from "@okxweb3/app-x402-core/subscription"; // `facilitator` is your OKXFacilitatorClient — it implements // SubscriptionFacilitatorClient (subscribe / change / cancel / charge / // getSubscription). const dueSubIds: string[] = /* your query — Redis, Postgres, cron shard, … */ []; for (const subId of dueSubIds) { await (facilitator as SubscriptionFacilitatorClient).chargeSubscription(subId); }
Cancel a subscription#
Both the Buyer and the Seller can initiate cancellation:
- Stops all future charges immediately
- No refund of what's already been paid
- Can only cancel while the subscription is active
// Declare `operation` routes; the middleware relays the buyer-signed
// CancelAuth (or PendingChangeCancelAuth) to the facilitator. No 402
// handshake — the buyer POSTs an already-signed auth, middleware verifies
// and settles before the handler runs.
const routes = {
// ... resource routes ...
"POST /subscription/cancel": { // buyer POSTs a signed CancelAuth
accepts: [toAccept(basic), toAccept(pro)], // list every plan you support
description: "Cancel a subscription",
mimeType: "application/json",
operation: "cancel" as const,
},
"POST /subscription/cancel-pending": { // revert a not-yet-effective downgrade
accepts: [toAccept(basic), toAccept(pro)],
description: "Cancel a scheduled downgrade",
mimeType: "application/json",
operation: "cancel-pending-change" as const,
},
};
const app = express();
app.use(express.json());
app.use(paymentMiddleware(routes, server));
// Register the routes; middleware intercepts them BEFORE the handler runs.
app.post("/subscription/cancel", (_req, res) => res.json({ ok: true }));
app.post("/subscription/cancel-pending", (_req, res) => res.json({ ok: true }));
// On success the middleware writes a `PAYMENT-RESPONSE` header with
// { subId, txHash, state } — state===3 → canceled.
//
// Merchant-initiated cancel bypasses the buyer entirely: sign a CancelAuth
// with `initiator="merchant"` locally and call `client.cancelBySeller(subId, auth)`.
Advanced: support plan changes#
On top of basic integration, add one endpoint that handles plan changes; both upgrade and downgrade go through it, and everything else stays the same:
Example code
// One change endpoint; direction (up/downgrade) is derived from target vs.
// current tier. Two phases, same URL:
// phase 1: APP-Access header alone → 402 with extra.changeFrom (current sub)
// phase 2: buyer signs new SubscriptionTerms bound to changeFrom, resubmits
// with PAYMENT-SIGNATURE → middleware executes the change.
const routes = {
// ... resource routes, cancel routes ...
"GET /subscription/change": {
accepts: [ // list every switchable plan
toAccept(basic),
toAccept(pro),
],
description: "Change your subscription plan",
mimeType: "application/json",
operation: "change" as const,
},
};
const app = express();
app.use(express.json());
app.use(paymentMiddleware(routes, server));
// Reached only after a successful change: new subId / txHash / state
// are on `PAYMENT-RESPONSE`, and req.x402.settleResult.data carries them
// for handler consumption.
app.get("/subscription/change", (req, res) => {
const data = (req as any).x402?.settleResult?.data;
res.json({
result: "subscription plan changed",
newSubId: data?.newSubId,
operationType: data?.operationType, // "upgrade" | "downgrade"
scheduledFromPeriod: data?.scheduledFromPeriod,
});
});
// Upgrade activates immediately (state===1). Downgrade schedules a pending
// change at period_end; buyer sees the target planId in
// subscription.pendingPlanChange until the boundary. To roll it back before
// it activates, use the `cancel-pending-change` route from step 05.
Upgrade vs. downgrade
| Upgrade | Downgrade | |
|---|---|---|
| Direction | New tier's planTier is higher | New tier's planTier is lower (same tier is rejected) |
| When it takes effect | Immediately (auto-completes at the moment of switching) | After the current period ends — your backend must trigger a charge for the switch to happen |
| How much is charged then | The new tier's first period: full by default; to charge only the difference / apply a credit, compute the amount yourself and fill initialChargeAmount | The new (lower) tier's first period |
| subId change | Returns the new subId on the spot | Returns one at registration (state pending), activated once effective |
Example (Basic $10/mo, Pro $30/mo, billed by subscription date, using "keep the billing date")
- Subscribe to Basic on 3/15 → charge $10 on the 15th of each month.
- Upgrade to Pro on 6/20 (effective immediately): charge the current-period difference $30 − $10 = $20 that day; from 7/15 charge $30 for Pro, billing date still the 15th.
- Downgrade back to Basic on 9/20 (effective at period end): only registered that day, keep using Pro through September; on 10/15 you trigger a charge → switch to Basic and charge $10; the Pro already paid is not refunded.
Billing date after upgrade
After the upgrade, keep charging on the original billing date and end date; on the upgrade day charge only the current-period difference. Good for merchants who need consistent reconciliation.
Example: originally charged on the 15th each month, user upgrades 6/20 → charge the current-period difference on 6/20, charge the new tier from 7/15, billing date still the 15th.
Parameters:
startAt= the current billing period's startinitialChargePeriods= 1initialChargeAmount= new tier's current-period due − old tier's current-period paid (≥0)maxPeriods= original periods − current period index + 1
Consecutive changes
| Operation sequence | What to do |
|---|---|
| Upgrade → then want to go back down | Initiate a downgrade on the new subscription (an upgrade can't be undone instantly, only downgraded at period end) |
| Registered downgrade → then want to upgrade | Revert the downgrade first, then upgrade (only one pending change at a time) |
| Same-tier change | Rejected outright |
Integration notes & APIs#
- Scheduled charges must be reliable — missed periods aren't backfilled, at most 1 period per charge; select due subscriptions by
nextChargeableAtand trigger idempotently. - Failures are known synchronously — no webhook. Input errors surface directly as API errors; on-chain results must be read from the response
data.state— it may return pending (0), so poll the query API to confirm the final state. - Allowance must cover the whole subscription period — multiple subscriptions share one Permit2 allowance, and a new authorization overrides the old; when upgrading to a pricier / longer plan, the Buyer must re-sign a larger allowance.
subIdchanges — after an upgrade/downgrade, update your mapping to the newsubIdreturned by the API; no need to trace history.
For the subscription query APIs (subscription details / charge history / pending downgrade / authorization status / a Buyer's subscription list), fields, auth, and full error codes, see API Reference.
Buyer integration#
Buyers subscribe using an AI Agent that supports Onchain OS (e.g. Claude Code, Cursor) — just tell the Agent what you want in natural language.
- 1Install Onchain OS + log into your wallet
Have the Agent install the skills, then log into the Agentic Wallet with your email (first login auto-creates a wallet; the private key is generated inside a TEE):
Run npx skills add okx/onchainos-skills, then log into the Agentic Wallet with your email - 2Subscribe to a Seller's service
Have the Agent access the Seller's paid endpoint. The Agent receives the 402 and optional plans; pick the tier you want, confirm as prompted, and the subscription authorization is done:
Visit <the Seller's paid endpoint> and subscribe to the Pro plan - 3Start using it
After that, visiting the same endpoint returns the service; the Seller charges each period automatically — no further action from you.
- 4Upgrade / downgrade
Have the Agent do:
Upgrade/downgrade <the Seller's paid endpoint> to the Pro plan - 5Cancel the subscription
Have the Agent do:
Cancel the Pro plan subscription on <the Seller's paid endpoint>
Limits and trade-offs#
- One-off, single charge: use One-time payment
- Varying amount, billed by usage: use Pay-as-you-go
- High-frequency micro-payments, deferrable batch settlement: use Batch payment
