Stripe webhook signing secret 轮换最怕两件事:只改了一个生产环境变量,却忘了另一个 endpoint;或者旧 secret 还在过期窗口里,服务端已经删掉旧值,结果一批 checkout.session.completed 变成 400。一个人维护 SaaS 时,支付回调通常没人值夜班,轮换要按可回滚的发布来做。

多 endpoint 要先列哪张清单?

打开 Stripe Workbench -> Webhooks,把 live mode 和 test mode 分开列。Stripe 文档里写得很清楚:每个 endpoint 有自己的 secret;同一个 URL 如果在 test 和 live 都建了 endpoint,secret 也不同。Connect 平台还可能有 account scope 和 connected accounts scope,不要拿一个 whsec_... 验所有入口。

Endpoint环境监听事件当前 secret 名称轮换顺序放弃条件
/api/stripe/webhooklivecheckout.session.completedinvoice.paidSTRIPE_WEBHOOK_SECRET_LIVE_PRIMARY第一批最近 30 分钟有签名失败
/api/stripe/connectliveaccount.updatedcapability.updatedSTRIPE_CONNECT_WEBHOOK_SECRET_PRIMARY第二批Connect 事件依赖没写幂等
/api/stripe/webhooktest同 live 子集STRIPE_WEBHOOK_SECRET_TEST_PRIMARY发布前演练staging 没有独立数据库
/api/stripe/meterlivev1.billing.meter.*STRIPE_METER_WEBHOOK_SECRET_PRIMARY最后worker backlog 未清空

清单里不要写真实 secret,只写环境变量名、endpoint ID、URL、事件类型和负责人。真正的 whsec_... 留在 secret manager 里;截图也别贴到 Linear、Notion 或客服群。

服务端怎样同时接受新旧 secret?

先改代码,再点 Stripe 的 Roll secret。Stripe 官方库验证签名时需要 raw body、Stripe-Signature header 和 endpoint secret。轮换窗口里可以把旧 secret 和新 secret 都放进数组,逐个尝试;任何一个通过,就用同一套业务幂等逻辑处理事件。

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const webhookSecrets = [
  process.env.STRIPE_WEBHOOK_SECRET_LIVE_PRIMARY,
  process.env.STRIPE_WEBHOOK_SECRET_LIVE_PREVIOUS,
].filter(Boolean) as string[];

export async function POST(request: Request) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return new Response("Missing Stripe-Signature", { status: 400 });
  }

  let event: Stripe.Event | null = null;
  let matchedSecretIndex = -1;

  for (const secret of webhookSecrets) {
    try {
      event = stripe.webhooks.constructEvent(rawBody, signature, secret);
      matchedSecretIndex = webhookSecrets.indexOf(secret);
      break;
    } catch {
      continue;
    }
  }

  if (!event) {
    return new Response("Webhook signature verification failed", { status: 400 });
  }

  await recordStripeEvent({
    eventId: event.id,
    eventType: event.type,
    matchedSecret: matchedSecretIndex === 0 ? "primary" : "previous",
  });

  return new Response("ok", { status: 200 });
}

这里的 recordStripeEvent() 必须按 event.id 去重。不要把「通过了哪个 secret」当成业务状态;它只用于轮换观察。事件处理仍然按 checkout.session.idinvoice.idsubscription.id 这些 Stripe 对象做订单和权益幂等。

Workbench 里怎么灰度 Roll secret?

生产默认选延迟旧 secret 过期,而不是立即过期。Stripe 的 Roll secret 支持马上让旧 secret 失效,也支持把旧 secret 的过期时间延迟到最多 24 小时;这段时间里 endpoint 有多个 active secrets,Stripe 会为每个 secret 生成签名。

阶段Stripe 动作代码状态观察信号下一步
T-1staging 建新 secret双 secret 验签已上线test event 200准备 live
T0live endpoint 点 Roll secretPRIMARY=newPREVIOUS=old新 secret 命中开始出现继续观察
T+15min不点 resend,等自然事件双 secret 保留Event deliveries 是 Delivered处理下一 endpoint
T+2h低峰期抽查订单双 secret 保留签名失败为 0准备旧 secret 退场
T+24h 前旧 secret 到期或手动收尾删除 PREVIOUS只剩 primary 命中完成轮换

灰度不要按「所有 endpoint 一起点」推进。先处理最小流量的 test 或 staging,再处理 live 的低风险 endpoint,最后处理 Checkout、Billing、Connect 这种会影响开通和收款的 endpoint。每一步至少看一轮真实事件,没事件就用 Stripe CLI 或 Dashboard 触发测试事件,但不要把 CLI 打印的 whsec_... 混进生产。

