接入指南
把预付额度接到你的产品后端:用户先买额度,用了多少你报给 Billing,Billing 记账并告诉你余额还够不够。本指南按接入的先后顺序写,每一步都给 curl 和 TypeScript SDK 两种写法;做完最后一节的验收,就算接入完成。
把接入交给开发 Agent(Claude Code、Codex 等)时,只要对它说「请按 你的服务地址/SKILL.md 接入 Billing」,再告诉它产品需求和 product.json 的绝对路径。Agent 按同一套步骤做,本页是它和你共用的说明。
发布 0.4.0 / API v1。
谁负责什么
| 你的产品 | Billing |
|---|---|
| 用户注册、登录,把当前用户的 ID 传给 Billing | 按(产品,用户 ID)记账,用户之间、产品之间互不可见 |
| 自己的购买页:列出额度包与档位,用户确认后建单 | 订单、Stripe 收银台、确认付款、发放额度 |
| 真实的服务,以及计量:每段工作用了多少 | 按产品的费率扣额度;余额不够时记为欠额,告诉你停止 |
| 每次展示余额时读一次 | 余额、流水,每条带变动后的余额 |
Billing 不会主动通知你的产品「付款到了」或「余额用完了」:你需要的时候读,报用量时看返回。
拿到连接配置并核对
负责人在控制台为你的产品签发产品密钥,和服务地址一起交给你,存成后端私有的 product.json(权限 0600):
{ "base_url": "https://billing.example.com", "api_key": "key_….…" }
负责人在「开发接入」复制接入说明;验收用的 demo_integration(有测试额度)和 demo_empty(零额度),需另在「测试体验」中准备并检查就绪。复制说明不会改变测试数据。密钥只显示一次;不要把它粘贴进对话、命令行参数或日志。
用一次只读请求核对(密钥经 stdin 交给 curl,不出现在命令行里):
jq -r '"header = \"Authorization: Bearer \(.api_key)\"\nurl = \"\(.base_url)/v1/capabilities\""' /absolute/path/product.json | curl -fsS -K -
返回 200 即说明地址和密钥可用:environment 是 test 或 live,actions 含 usage.record、balance.read;产品配了 Stripe 时另含 checkout.create。这次调用成功后,控制台的「接入进度」会把「后端已连通」打勾。
下面的 curl 示例为了简短,把两项放进环境变量;生产代码从私有文件读取。
export BILLING=https://billing.example.com
export KEY=$(jq -r .api_key /absolute/path/product.json)
TypeScript / JavaScript 后端用公开 SDK flyresearch/billing-sdk:零依赖,只用 fetch,Node 18+、Bun、Deno、Cloudflare Workers 都能用。其他语言直接按 OpenAPI 调 HTTP。
bun add github:flyresearch/billing-sdk#v0.12.0
import { createBillingClient } from "@flyresearch/billing-sdk";
const billing = createBillingClient({ baseUrl, apiKey }); // 来自 product.json
读一次计量合同:GET /v1/catalog 的 config 说明你上报的单位(metric)和每单位扣多少额度(credits_per_unit)。
核心循环:先读余额,用量发生后上报
这是接入的主体。规则只有三条:
- 开始一次工作前读一次余额,
allowed为false就不开始。 - 工作中按固定间隔上报已经发生的用量(例如每 5 秒),不需要事先冻结额度。
- 上报的返回里
allowed变成false,就停止这次工作。用量总会记下:余额不够时差额记为欠额,balance变成负数,下一次充值先抵扣欠额。所以超用最多一个上报间隔。
curl -sS "$BILLING/v1/users/USER_ID/balance" -H "Authorization: Bearer $KEY"
# → {"balance":"3000000","allowed":true, …}
curl -sS -X POST "$BILLING/v1/users/USER_ID/usage" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"event_id":"session-42:1","units":"5000","metadata":{"session":"session-42"}}'
# → {"event_id":"session-42:1","credits":"5000","balance":"2995000","allowed":true}
event_id 识别这条用量,是重试的依据:
- 上报失败或没收到回复,就用原
event_id、原内容重发,返回第一次的结果,不会重复扣费。 - 同一个
event_id换了内容,返回 409event_conflict。所以每段用量用自己的 ID,比如「会话 ID:序号」。 - 每条扣
units × credits_per_unit额度,精确相乘、不取整。metadata只收字符串值,最多 1 KB,会随流水返回。账户关闭后上报返回 409account_closed。
TypeScript 里 createMeter 已经实现了这个循环:按间隔批量上报、递增事件 ID、失败用原 ID 重试、allowed 变 false 时调用一次 onStop。
import { createMeter } from "@flyresearch/billing-sdk";
if (!(await billing.balance(userId)).allowed)
return showPurchasePage(await billing.catalog()); // 你自己的购买页(见下文「在你自己的页面卖额度」)
const meter = createMeter(billing, {
customer: userId,
eventPrefix: sessionId, // 每次工作唯一
intervalMs: 5000,
onStop: () => endSession("insufficient_credits"),
});
meter.add(measuredUnits); // 每测到一段用量就加
await meter.close(); // 结束时把剩下的报完
产品第一次用密钥上报用量后,控制台的「接入进度」会把「第一笔用量」打勾。控制台「测试体验」里记的用量不算:那是负责人在控制台里试,不是你的后端。
在你自己的页面卖额度
购买页做在你自己的产品里,和其余界面保持一致。Billing 负责订单、Stripe 收银台、确认付款和发放额度。
- 用
GET /v1/catalog列出在售额度包:每条带sku、offer_version、金额与额度。kind为flexible的档允许用户在范围内自定金额,另有min_amount、max_amount、step。金额都是最小币种单位的整数字符串。 - 用户确认后,先在自己的数据库里存一个新的
purchase_ref和所选的 SKU、offer_version,再建单:
curl -sS -X POST "$BILLING/v1/users/USER_ID/checkouts" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"sku":"credits-10","offer_version":"v1","purchase_ref":"pr_7f3c…","return_path":"/billing/done"}'
# → 201 {"order_id":"order_…","state":"awaiting_payment","checkout_url":"https://checkout.stripe.com/…"}
const order = await billing.checkout(userId, {
sku,
offer_version,
purchase_ref, // 先存进你的库
return_path: "/billing/done",
});
redirect(order.checkout_url);
- 把浏览器送到
checkout_url,用户在 Stripe 收银台付款。
同一用户的同一 purchase_ref 就是同一笔购买,所以重试很简单:
- 建单失败或没收到回复,用同一个
purchase_ref、原 SKU 重发,得到同一个订单和收银台,不会建第二单。Stripe 暂时不可用时返回 503stripe_unavailable,照此重试。 - 负责人调价或下架后,返回 HTTP 201 和
{"state":"rejected","reason":"offer_changed"}:这笔购买已终结,同一个purchase_ref重发也只得到这个拒绝。刷新额度包,用户重新确认后换一个新的purchase_ref。 - 只有带
order_id的结果才打开收银台。
付完回到哪里:站点(return_origin,如 https://your-product.example)由负责人在控制台「收款配置」里填一次;具体页面(return_path)每次建单时传。站点没配时不要传 return_path,否则返回 return_origin_not_configured。回跳地址带 order 和 product 两个参数。
回跳不是付款凭证。回到你的页面后,从后端读订单或余额,以服务端状态为准:
curl -sS "$BILLING/v1/users/USER_ID/orders/ORDER_ID" -H "Authorization: Bearer $KEY"
订单 state 为 fulfilled 表示额度已到账,awaiting_payment 表示还在等付款确认(支付宝、微信是异步的,几秒后再读),exception 不为空需要负责人处理。用户也可能付完就关掉收银台、根本不回跳,所以每次展示余额都读一次,不要只在回跳页刷新。
你的后端第一次用产品密钥成功建单(或开通订阅)后,控制台的「接入进度」会把「购买已接通」打勾。Billing 另有一张托管充值页,只供控制台「测试体验」使用,界面不属于你的产品,不要拿它做正式的购买入口。
谁可以购买由你的产品决定:不让某个用户买,就不替他建单。
按月订阅(可选)
产品配了档位时,catalog.plans 按价格从低到高列出(价格为 0 的是免费档)。订阅同样在你自己的页面里做:
| 用户要做什么 | HTTP | SDK |
|---|---|---|
| 看当前档位与本期额度 | GET /v1/users/{user}/plan | plan() |
| 开通付费档 | POST /v1/users/{user}/subscription-checkouts,返回订阅收银台地址 | subscriptionCheckout() |
| 升级或降级 | POST /v1/users/{user}/plan | changePlan() |
| 到期取消、撤销取消 | POST /v1/users/{user}/subscription/cancel、POST /v1/users/{user}/subscription/resume | cancelPlan()、resumePlan() |
| 换卡、看发票 | POST /v1/users/{user}/billing-portal,返回 Stripe 客户管理页 | billingPortal() |
档位的额度每期发放,先于额度包被消耗,期末作废。开通订阅也用 purchase_ref 识别,规则同建单。档位里的 entitlements(如并发数、密钥数)由你的产品自己执行,Billing 原样返回。各接口的状态与错误码见接口与状态。
给用户看余额与账单
- 余额:
GET /v1/users/{user}/balance(SDKbalance())。balance可以为负(欠额)。 - 流水:
GET /v1/users/{user}/history(SDKhistory()),从新到旧,每条带变动后的余额,购买条目附带订单。用has_more和next_cursor翻页;?type=usage只看用量,?grouped=true把同一group的用量合成一行。 - 订单:
GET /v1/users/{user}/orders(SDKorders())。
验收
用负责人准备的两个测试用户,在产品的测试环境里跑一遍。每轮用新的 event_id 前缀,重试沿用旧的。下面 1–6 是必做项,不需要 Stripe。
- 上报与重复:读
demo_integration的余额,上报一段用量,再用同一个event_id、同样内容重发。两次返回相同,余额只减少一次,流水里只有一条用量。 - 内容冲突:同一个
event_id换一个units上报,返回 409event_conflict,余额不变。 - 欠额与停止:对
demo_empty上报一段用量。照样记账:balance为负、allowed为false。你的产品此时应停止服务。 - 充值先抵扣欠额:给
demo_empty充值(或请负责人在控制台发测试额度)后,余额等于充值额度减去欠额。 - 流水:
history从新到旧列出充值、用量等,每条带变动后的余额,metadata原样返回。 - 用户隔离:一个用户的用量不影响另一个用户的余额;用一个用户的订单 ID 在另一个用户的路径下查询,返回 404。你的后端也必须拒绝前端替换用户 ID。
配了 Stripe 时再做两项:
- 购买:从你自己的购买页为测试用户建单,在沙盒收银台用 Stripe 测试卡 付款。只有 Billing 核实付款后额度才到账;不能凭回跳页加额度。
- 退款:用一笔完全没用过的购买,请负责人在控制台退款;退款后这笔购买的额度被撤回,产品密钥本身不能退款。
验收时记下:服务的 release_version、capabilities 的结果、跑过哪些项、余额怎么变、哪些没验证。不要记录密钥或用户的原始数据。在控制台「接入进度」里,「后端已连通」「第一笔用量」以及(产品在售时)「购买已接通」都会打勾。
测试里不必连真服务:@flyresearch/billing-sdk/testing 的 createTestBilling() 是按同一套接口规则运行的内存服务,与真服务由同一套契约测试校验,适合写你产品里的计量、停工和购买测试。
卡住时
| 看到什么 | 怎么办 |
|---|---|
| capabilities 连不上 | 确认服务地址可达;地址是 127.0.0.1 时,后端必须在同一台机器上运行 |
HTTP 401 unauthorized | 密钥不属于这个服务,或已被撤销;请负责人在控制台「开发接入」核对或重新签发 |
allowed: false | 余额用完或有欠额:让用户充值,测试用户请负责人发测试额度(产品密钥不能自己加额度) |
409 event_conflict | 同一个 event_id 报了不同内容;每段用量用自己的 ID,重试用原 ID 原内容 |
stripe_not_configured | 产品还没配 Stripe;可以先接完用量,购买等负责人配好再测 |
503 stripe_unavailable | 订单已保存、收银台没建好;用同一个 purchase_ref 重试 |
201 offer_changed | 额度包变了;刷新列表,用户重新确认后用新的 purchase_ref |
return_origin_not_configured | 传了 return_path 但负责人没配回跳站点;去掉 return_path,或请负责人配置 |