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.updatedcheckout.updated 里看到 checkout status 变成 succeeded,也不能代替 order.paid

Polar 对象或事件本地主表唯一键主要用途不该承担的事
customer.createdcustomer.updatedcustomer.state_changedbilling_customerspolar_customer_id / external_customer_id把 Polar customer 和本地 user、workspace 绑定不直接代表某笔付款成功
checkout.createdcheckout.updatedcheckout.expiredbilling_checkoutspolar_checkout_id记录支付页状态、metadata、回跳来源不直接开通长期权益
subscription.createdsubscription.updatedsubscription.past_duebilling_subscriptionspolar_subscription_id记录订阅周期、状态、续费和取消不保存每一次付款明细
order.createdorder.paidbilling_orders / billing_paymentspolar_order_id记录订单、付款完成和 billing_reason不替代订阅生命周期
granted benefits / customer stateentitlement_snapshotspolar_customer_id + benefit_key保存当前可用功能、席位、宽限期不处理税务、发票或合同解释
delivery 失败和本地 worker 失败billing_event_attemptsevent_id + attempt_no记录重试、错误和补跑结果不直接改业务状态

创建 checkout 时,external_customer_id 要尽早传进去,用它把 Polar customer 和本地用户固定住。Polar 的 customer 文档说明 external id 是你系统里的客户标识;一旦写乱,后面靠邮箱合并会很痛苦,尤其是团队 workspace、邀请成员和企业采购邮箱不一致时。

checkout 已回跳,为什么还不能开通权益?

Checkout 是「客户走到付款页面并完成一段支付流程」,不是完整的订阅账本。Polar checkout 文档里的状态包括 openexpiredconfirmedsucceededfailed;其中 confirmed 更接近用户确认支付动作,不等于产品已经拿到可长期信任的权益状态。

更稳的开通顺序是:

  1. checkout.updated 到达且 checkout status 为 succeeded 时,只把 billing_checkouts.status 改成成功路径,并记录 polar_customer_idpolar_subscription_idmetadata
  2. order.paid 到达后,写 billing_orders.paid_at、金额、币种、billing_reason,再触发一次权益刷新。
  3. subscription.activesubscription.updated 到达后,写订阅周期、当前状态、取消时间、下次账期。
  4. 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_opencheckout.createdcheckout id、customer id、metadata不开通,只显示等待付款
checkout_completedcheckout.updated 且 status 为 succeededcheckout status、回跳时间提示处理中,不作为最终授权
payment_paidorder.paidorder id、amount、currency、billing_reason触发订单对账和权益刷新
subscription_activesubscription.active / subscription.updatedcurrent period、status、product开通或延长订阅权益
subscription_past_duesubscription.past_duepast_due_at、retry window进入宽限期,不立即删数据
entitlement_activecustomer state / benefits 变化benefit key、active、source_event_id更新功能开关和席位上限
entitlement_revokedsubscription.revoked / benefits 变化revoked_at、reason关闭付费入口,保留历史记录

权限表只保存产品运行时真正要读的内容,比如 api_accessteam_workspaceseat_limitpriority_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 缺依赖、权限表写入失败已经 2xxbilling_event_attempts 自己重试

billing_event_attempts 至少要有 event_idattempt_nostarted_atfinished_atresulterror_codeerror_messageworker_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_idstripe_subscription_id 这种字段名继续复制。建议用 provider_customer_idpolar_customer_id 明确来源,后面真要做 Stripe + Polar 双通道,也不会把两个平台的事件混进同一条状态机。

如果你是从 Stripe 迁移,先保留原有订单和权益表,只新增 Polar event ingest 和映射层。等一个完整账期跑完,确认新老客户的 checkout、order、subscription 和 entitlement 对账一致,再把写路径切过去。

相关阅读