Stripe meter events 出错时,默认不要从 Dashboard 手工凑数字,也不要把同一批事件换个 identifier 再发一遍。先把本地 usage_event 表当作事实层:原始业务事件、幂等标识、Stripe 上报状态、重放批次和客服证据都落在这里,Stripe 只接收可收费投影。

典型现场是 AI SaaS 周一发现周末 worker 挂了 9 小时,客户又同时投诉同一段 token 被收了两次。此时最贵的错误不是少补一条 usage,而是为了让 Dashboard 数字好看,破坏了本地账本和 Stripe identifier 的对应关系。

重复、迟到、补报先分成哪三类?

把事故先分型,后面才不会把所有动作都叫「补账」。Stripe 官方文档写明,meter events 会异步处理,所以 API 返回成功不等于 meter summary 或 upcoming invoice 已经同步变动。

事故类型现场信号默认动作停止条件
重复事件同一业务请求被 worker 发了两次本地唯一键先拦,Stripe 侧用相同 identifier 重试已经换了 identifier 再发,先冻结批次
迟到事件业务发生在过去,worker 现在才补发保留真实 occurred_at,只补 timestamp 窗口内事件发生时间超过 35 个日历日
漏报补批某段队列、cron 或 webhook 中断dry-run 列出差异,再按批次重放数量和收入影响无法解释
错发事件发到错客户、错 meter 或测试数据进生产用 identifier 查原事件,评估 adjustmentStripe 收到已超过 24 小时或 invoice 已 finalized

这里的默认策略很保守:宁可让差异表多待一天,也不要为了赶账期把不可解释的事件塞进 Stripe。用量计费最怕客户问「为什么多收 18 美元」时,你只能回答「脚本补过一次」。

usage_event 表该怎么设计?

本地账本不要只保存 Stripe 返回结果。一个可用的事件表至少要能回答 4 个问题:业务事件是哪次请求产生的,什么时候发生,为什么变成可收费值,是否已经发给 Stripe。

create table billing_usage_events (
  usage_event_id uuid primary key,
  source_event_id text not null,
  customer_id text not null,
  stripe_customer_id text not null,
  meter_code text not null,
  event_name text not null,
  occurred_at timestamptz not null,
  raw_value numeric(18,6) not null,
  billable_value numeric(18,6) not null,
  stripe_identifier text not null,
  stripe_idempotency_key text not null,
  status text not null check (status in (
    'recorded',
    'queued',
    'sent',
    'duplicate',
    'late_rejected',
    'adjustment_pending',
    'adjusted',
    'dead_letter'
  )),
  replay_batch_id text,
  stripe_request_id text,
  last_error text,
  created_at timestamptz not null default now(),
  unique (source_event_id),
  unique (stripe_identifier)
);

source_event_id 来自产品侧,比如一次 API call、一次图片生成、一次 workflow run。stripe_identifier 是发给 Stripe meter event 的唯一号,最好由 source_event_id + meter_code + occurred_at 派生,再做哈希或 UUID 化。

发给 Stripe 的事件可以保持很薄:

{
  "event_name": "ai_tokens_used",
  "timestamp": "2026-05-27T10:15:30Z",
  "identifier": "useevt_01HX7P4Q7F8K9M2N6T3V5A1B0C",
  "payload": {
    "stripe_customer_id": "cus_123",
    "value": "1842",
    "request_id": "req_8a7f",
    "workspace_id": "ws_42"
  }
}

不要把套餐名、折扣原因、客服备注都塞进 payload。meter 的 event name、aggregation 和 payload key 配好后,后续可改空间很小;产品侧的解释材料放在本地表和客服后台更稳。

identifier 和 Idempotency-Key 分别管什么?

identifier 管业务事件去重,Idempotency-Key 管一次 HTTP POST 的安全重试。两者可以从同一个 usage_event_id 派生,但不要把它们理解成同一个东西。

标识放在哪里主要用途保留多久
source_event_id本地数据库防止同一业务请求入账两次永久或按审计周期归档
stripe_identifierStripe meter event让重复 meter event 被识别Stripe 文档按滚动 24 小时执行唯一性
Idempotency-KeyStripe POST 请求头网络断开后重试同一请求Stripe 文档说明至少 24 小时后可能清理
replay_batch_id本地重放任务说明哪次补批影响了哪些事件和事故记录一起保留

Stripe idempotency 文档还有一个容易忽略的点:同一个 key 的第一次结果会被保存,包括失败结果。也就是说,遇到连接超时可以用相同 key 重试;但参数已经错了,就不要继续拿同一个 key 反复打。

本地唯一键才是长期防线。一个月后客服发现重复收费,Stripe 的 identifier 窗口不能替你解释历史;你要靠 source_event_idmeter_codecustomer_idoccurred_at 把两条记录串起来。

迟到事件还能补到哪一天?

