首页
/ Supabase Edge Functions 集成 Stripe Webhooks:签名校验与本地联调的完整实战指南

Supabase Edge Functions 集成 Stripe Webhooks:签名校验与本地联调的完整实战指南

2026-09-06 18:30:28作者:吴年前Myrtle

本篇指南围绕 stripe-webhooks 示例 展开,讲解如何在 Supabase Edge Functions(基于 Deno 运行)中接收并校验 Stripe Webhook 事件,覆盖从环境变量配置、本地三端联调到 verify_jwt 关闭与云端部署的完整链路。读完本文你将能独立搭建一套无需自建服务器即可安全响应 payment_intent.succeeded 等 Stripe 事件的函数,并掌握原始报文读取与签名验真的底层原理。

示例概览:目录结构与角色分工

该示例位于当前仓库的 examples/edge-functions/supabase/functions/stripe-webhooks 目录,仅由两个文件构成,职责非常清晰:

文件 作用
index.ts Edge Function 的唯一入口,实现事件接收、签名校验与响应
README.md 本地测试与部署的快速操作说明(本文扩展的对象)

同时在仓库中配合使用的还有两份关键配置:

从结构上看,这是典型的"处理外部回调、不使用 Supabase Auth JWT"场景:Stripe 作为第三方服务无法携带 Supabase 签发的令牌发起请求,因此该函数绕开了默认的 JWT 网关校验,改用 Stripe 自身的 Stripe-Signature 报文头完成鉴权。

第一步:配置环境变量

原文档要求先创建本地环境变量文件,命令如下:

cp supabase/.env.local.example supabase/.env.local

请在仓库根目录(即 examples/edge-functions)下执行。复制后,需要在 .env.local 中为 stripe-webhooks 填写两个关键变量。模板(见 .env.local.example 第 40–42 行)预置的内容为:

# stripe-webhooks
STRIPE_API_KEY="<YOUR API KEY HERE>"
STRIPE_WEBHOOK_SIGNING_SECRET="<YOUR WEBHOOK SIGNING HERE>"

两个变量的取值来源:

  • STRIPE_API_KEY:在 Stripe Dashboard 的 "Developers → API keys" 中获取的 Secret key(形如 sk_test_...)。本地联调时建议使用 sk_test_ 开头的测试密钥,避免影响真实账单数据;
  • STRIPE_WEBHOOK_SIGNING_SECRET:运行 stripe listen 时终端会打印一个 whsec_ 开头的签名密钥,或从 Stripe Dashboard 的 Webhook 详情页中复制。该密钥用于服务端验签。

对应到源码(index.ts),两个变量在初始化时分别被读取:

const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
// ...后续验签时读取:
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!

注意该示例使用顶层 const 在模块加载期就读取密钥(index.ts)。若密钥缺失,构造函数会收到 undefined 而抛错,因此务必先完成本步再启动服务。

通用部署经验(同样适用于本函数):本地 .env.local 与云端生产密钥不应混用。推荐的惯例是本地用 .env.local 保存测试密钥,另准备一份仅存放生产密钥的文件专门用于 supabase secrets set,避免测试密钥泄漏到线上。

第二步:读懂核心源码——签名校验如何工作

示例入口源码虽然只有 30 余行,却浓缩了 Stripe Webhook 集成的全部关键点。逐段拆解如下(完整代码见 index.ts):

import Stripe from 'npm:stripe@^22'
import { withSupabase } from 'npm:@supabase/server@^1'

const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
// This is needed in order to use the Web Crypto API in Deno.
const cryptoProvider = Stripe.createSubtleCryptoProvider()

要点一:Edge Function 通过 npm: 前缀直接导入 npm 包。此处使用了 stripe 官方 SDK v22,以及 @supabase/server 提供的 withSupabase 包装器。源码第 8 行用 API Key 实例化客户端;第 10 行创建了 createSubtleCryptoProvider(),其作用是让验签过程复用 Deno/Web 标准的 SubtleCrypto API——注释明确指出这是 Deno 环境下使用 Web Crypto 的必需步骤。

