Billing 文档开始使用体验充值与扣费0.4.0 · API v1管理控制台 →

接入指南

把预付额度接到你的产品后端:用户先买额度,用了多少你报给 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)。

核心循环:先读余额,用量发生后上报

这是接入的主体。规则只有三条:

  1. 开始一次工作前读一次余额,allowed 为 false 就不开始。
  2. 工作中按固定间隔上报已经发生的用量(例如每 5 秒),不需要事先冻结额度。
  3. 上报的返回里 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 换了内容,返回 409 event_conflict。所以每段用量用自己的 ID,比如「会话 ID:序号」。
  • 每条扣 units × credits_per_unit 额度,精确相乘、不取整。metadata 只收字符串值,最多 1 KB,会随流水返回。账户关闭后上报返回 409 account_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 收银台、确认付款和发放额度。

  1. 用 GET /v1/catalog 列出在售额度包:每条带 sku、offer_version、金额与额度。kind 为 flexible 的档允许用户在范围内自定金额,另有 min_amount、max_amount、step。金额都是最小币种单位的整数字符串。
  2. 用户确认后,先在自己的数据库里存一个新的 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);
  1. 把浏览器送到 checkout_url,用户在 Stripe 收银台付款。

同一用户的同一 purchase_ref 就是同一笔购买,所以重试很简单:

  • 建单失败或没收到回复,用同一个 purchase_ref、原 SKU 重发,得到同一个订单和收银台,不会建第二单。Stripe 暂时不可用时返回 503 stripe_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 的是免费档)。订阅同样在你自己的页面里做:

用户要做什么HTTPSDK
看当前档位与本期额度GET /v1/users/{user}/planplan()
开通付费档POST /v1/users/{user}/subscription-checkouts,返回订阅收银台地址subscriptionCheckout()
升级或降级POST /v1/users/{user}/planchangePlan()
到期取消、撤销取消POST /v1/users/{user}/subscription/cancel、POST /v1/users/{user}/subscription/resumecancelPlan()、resumePlan()
换卡、看发票POST /v1/users/{user}/billing-portal,返回 Stripe 客户管理页billingPortal()

档位的额度每期发放,先于额度包被消耗,期末作废。开通订阅也用 purchase_ref 识别,规则同建单。档位里的 entitlements(如并发数、密钥数)由你的产品自己执行,Billing 原样返回。各接口的状态与错误码见接口与状态。

给用户看余额与账单

  • 余额:GET /v1/users/{user}/balance(SDK balance())。balance 可以为负(欠额)。
  • 流水:GET /v1/users/{user}/history(SDK history()),从新到旧,每条带变动后的余额,购买条目附带订单。用 has_more 和 next_cursor 翻页;?type=usage 只看用量,?grouped=true 把同一 group 的用量合成一行。
  • 订单:GET /v1/users/{user}/orders(SDK orders())。

验收

用负责人准备的两个测试用户,在产品的测试环境里跑一遍。每轮用新的 event_id 前缀,重试沿用旧的。下面 1–6 是必做项,不需要 Stripe。

  1. 上报与重复:读 demo_integration 的余额,上报一段用量,再用同一个 event_id、同样内容重发。两次返回相同,余额只减少一次,流水里只有一条用量。
  2. 内容冲突:同一个 event_id 换一个 units 上报,返回 409 event_conflict,余额不变。
  3. 欠额与停止:对 demo_empty 上报一段用量。照样记账:balance 为负、allowed 为 false。你的产品此时应停止服务。
  4. 充值先抵扣欠额:给 demo_empty 充值(或请负责人在控制台发测试额度)后,余额等于充值额度减去欠额。
  5. 流水:history 从新到旧列出充值、用量等,每条带变动后的余额,metadata 原样返回。
  6. 用户隔离:一个用户的用量不影响另一个用户的余额;用一个用户的订单 ID 在另一个用户的路径下查询,返回 404。你的后端也必须拒绝前端替换用户 ID。

配了 Stripe 时再做两项:

  1. 购买:从你自己的购买页为测试用户建单,在沙盒收银台用 Stripe 测试卡 付款。只有 Billing 核实付款后额度才到账;不能凭回跳页加额度。
  2. 退款:用一笔完全没用过的购买,请负责人在控制台退款;退款后这笔购买的额度被撤回,产品密钥本身不能退款。

验收时记下:服务的 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,或请负责人配置

接下来读什么

  • 接口与状态:每个接口的字段、精度、分页与重试规则。
  • 错误与恢复:每个错误码的含义与处理。
  • OpenAPI:完整契约;GET /v1 不需要凭据,列出全部接口、各自要的凭据和全部错误码。

Markdown 原文 · 文档首页 · 0.4.0