# Next.js 订阅计费：真正能用的 Webhook 契约

> 幂等、验签、套餐权限——教程跳过的部分，再加一个无需支付账户就能跑的离线探测脚本。

- Author / publisher: make1day (company)
- Published: 2026-03-21
- Updated: 2026-11-07
- Canonical: /blog/nextjs-subscription-billing
- Category: Billing
- Tags: Next.js subscription billing boilerplate, Stripe subscriptions Next.js, SaaS template
- Language: zh

多数订阅计费教程止步于顺利路径：创建结账会话、跳转、结束。生产级计费恰恰活在那些被跳过的地方。

## Webhook 才是真相来源

浏览器跳转会骗人：用户关掉标签页、支付商重试、测试触发两次。数据库必须是唯一真相来源，且只由已验签的 Webhook 更新。

验签契约值得写清楚，因为写反了就是安全漏洞：

- 签名缺失、无效或格式损坏 → 抛错，路由返回 400。
- 合法已验签但无需处理的事件 → 返回空列表，路由返回 200 并标记 ignored。
- 已见过的事件 id → 写库前直接跳过。

本仓库每个适配器都遵守这份契约。早期版本在验签失败时也返回空列表，等于用 200 确认了伪造请求，别再退回去。

## 权限在服务端，不在界面

永远不要信任客户端。每个受保护路由都从会话或数据库读套餐；套餐未激活时 AI 接口返回 402——哪怕有人在 devtools 里改了 DOM。

## 预算和配额是两件事

订阅状态不等于用量限制。AI 接口另有每用户每日配额（AI_MESSAGES_PER_DAY，默认 50），由 Supabase RPC 或本地 SQLite 的 ai_usage 表扣减。用尽返回 429，带 X-AI-Quota-Reset 与按真实剩余秒数计算的 Retry-After，不是写死的 86400。

## 离线也能测

验证这份契约不需要六个沙箱账户。仓库提供离线验签探测（npm run probe:webhook），向运行中的服务发送合法、缺失、篡改、损坏四种请求并断言状态码。上线前仍要跑沙箱端到端，但安全不变量可以在 CI 里守住。四层测试策略见 docs/payment-testing.md。

## FAQ

### Webhook 怎么做幂等？

把支付商事件 id 记进 webhook_events 表，在写套餐或 license 之前先查。验签在最前面，只有合法且未处理的事件才进入去重步骤。

### 402 和 429 有什么区别？

402 表示没有下载凭据（无有效授权码、订阅或邮箱认领）。429 表示 AI 每日消息配额用尽。预览额度耗尽是另一个 402，错误码为 preview_limit_reached。

### 没有沙箱账户能测六个通道吗？

共用验签契约可以用 npm run probe:webhook 离线测；每个通道的真实结账流程上线前仍需各自的沙箱凭据。所需环境变量见 docs/payment-testing.md。

---

Source: make1day. See /llms.txt and /llms-full.txt for the full machine-readable product description.
