订阅支付#
订阅支付让买家授权一次,之后由商户后端按周期主动触发扣款,无需每期重新签名或充值。资金始终留在买家自己的钱包里;每期扣多少、多久扣一次、扣给谁,都在买家签名时就定好——商户改不了,也不能多扣。
适用场景#
| 业务特征 | 是否适合 |
|---|---|
| 周期性、固定金额收费(月 / 季 / 年) | ✅ |
| 会员制 / 内容订阅 / API 套餐 | ✅ |
| 需要分档(如 Basic / Pro)并支持升降级 | ✅ |
前提条件#
- API Key:商户需先在 OKX 开发者平台申请 API Key(API Key / Secret Key / Passphrase 三件套,对应请求头
OK-ACCESS-*、代码中的OKX_API_KEY/OKX_SECRET_KEY/OKX_PASSPHRASE)。 - 代币:商户可选择任意标准 ERC-20 代币作为支付币种;不支持原生币(OKB)。
业务流程#
买家一次双签(订阅条款 + Permit2 授权)创建订阅;之后每期由你触发扣款,合约按签名锁定的金额直转收款地址。
卖家接入#
接入分两种,按是否让用户切换套餐来选:
- 基础接入 — 套餐固定,用户订阅后不切换;负责定义套餐、收款、自动续费。
- 进阶:支持切换套餐 — 在基础之上,让用户在订阅期内升级或降级。
基础接入(套餐固定)#
开通订阅#
- 1定义套餐
先选计费模式,再为每档配好价格、期数和促销。
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!; // 商家收款地址 (EIP-55) // 金额使用 token 最小单位 (USDT/USDC 6 位小数; "5000" = $0.005) export const basic: PlanCatalogEntry = { id: "basic_monthly", // 套餐业务 id (访问放行时按此校验) tier: 1, // tier 越大等级越高 (决定升/降级方向) payTo, // 收款地址 amountPerPeriod: "5000", // 每期扣款金额 periodSec: 2_592_000, // 周=604800 / 月=2592000 / 年=31536000 periodMode: 0, // 0 = 固定间隔, 1 = 自然月 maxPeriods: 12, // 总授信额度上限 = maxPeriods × amountPerPeriod initialCharge: { // 首扣策略 (促销用) periodCount: 1, // 首扣覆盖多少期 totalAmount: "5000", // 首扣总额 (<= periodCount × amountPerPeriod) }, name: "Basic Monthly", // 展示元数据 // asset: token 地址覆盖; 不填 → SDK 用当前网络的默认稳定币 }; // Pro: 更高 tier + 更高金额, 结构与 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", }; // x402 route 需要完整的 `PaymentRequirements` accept 而非原始 plan; // 封装一次让所有 route 复用同一映射 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, // 不填 → SDK 用默认稳定币 price: { amount: plan.amountPerPeriod, asset: plan.asset }, maxTimeoutSeconds: 600, // 402 挑战有效期 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 }, }, }; }计费模式
按订阅日(每月同一天,遇月末顺延) 按固定周期(每隔固定时长,如每 30 天) 用户体验 3/15 订阅 → 当天扣首期,之后 4/15、5/15… 3/15 订阅、周期 30 天 → 当天扣首期,之后 4/14、5/14…(日期会漂移) periodMode自然月 固定间隔 periodSec0 自定义秒数 startAt0(立即开始) 0(立即开始) billingAnchorAt= 订阅当天 不适用 - 固定间隔时长:周 = 604800、月(30 天)= 2592000、年(365 天)= 31536000;要对齐日历日期请用自然月。
- 月末规则:按订阅当天的号数逐月扣;某月没有这个号数(如 2 月没有 31 号)就扣当月最后一天——
1/31 → 2/28 → 3/31。
套餐参数——每档的价格、期数(示例:两档月付、按订阅日计费)
套餐 planIdplanTieramountPerPeriodmaxPeriodsBasic basic_m1 10 USDC 12 Pro pro_m2 30 USDC 12 - 金额按代币最小单位传(USDC 6 位小数,10 USDC =
10000000)。 planTier:数值越大档位越高,升降级时靠它判断方向;即使现在不做切换套餐,也建议一开始就按真实档位设好。
促销玩法——都通过首期参数
initialChargePeriods/initialChargeAmount实现,和你选哪种计费模式无关。以amountPerPeriod=$30、maxPeriods=12为例:策略 initialChargePeriodsinitialChargeAmount效果 标准月付 0 0 逐期扣 $30 首 N 期免费试用 3 0 1~3 期免费,第 4 期起 $30 首期折扣 / 优惠券 1 折后金额(如半价填 $15) 第 1 期按折后价;链上不认优惠码,金额你的后端算 首 N 期促销价预付 3 $30 前 3 期合计预付 $30 年付折扣(付 10 用 12) 12 $300 一次扣 $300,立即完成 抵扣 / 送一期 1 抵扣后金额(送一期填 0) 把抵扣折进首期:下期 $30 抵 $20 → 填 $10 - 约束:
initialChargeAmount ≤ initialChargePeriods × amountPerPeriod(不允许加价)。 - 抵扣只能在用户签名时设置(订阅、切换套餐);订阅开始后每期按固定金额扣,不能对单期临时减免。
- 2提供付费端口
给付费资源开一个接口:没订阅的买家访问就返回 402 + 可选套餐;已订阅的正常返回内容。
要不要放行,分两步鉴权:
① 验证身份(二选一)
- 钱包凭证:买家用钱包签个凭证(accessProof),你验签拿到他的钱包地址。
- 你的账户体系:订阅时把
subId绑到你系统里的用户,之后照旧用 API Key / 登录态识别(已有账户体系推荐这种)。
② 检查权限:这个用户订阅有效、且他的套餐(
planId)允许访问当前接口 → 放行;否则返回 402。(同一套系统里,某个接口可以只对某些档开放,比如高级接口只放 Pro。)订阅之后,买家每次请求服务都走这条:
订阅后每次请求服务TypeScriptRusttsimport 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: 所有 route 共用同一套装配 ─────────── const facilitator = new OKXFacilitatorClient({ apiKey: requireEnv("OKX_API_KEY"), secretKey: requireEnv("OKX_SECRET_KEY"), passphrase: requireEnv("OKX_PASSPHRASE"), baseUrl: process.env.OKX_BASE_URL, // 不填 → OKX 生产环境 }); const store = new InMemoryStore(); // 生产环境换成持久化 store const scheme = new PermitSubscriptionScheme({ facilitator, network: NETWORK, store, // 与下方 SubscriptionClient 共享 }); const client = new SubscriptionClient({ scheme, store }); // 用于主动 charge / cancelBySeller / syncFromChain // register(network, scheme) 后 initialize() → 拉取 /supported 并缓存 // (facilitatorAddress, subscriptionContract, permit2Contract) 三元组 const server = new x402ResourceServer(facilitator).register(NETWORK, scheme); await server.initialize(); // ── seller 全局 onBeforeAccess (对齐 Rust `SubscriptionSupport::on_before_access`) ── // 触发时点: `verifyAccess` 通过后 (签名 + payer + plan 白名单 + 周期校验), // route handler 执行前. 每个 access-verified 请求都会触发. // 返回 `{ ok: false, error }` 即拒绝 → 402. // // ctx 上可用字段: // ctx.subscription — 完整 Subscription: subId / payer / merchant / // planId / planTier / amountPerPeriod / periodSec / // periodMode / maxPeriods / state / lastChargedPeriod / // elapsedPeriods / nextChargeableAt / pendingPlanChange … // ctx.request.path — 请求 pathname, 如 "/premium" // ctx.request.method — 实际 HTTP method (非硬编码) // ctx.request.headers — 全部请求头 (小写 key) // ctx.route.acceptedPlanIds — 本 route `accepts` 里的 plan id 列表 // ctx.route.accepts — 完整 `PaymentRequirements[]`: 每项 `extra.plan` // = { id, tier, name }, 同时带 `extra.amountPerPeriod` // / `extra.periodSec` / `extra.periodMode` / // `extra.maxPeriods`. 需要基于套餐详情做策略 (升级 // 提示 / tier 上限 / 价格分层) 时直接读这里, // 无需 seller 侧再维护一张 catalog 表. // // 多个 hook 累加执行: 多次调用 `.onBeforeAccess(hook)` 按注册顺序执行, // 任一 hook 返回 `{ ok:false }` 立即拒绝. 路由级 `RouteConfig.onBeforeAccess` // 在所有全局 hook 之后执行. const denied = new Set<string>(); const quotaByPayer = new Map<string, number>(); // 按 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) => { // CDN header 驱动的地区门 — 任意业务逻辑均可读 ctx.request if (ctx.request.headers["x-region"] === "restricted") { return { ok: false, error: "region_blocked" }; } return { ok: true }; }; // 示例: 用 ctx.route.accepts 里的完整 plan 详情, 在当前订阅未达路由最高 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) { // 非拒绝 — 仅记录 / 打点. 业务逻辑仍可放行 console.log(`sub ${ctx.subscription.subId}: 当前 tier ${currentTier}, 本路由最高接受 tier ${maxAcceptTier}`); } return { ok: true }; }; // routes.accepts = 套餐门禁: 只有订阅的 planId 命中列出的 accept 才放行, // 否则返回 402 const routes = { "GET /weather": { accepts: [toAccept(basic)], // 仅 Basic description: "天气数据 (Basic 套餐)", mimeType: "application/json", }, "GET /premium": { accepts: [toAccept(pro)], // 仅 Pro; Basic 订阅访问会 402 description: "高级分析 (仅 Pro 套餐)", mimeType: "application/json", }, }; // Builder 风格装配: 在 HTTPResourceServer 上挂 seller 全局 onBeforeAccess, // 再交给 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) => { // 中间件完成 access 校验后会把 subscription 挂到 req.x402 上 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")); - 3创建订阅
SDK 把买家双签转发给 Facilitator,合约创建并扣首期。
TypeScriptRustts// 订阅创建全自动: 买家把双签 payload (Permit2 PermitSingle + SubscriptionTerms) // POST 到任意 `accepts` 里列出了目标 plan 的订阅路由, 中间件即完成 verify + settle, // 商家侧无需额外代码 const routes = { "GET /weather": { accepts: [toAccept(basic)], // 本路由允许买家订阅哪些 plan description: "天气数据 (Basic 套餐)", mimeType: "application/json", }, }; const app = express(); app.use(express.json()); app.use(paymentMiddleware(routes, server)); // 启用自动订阅 app.get("/weather", (req, res) => { // 首次成功请求, 中间件会跑完 subscribe 流程并把 subscription 挂到 req 上 const x402 = (req as any).x402; res.json({ report: { weather: "sunny", temperature: 23 }, subId: x402?.subscription?.subId, }); }); // 订阅成功后中间件会写入 `PAYMENT-RESPONSE` 响应头, 内容为 // { subId, txHash, state } (state===1 → 已激活), 失败返回 HTTP 402 - 4定时扣款
按
nextChargeableAt定时触发扣款。TypeScriptRustts// ── 路径 A: 使用 SDK 层 (SubscriptionClient) ── // store.list() 遍历所有持久化订阅; nextChargeableAt (每次 getSubscription / // charge 后都会刷新) 标记下一次可扣时间点. `client.charge` 完成扣款并同步 store // (推进 lastChargedPeriod, 降级激活时迁移 subId). 合约侧不做补扣: 每次调用最多 // 推进一期. // // 沿用步骤 02 中的 `store` / `client`, 不需要重新装配 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 直接跳过 if (sub.nextChargeableAt == null) continue; // 未扣快照, 用 syncFromChain 刷新 if (sub.nextChargeableAt > nowSec()) continue; // 还没到期 try { const r = await client.charge(sub.subId); // r.txHash / r.state / r.planChangeTriggered — 若为 true 表示排队中的降级 // 已激活, 新的 active subId 在 `sub.changedToSubId` 里; scheme.charge // 已完成 store 行迁移 } catch (_e) { // dunning: 按需重试 / 通知买家 / N 次失败后自动取消 } } }, 60_000); // ── 路径 B: 完全自定义调度 + 底层 charge ── // 调度 / 选择 / 状态全部自管, 直接调 facilitator 的 `chargeSubscription`, // 手动处理原始 ChargeResult (period / state / txHash / planChangeTriggered) import type { SubscriptionFacilitatorClient } from "@okxweb3/app-x402-core/subscription"; // `facilitator` 就是 OKXFacilitatorClient, 已实现 SubscriptionFacilitatorClient // (subscribe / change / cancel / charge / getSubscription) const dueSubIds: string[] = /* 自行查询 — Redis / Postgres / cron shard … */ []; for (const subId of dueSubIds) { await (facilitator as SubscriptionFacilitatorClient).chargeSubscription(subId); }
取消订阅#
买家和商户都能发起取消:
- 立即停止后续扣款
- 已付不退
- 仅在订阅生效中可取消
// 声明 `operation` route, 中间件会中转买家侧已签好的 CancelAuth
// (或 PendingChangeCancelAuth) 给 facilitator. 没有 402 握手 — 买家直接 POST
// 已签授权, 中间件在 handler 之前完成 verify + settle
const routes = {
// ... 资源路由 ...
"POST /subscription/cancel": { // 买家 POST 已签 CancelAuth
accepts: [toAccept(basic), toAccept(pro)], // 列出所有支持的 plan
description: "取消订阅",
mimeType: "application/json",
operation: "cancel" as const,
},
"POST /subscription/cancel-pending": { // 撤销尚未生效的降级
accepts: [toAccept(basic), toAccept(pro)],
description: "撤销排队中的降级",
mimeType: "application/json",
operation: "cancel-pending-change" as const,
},
};
const app = express();
app.use(express.json());
app.use(paymentMiddleware(routes, server));
// 注册这两个 route; 中间件会在 handler 之前拦截
app.post("/subscription/cancel", (_req, res) => res.json({ ok: true }));
app.post("/subscription/cancel-pending", (_req, res) => res.json({ ok: true }));
// 成功后中间件写入 `PAYMENT-RESPONSE` 响应头, 内容为 { subId, txHash, state },
// state===3 → 已取消
//
// 商家主动取消完全跳过买家: 本地签一份 initiator="merchant" 的 CancelAuth,
// 然后调 `client.cancelBySeller(subId, auth)`
进阶:支持切换套餐#
在基础接入之上,新增一个处理套餐切换的接口,升级和降级都通过它完成,其余不变:
示例代码
// 一个 change 端点即可; 升 / 降级方向由目标 tier 与当前 tier 对比推导.
// 同一 URL 分两阶段:
// 阶段 1: 仅带 APP-Access → 返回 402 + extra.changeFrom (当前订阅信息)
// 阶段 2: 买家基于 changeFrom 签新 SubscriptionTerms, 再带 PAYMENT-SIGNATURE
// 重发 → 中间件执行 change
const routes = {
// ... 资源路由, 取消路由 ...
"GET /subscription/change": {
accepts: [ // 列出所有可切换的 plan
toAccept(basic),
toAccept(pro),
],
description: "切换订阅套餐",
mimeType: "application/json",
operation: "change" as const,
},
};
const app = express();
app.use(express.json());
app.use(paymentMiddleware(routes, server));
// 只有 change 成功才会到达 handler; 新的 subId / txHash / state 在
// `PAYMENT-RESPONSE` 头里, req.x402.settleResult.data 同时携带这些字段
app.get("/subscription/change", (req, res) => {
const data = (req as any).x402?.settleResult?.data;
res.json({
result: "订阅套餐已切换",
newSubId: data?.newSubId,
operationType: data?.operationType, // "upgrade" | "downgrade"
scheduledFromPeriod: data?.scheduledFromPeriod,
});
});
// 升级立即生效 (state===1); 降级在期末生效, 买家在期末前会看到
// subscription.pendingPlanChange 里带目标 planId. 期末生效前要回滚,
// 用步骤 05 的 `cancel-pending-change` 路由
升级 vs 降级
| 升级 | 降级 | |
|---|---|---|
| 方向 | 新档 planTier 更高 | 新档 planTier 更低(同档会被拒) |
| 什么时候生效 | 立即(切换那刻自动完成) | 当前周期用完后——需你的后端触发一次扣款才切换 |
| 生效时扣多少 | 扣新档首期:默认全额;想只补差价 / 做抵扣,就自己算好金额填 initialChargeAmount | 扣新档(低档)首期 |
| subId 变化 | 当场返回新 subId | 登记时先返回一个(状态待生效 pending),生效后启用 |
例子(Basic $10/月、Pro $30/月,按订阅日计费,用"账单日不变")
- 3/15 订阅 Basic → 每月 15 号扣 $10。
- 6/20 升级 Pro(立即生效):当天补本期差价 $30 − $10 = $20;7/15 起按 Pro 扣 $30,账单日仍是 15 号。
- 9/20 降回 Basic(期末生效):当天只登记、9 月继续用 Pro;10/15 你触发扣款 → 切到 Basic 扣 $10;已付的 Pro 不退。
升级后的账单日
升级后仍按原来的账单日和到期日扣款,升级当天只补本期差价。适合需要统一对账的商户。
例:原本每月 15 号扣,用户 6/20 升级 → 6/20 当天补本期差价,7/15 起按新档扣,账单日仍是 15 号。
参数:
startAt= 当前账期起点initialChargePeriods= 1initialChargeAmount= 新档当期应收 − 旧档当期已付(≥0)maxPeriods= 原期数 − 当前是第几期 + 1
连续切换
| 操作序列 | 怎么办 |
|---|---|
| 升级 → 又想降回 | 对新订阅发起降级(升级不可即时撤销,只能期末降) |
| 已登记降级 → 又想升级 | 先撤回降级再升级(同时只能有一个待生效计划) |
| 同档变更 | 直接被拒 |
对接要点与接口#
- 定时扣款要可靠 — 漏期不补扣,一次最多扣 1 期;按
nextChargeableAt选出到期订阅、幂等触发。 - 失败同步可知 — 无 webhook。入参错误直接接口报错;链上结果要读响应
data.state—— 可能返回 pending(0),需轮询查询接口确认终态。 - 授权额度要覆盖整个订阅期 — 多条订阅共用一份 Permit2 额度、新授权覆盖旧的;升到更贵 / 更长套餐时,买家要重签更大额度。
subId会变 — 升降级后用接口返回的新subId更新映射,无需追溯历史。
订阅的查询接口(订阅详情 / 扣款流水 / 待生效降级 / 授权状态 / 买家订阅列表)、字段、鉴权与完整错误码,详见 API 参考。
买家接入#
买家用支持 Onchain OS 的 AI Agent(如 Claude Code、Cursor)来订阅——用自然语言告诉 Agent 要做什么即可。
- 1装好 Onchain OS + 登录钱包
让 Agent 安装 skills、再用邮箱登录 Agentic Wallet(首次登录自动创建钱包,私钥在 TEE 里生成):
运行 npx skills add okx/onchainos-skills,然后用邮箱登录 Agentic Wallet - 2订阅卖家服务
让 Agent 访问卖家的付费端口。Agent 会收到 402 和可选套餐,你选好要订的档位、按提示确认,即完成订阅授权:
访问 <卖家的付费端口>,订阅 Pro 套餐 - 3开始使用
之后再访问同一端口就能拿到服务;后续每期由卖家自动扣款,你无需再操作。
- 4升级/降级
让 Agent 完成:
升级/降级 <卖家的付费端口>,至 Pro 套餐 - 5取消订阅
让 Agent 完成:
取消 <卖家的付费端口> 的 Pro 套餐订阅