export default {
  fetch: withSupabase({ auth: 'none' }, async (req) => {
    const signature = req.headers.get('Stripe-Signature')
    const body = await req.text()

要点二:withSupabase({ auth: 'none' }, ...) 显式声明不使用 Supabase Auth。这与 config.tomlverify_jwt = false 的配置互为呼应——后者控制平台网关层是否强制校验 JWT,前者控制运行时是否注入用户身份。二者都关闭后,函数完全以"裸 HTTP 端点"的方式暴露给 Stripe。

要点三:必须用 req.text() 读取原始请求体index.ts)。源码注释强调:签名校验依赖的是未被解析过的原始报文,若先用 req.json() 解析再序列化回字符串,任何空白字符或字段顺序变化都会导致签名验算失败。

let receivedEvent
try {
  receivedEvent = await stripe.webhooks.constructEventAsync(
    body,
    signature!,
    Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
    undefined,
    cryptoProvider
  )
} catch (err) {
  return new Response(err.message, { status: 400 })
}
console.log(`🔔 Event received: ${receivedEvent.id}`)
return Response.json({ ok: true })

要点四:验签失败返回 HTTP 400constructEventAsync 内部用签名密钥对报文头 Stripe-Signature 与原始 body 做 HMAC 比对;任何篡改、过期时间戳或密钥不匹配都会抛错,此时返回 400 可引导 Stripe 按重试策略处理。校验通过后,receivedEvent 即为结构化的事件对象,字段 receivedEvent.idreceivedEvent.type 可用于后续分发。

从"只确认不处理"到"真正执行业务"

当前示例校验成功后仅返回 { ok: true },属于最小可运行骨架。要在真实支付链路中使用,可仿照官方推荐的模式在 console.log 之后按事件类型分发:

switch (receivedEvent.type) {
  case 'payment_intent.succeeded':
    const paymentIntent = receivedEvent.data.object
    // 在此落库订单、发放权益或写入审计日志
    break
  case 'checkout.session.completed':
    // 处理订阅/一次性结账成功
    break
  default:
    console.log(`Unhandled event type: ${receivedEvent.type}`)
}

如需回写数据库,可结合本仓库其他示例(如 postgres-on-the-edge)的思路,通过连接池与事务模式安全写入;该方向不在本文扩展范围内,仅作提示。若你的客户端是 React Native (Expo) 或 Flutter,Supabase 官方另有配套的完整 Stripe 支付端到端示例,可作为"客户端发起支付 + 服务端收 webhook 履约"全链路的学习参照。

第三步:本地三端联调

原文档给出了一套三个终端的本地联调方案,这也是调试 webhook 最顺手的工作流:

终端 1 —— 启动 Edge Function(需先确保 Docker 与本地 Supabase 已运行):

supabase functions serve --no-verify-jwt --env-file ./supabase/.env.local
  • --no-verify-jwt:本地同样跳过 JWT 网关校验,与 Stripe 无法携带 Supabase 令牌的实际情况对齐(在本地服务模式下,若不传该参数,未带有效 JWT 的请求会被拦下);
  • --env-file ./supabase/.env.local:显式加载上一步复制并填写的环境变量。

终端 2 —— 启动 Stripe CLI 将线上事件转发到本地:

stripe listen --forward-to localhost:54321/functions/v1/

54321 是本仓库 config.toml[api] 声明的本地 API 端口。命令执行后 Stripe CLI 会打印 whsec_... 签名密钥——把它填入 .env.localSTRIPE_WEBHOOK_SIGNING_SECRET 后需重启终端 1 的函数进程才能生效。Stripe CLI 会在本地额外起一个 http://localhost:12111 状态页面,方便观察每一条转发记录。

终端 3(可选)—— 触发一条测试事件:

stripe trigger payment_intent.succeeded

stripe trigger 会模拟真实支付流程并发出对应事件。若想把整条链路变成可重复的回归手段,也可以直接携带真实事件样例用 curl 替代:先 stripe listen --print-json 拿到带签名的请求结构,再向函数地址发起 POST。

验证方式:事件到达时,终端 1 会打印 🔔 Event received: evt_...,终端 2 显示该请求转发成功(200);若签名密钥或 body 处理有误,终端 1 打印错误消息并返回 400,终端 2 会标记失败并展示重试状态。

第四步:云端部署

本地验证通过后按原文档部署到云端:

supabase functions deploy --no-verify-jwt stripe-webhooks
supabase secrets set --env-file ./supabase/.env.local

两条命令的职责不同:

  1. supabase functions deploy --no-verify-jwt stripe-webhooks:将函数发布为线上可调用端点。与 serve 一致,--no-verify-jwt 是关键参数——如果部署时省略它,网关层默认开启 JWT 校验,Stripe 的请求会被一律 401 拒绝
  2. supabase secrets set --env-file ./supabase/.env.local:把本地环境变量批量写入云端的函数机密存储,供 Deno.env.get 在运行时读取。

部署完成后,需要回到 Stripe Dashboard 把 Webhook 端点 URL 指向线上地址,格式为:

https://<project-ref>.supabase.co/functions/v1/stripe-webhooks

随后 Stripe 会发送一次测试事件验证端点可达性。云端生效后可用 supabase secrets list 复查已设置的密钥,并确认端点可返回 200。

关于 verify_jwt 的配置落点

除部署参数外,仓库已在项目级配置中为云端/本地一致性预留了声明式入口。在 config.toml 第 98–99 行可以看到:

[functions.stripe-webhooks]
verify_jwt = false

这意味着该函数的 JWT 校验状态同样固化在 config.toml 中。把它与 supabase functions deploy(不带标志)配合使用,也能保证部署结果一致。两种方式二选一即可,但建议以 config.toml 为准,便于团队协作与 CI 复现。整个 edge-functions 示例集中大量回调类函数(如 telegram-botcloudflare-turnstile)都采用了同样的"第三方回调场景关闭 JWT"模式,可对比参考。

常见坑位与排查速查

现象 根因 处理
返回 400,报签名不匹配 .env.localSTRIPE_WEBHOOK_SIGNING_SECRET 与当前 stripe listen 会话不一致 每启动一次 stripe listen 都会生成新的 whsec_,更新后重启函数进程
本地联调收不到事件 Stripe CLI 未安装或 stripe listen 未运行 先装 Stripe CLI,确认终端 2 已就绪并显示打印的密钥
线上请求全部 401 部署时省略了 --no-verify-jwt,或 config.toml 未声明 重新执行带标志的 deploy,或确认 [functions.stripe-webhooks] verify_jwt = false
直接 req.json() 后验签必败 签名绑定的是原始字节流 改为 await req.text() 并原样传给 constructEventAsync
云端提示密钥为空 只 deploy 未执行 secrets set 执行 supabase secrets set --env-file ./supabase/.env.local 后用 secrets list 复查

值得重申的核心结论:该函数的安全边界不是 Supabase 的 JWT 网关,而是 Stripe 的报文签名机制。只要 STRIPE_WEBHOOK_SIGNING_SECRET 不被泄露、验签流程严格使用原始 body,就能确认请求确实来自 Stripe;反之,关闭 JWT 校验同时意味着任何人只要知道端点 URL 就能触发该函数,因此不要在验签通过前执行任何有副作用的业务逻辑——这正是源码把数据库写入、订单履约等动作刻意留在 console.log("🔔 Event received") 之后的用意所在。

登录后查看全文
热门项目推荐
相关项目推荐