Stripe meter event 的 timestamp 不能随便写。官方口径是过去 35 个日历日以内,未来最多 5 分钟;未来 5 分钟是给服务器和 Stripe 系统之间的时钟漂移,不是让你提前造 usage。

补报时不要把 timestamp 改成当前时间。真实发生时间是客户解释、月度对账和重放窗口的核心证据。occurred_at 超过 35 天的事件,先标成 late_rejected,进入差异表,不要自动换时间重发。

一个实用分支是:

  1. occurred_at 在 35 天内,事件未发过:进入补批 dry-run。
  2. occurred_at 在 35 天内,但本地已有 sent:按重复事件处理。
  3. occurred_at 超过 35 天:不自动发 Stripe,生成客户、meter、金额影响和原因说明。
  4. occurred_at 接近账期结束:先小批量重放,再等 Stripe 异步处理结果进入 meter summary。

如果事故跨过账期,工程侧只给事实,不替财务下结论。哪些差异要调整 invoice、退款、人工减免或只做备注,交给你的账务和合规流程处理。

错发以后还能撤回吗?

Stripe 的 Meter Event Adjustment 可以按 identifier 取消单个 meter event,但窗口是 Stripe 收到事件后的 24 小时内。它不是通用撤销键,也不是改历史账单的后门。

Stripe 的 meter 配置文档还提醒:如果取消的 usage 已经包含在 finalized invoice 里,Stripe 不会因为这次取消自动更新那张 invoice,也不会为已发送给客户的 finalized invoice 生成更正发票。这里不要继续猜账务动作,先把事实包整理出来。

错发事故按这张表走:

情况可做动作不该做的事
24 小时内、错客户用原 identifier 创建 adjustment,标记本地 adjustment_pending换客户再发一条抵消事件解释
24 小时内、错 meter取消原事件,再由新 source_event_id 派生正确 identifier复用旧 identifier 改 payload
超过 24 小时、未出账进入差异表,按账期影响评估在生产库直接删原记录
已 finalized invoice准备证据包给财务和客服承诺系统会自动改历史发票

负数用量也要谨慎。Stripe 文档允许用 negative quantities 修正错误数据,并说明周期总 usage 为负时 invoice line item usage quantity 会按 0 处理;这仍然是账务动作,不适合由补批脚本自动决定。

重放窗口怎么跑才不伤客户账单?

重放脚本第一版只做 dry-run。它输出客户、meter、事件数、raw_value 总和、billable_value 总和、最早和最晚 occurred_at,以及将要使用的 identifier 列表。dry-run 数字和你预期不一致时停下,不要让脚本边跑边解释。

实际补批按 4 个闸口推进:

  1. prepare:锁定事故时间窗和事件类型,生成 replay_batch_id
  2. canary:只发 1-3 个低风险客户,确认没有 duplicate_meter_eventtimestamp_too_far_in_pastmeter_event_invalid_value
  3. batch:分批发送,记录 Stripe request id、HTTP status、错误码和 worker 版本。
  4. close:等异步处理后的 error event、meter summary 或 upcoming invoice 信号,再关闭批次。

普通 Meter Event endpoint live mode 口径是 1000 calls/sec;高吞吐场景可以评估 API v2 meter event streams。一个人 SaaS 的补账任务通常先限速到远低于上限,避免 409 too_many_concurrent_requests 和后台队列堆积把问题扩大。

客服要给客户看哪些证据?

客户问账单时,不要把数据库截图、完整 payload 或内部密钥发出去。客服需要的是可解释链路:哪次产品动作产生 usage,产品侧怎么算成 billable value,什么时候发给 Stripe,是否属于某次重放批次。

建议客服后台只展示这些字段:request_id、用户可见任务名、发生时间、meter_code、原始值、计费值、Stripe identifier、重放批次、处理状态。涉及模型成本、内部倍率、风控字段和 secret 的内容不进客服页面。

事故处理时会同时打开 Stripe Dashboard、Workbench、队列监控、数据库只读面板和客服邮箱。远程值班的人如果反复换设备、换浏览器会话,证据链很容易断;可以把 Stripe、Vercel、Cloudflare 的后台操作固定在同一套值班环境里,并用Stripe Dashboard 稳定访问承载核心后台查看和截图留存。

什么时候暂停自动补账?

出现下面任一情况,补批 worker 先停:账期已 finalized、客户已发起争议、同一批事件同时涉及错客户和错 meter、Stripe 返回的错误码不是单一类型、客服无法解释差异来源。

暂停不是放弃处理,而是把工程任务切成证据任务。把受影响客户、事件、identifier、发生时间、发送时间、错误码、invoice 状态和人工动作列成一张表,再决定谁来处理账务结论。

这篇只处理 usage-based billing 的工程侧事实:幂等、迟到、撤回窗口、补批和客服证据。税务归类、收入确认、退款、credit note 或合同补偿不在这里下结论。

相关阅读