旧 secret 什么时候可以退场?

旧 secret 退场要等三个信号同时满足:Stripe 的旧 secret 已过期或你已在 Workbench 确认失效;服务端 400 中没有新的签名失败;事件表里事故窗口内的关键事件都已经 processed 或有明确跳过原因。

# before rotation
STRIPE_WEBHOOK_SECRET_LIVE_PRIMARY=whsec_old_live_placeholder

# during rotation
STRIPE_WEBHOOK_SECRET_LIVE_PRIMARY=whsec_new_live_placeholder
STRIPE_WEBHOOK_SECRET_LIVE_PREVIOUS=whsec_old_live_placeholder

# after old secret expires
STRIPE_WEBHOOK_SECRET_LIVE_PRIMARY=whsec_new_live_placeholder
# STRIPE_WEBHOOK_SECRET_LIVE_PREVIOUS removed

删除旧环境变量后要重新部署一次,并保留部署版本号。最容易漏的是后台任务、Edge Function、队列 worker 和 monorepo 里的另一个 API route;它们可能共用同一个 webhook helper,也可能各自读取不同变量。

失败事件怎样重放才不乱?

如果轮换窗口里出现 400,不要在 Dashboard 里乱点 resend。Stripe 会自动重试未成功投递的 live mode webhook,官方文档给出的窗口是最多 3 天;Events API 还能列出最近 30 天的事件。手动处理未投递事件时,用 ending_beforetypes[]delivery_success=false 收窄范围。

stripe events list \
  --type checkout.session.completed \
  --type invoice.paid \
  --delivery-success=false \
  --ending-before evt_before_rotation_placeholder

脚本不要直接给用户开通权益。更稳的做法是把事件 ID 放回内部队列,让 worker 走同一套 event.id 去重和订单幂等。Stripe 文档也提醒,手动处理后自动重试仍可能继续到达;handler 收到已处理事件时,要跳过业务动作并返回 2xx。

create table stripe_webhook_events (
  event_id text primary key,
  endpoint_key text not null,
  event_type text not null,
  stripe_created_at timestamptz not null,
  status text not null check (status in ('received', 'processing', 'processed', 'skipped', 'failed')),
  matched_secret text,
  last_error text,
  received_at timestamptz not null default now()
);

轮换当天的重放范围按时间窗写死,例如 2026-04-15 02:00:00Z2026-04-15 04:00:00Z。dry-run 先输出 event id、event type、关联的 Checkout Session 或 invoice;数量不对就停,不要带着不完整清单跑生产。

团队值班时怎么避免复制错 secret?

把权限拆开:一个人能在 Stripe Workbench 点 Roll secret,另一个人负责 secret manager 和部署。两个人都不应该在聊天工具里粘贴完整 whsec_...。如果只能一个人操作,至少把 endpoint ID、环境变量名、部署版本和操作时间写在同一张变更单里。

配置命名要能一眼看出环境和用途:

STRIPE_WEBHOOK_SECRET_TEST_PRIMARY
STRIPE_WEBHOOK_SECRET_TEST_PREVIOUS
STRIPE_WEBHOOK_SECRET_LIVE_PRIMARY
STRIPE_WEBHOOK_SECRET_LIVE_PREVIOUS
STRIPE_CONNECT_WEBHOOK_SECRET_LIVE_PRIMARY
STRIPE_CONNECT_WEBHOOK_SECRET_LIVE_PREVIOUS

不要用 STRIPE_WEBHOOK_SECRET_2 这种名字。三个月后你不会记得它是新 secret、旧 secret、test secret,还是 Stripe CLI 的本地 secret。

这套轮换不处理哪些情况?

这篇只处理 Stripe webhook endpoint signing secret,不处理 Stripe API key 轮换、OAuth client secret、Connect 平台密钥托管,也不替代 Stripe 官方支持对账户异常的判断。不同框架读取 raw body 的方式不同,Next.js、Express、Rails、Lambda、Cloudflare Workers 都要按自己的运行时写测试。

如果你怀疑 secret 已泄露,别等低峰期。先把服务端发布到支持新 secret 的版本,再在 Workbench 选择立即过期旧 secret;随后按失败事件重放表把 400 时间窗补齐。这里的代价是短时间内更容易出现签名失败,所以事件账本和重放脚本要在紧急轮换前就准备好。

相关阅读