Polar Checkout 回跳到 success_url 之后,客户说已经付款,产品后台却没有开通权限,这时最容易写错的代码是:收到 checkout.updated 就直接改用户套餐。Polar 的 checkout、customer、subscription、order 和 benefits 是不同层级;把它们塞进一个 paid=true 字段,后面续费失败、退款、取消、补跑都会变成手工查账。
2026 年 5 月 27 日查看 Polar 文档时,webhooks 采用 Standard Webhooks 风格,delivery 页面可以看历史投递、payload 和重新投递,失败投递最多按指数退避重试 10 次,单次请求 10 秒超时,文档建议 endpoint 在 2 秒内响应。一个人 SaaS 的默认做法应该是:HTTP handler 只验签、落 event id、入队列,真正改 customer、subscription 和 entitlement 的动作放到 worker。
这篇只处理 Polar 事件入库和订阅状态机,不把 Polar 写成 Stripe 的绝对替代品。如果你已经有成熟的折扣、发票、财务报表或多支付网关逻辑,先把本地账本抽象出来,再决定是不是迁移到 Polar。
customer、checkout、subscription 各自落哪张表?
Polar 事件入库的第一条规则:事件表保存「发生过什么」,业务表保存「当前状态是什么」。customer.state_changed 可以帮助你刷新客户汇总,但它不能代替 subscription.updated;checkout.updated 里看到 checkout status 变成 succeeded,也不能代替 order.paid。
| Polar 对象或事件 | 本地主表 | 唯一键 | 主要用途 | 不该承担的事 |
|---|---|---|---|---|
customer.created、customer.updated、customer.state_changed | billing_customers | polar_customer_id / external_customer_id | 把 Polar customer 和本地 user、workspace 绑定 | 不直接代表某笔付款成功 |
checkout.created、checkout.updated、checkout.expired | billing_checkouts | polar_checkout_id | 记录支付页状态、metadata、回跳来源 | 不直接开通长期权益 |
subscription.created、subscription.updated、subscription.past_due | billing_subscriptions | polar_subscription_id | 记录订阅周期、状态、续费和取消 | 不保存每一次付款明细 |
order.created、order.paid | billing_orders / billing_payments | polar_order_id | 记录订单、付款完成和 billing_reason | 不替代订阅生命周期 |
| granted benefits / customer state | entitlement_snapshots | polar_customer_id + benefit_key | 保存当前可用功能、席位、宽限期 | 不处理税务、发票或合同解释 |
| delivery 失败和本地 worker 失败 | billing_event_attempts | event_id + attempt_no | 记录重试、错误和补跑结果 | 不直接改业务状态 |
创建 checkout 时,external_customer_id 要尽早传进去,用它把 Polar customer 和本地用户固定住。Polar 的 customer 文档说明 external id 是你系统里的客户标识;一旦写乱,后面靠邮箱合并会很痛苦,尤其是团队 workspace、邀请成员和企业采购邮箱不一致时。
checkout 已回跳,为什么还不能开通权益?
Checkout 是「客户走到付款页面并完成一段支付流程」,不是完整的订阅账本。Polar checkout 文档里的状态包括 open、expired、confirmed、succeeded、failed;其中 confirmed 更接近用户确认支付动作,不等于产品已经拿到可长期信任的权益状态。
更稳的开通顺序是:
checkout.updated到达且 checkout status 为succeeded时,只把billing_checkouts.status改成成功路径,并记录polar_customer_id、polar_subscription_id、metadata。order.paid到达后,写billing_orders.paid_at、金额、币种、billing_reason,再触发一次权益刷新。subscription.active或subscription.updated到达后,写订阅周期、当前状态、取消时间、下次账期。- customer 汇总状态或 benefits 变化到达后,覆盖
entitlement_snapshots。
这样设计会多几张表,但事故时能少很多猜测。客服看到的不是「用户是不是 paid」,而是「checkout 成功了没有、order 是否 paid、subscription 当前是不是 active、entitlement 有没有写入产品库」。
event-table schema:先把每个 event id 记下来
data.id 不要直接当 event id。data.id 可能是 checkout id、customer id、subscription id 或 order id;event id 是 webhook 事件本身的去重键。Polar 的 Events API 和 webhook delivery 视图能帮你按事件维度查历史,产品库也要保留同一层。
create table billing_events (
event_id text primary key,
provider text not null default 'polar',
event_type text not null,
event_timestamp timestamptz not null,
object_type text,
object_id text,
polar_customer_id text,
external_customer_id text,
checkout_id text,
subscription_id text,
order_id text,
status text not null default 'received',
attempts int not null default 0,
next_retry_at timestamptz,
last_error text,
payload jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);
HTTP handler 的代码路径保持很短:读取原始 body,验签,解析事件类型,把 payload 和可提取的对象 id 写进 billing_events,再发一条内部队列消息。插入时用 event_id 唯一键挡重复投递;如果同一个 event id 已经存在,直接返回 2xx。
数据库不可用时不要假装成功。Polar 会重试失败投递;如果你在没有落库的情况下返回 2xx,平台会认为这次投递已经处理完,后面只能靠人工从 delivery 页面或 Events API 补拉。
worker 怎么更新 payment 和 entitlement 状态?
worker 不要按事件到达顺序相信世界是线性的。订阅系统里常见顺序是 checkout 先到、order 后到、subscription 再更新;也可能同一事件被 delivery 重发,或者你从后台手工 redeliver 一次。
| 本地状态 | 触发事件 | 写入字段 | 产品动作 |
|---|---|---|---|
checkout_open | checkout.created | checkout id、customer id、metadata | 不开通,只显示等待付款 |
checkout_completed | checkout.updated 且 status 为 succeeded | checkout status、回跳时间 | 提示处理中,不作为最终授权 |
payment_paid | order.paid | order id、amount、currency、billing_reason | 触发订单对账和权益刷新 |
subscription_active | subscription.active / subscription.updated | current period、status、product | 开通或延长订阅权益 |
subscription_past_due | subscription.past_due | past_due_at、retry window | 进入宽限期,不立即删数据 |
entitlement_active | customer state / benefits 变化 | benefit key、active、source_event_id | 更新功能开关和席位上限 |
entitlement_revoked | subscription.revoked / benefits 变化 | revoked_at、reason | 关闭付费入口,保留历史记录 |
权限表只保存产品运行时真正要读的内容,比如 api_access、team_workspace、seat_limit、priority_support。Polar 的 benefits 可以提供授权来源,但产品仍要决定菜单显示、限额、缓存刷新和客服备注。
subscription.past_due、order.paid 和续费事件怎么排队?
订阅续费不要只盯 subscription.updated。Polar 文档里,订阅周期会生成新的 order;order.created 可以通过 billing_reason=subscription_cycle 表示续费周期,真正收款完成再看 order.paid。这能把「订阅该续费了」和「钱已经收到」分开。
付款失败时,subscription.past_due 是产品状态机的关键分支。Polar failed payments 文档写了固定重试节奏:2 天、5 天、7 天、7 天,最多约 21 天;还支持配置 benefits grace period。你可以给客户几天缓冲,但这个缓冲必须落在 entitlement_snapshots.grace_until,不能藏在客服口头约定里。
默认策略可以这样定:past_due 到达后,产品继续保留核心数据读取,暂停高成本动作,例如批量导出、API 高并发、团队新增席位;order.paid 补到后恢复权益;宽限期结束仍未付款,再把 entitlement 标为 revoked。这样不会因为一次银行卡失败就立刻破坏客户工作区。
重放和失败重试要留哪些字段?
Polar 自己会做 delivery retry,但它只负责把事件送到你的 endpoint;worker 处理失败、数据库死锁、第三方邮件超时、缓存刷新失败,都要你自己的队列处理。
建议把本地失败分成三层:
| 层级 | 典型失败 | 返回给 Polar | 本地处理 |
|---|---|---|---|
| endpoint 验签失败 | secret 错、raw body 被改 | 4xx | 不入队,记录安全日志 |
| endpoint 落库失败 | 数据库不可用、唯一键异常 | 5xx | 让 Polar delivery retry |
| worker 业务失败 | subscription 缺依赖、权限表写入失败 | 已经 2xx | billing_event_attempts 自己重试 |
billing_event_attempts 至少要有 event_id、attempt_no、started_at、finished_at、result、error_code、error_message、worker_version。补跑脚本不要直接改订单,还是把事件放回同一条 worker 队列,让幂等逻辑统一生效。
事故窗口补跑时,只按三类条件筛选:event type、发生时间、业务对象 id。dry-run 先打印将处理的 event id、customer、subscription、order,数量对不上就停。不要在用户催单时边改数据库边点 redeliver,那样最容易制造重复权益。
Polar 和 Stripe 的分工怎么讲清?
Polar 对独立开发者友好的地方,是 checkout、subscriptions、customer management、benefits 和 MoR 能放在一套系统里接;但它不是 Stripe 的绝对替代品。已经在旧支付系统里跑通的风控、折扣、企业账单、财务导出和历史集成,不会因为换 Polar webhook 就自动消失。
如果你是新 SaaS,可以按 Polar 对象重新建表,不要把 stripe_customer_id、stripe_subscription_id 这种字段名继续复制。建议用 provider_customer_id 或 polar_customer_id 明确来源,后面真要做 Stripe + Polar 双通道,也不会把两个平台的事件混进同一条状态机。
如果你是从 Stripe 迁移,先保留原有订单和权益表,只新增 Polar event ingest 和映射层。等一个完整账期跑完,确认新老客户的 checkout、order、subscription 和 entitlement 对账一致,再把写路径切过去。