Supabase Edge Functions 集成 Stripe Webhooks:签名校验与本地联调的完整实战指南
本篇指南围绕 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 | 本地测试与部署的快速操作说明(本文扩展的对象) |
同时在仓库中配合使用的还有两份关键配置:
- examples/edge-functions/supabase/.env.local.example(第 40–42 行定义了 Stripe 相关环境变量模板);
- examples/edge-functions/supabase/config.toml(第 98–99 行为该函数声明了
verify_jwt = false)。
从结构上看,这是典型的"处理外部回调、不使用 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.toml 中 verify_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 400。constructEventAsync 内部用签名密钥对报文头 Stripe-Signature 与原始 body 做 HMAC 比对;任何篡改、过期时间戳或密钥不匹配都会抛错,此时返回 400 可引导 Stripe 按重试策略处理。校验通过后,receivedEvent 即为结构化的事件对象,字段 receivedEvent.id、receivedEvent.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.local 的 STRIPE_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
两条命令的职责不同:
supabase functions deploy --no-verify-jwt stripe-webhooks:将函数发布为线上可调用端点。与serve一致,--no-verify-jwt是关键参数——如果部署时省略它,网关层默认开启 JWT 校验,Stripe 的请求会被一律 401 拒绝;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-bot、cloudflare-turnstile)都采用了同样的"第三方回调场景关闭 JWT"模式,可对比参考。
常见坑位与排查速查
| 现象 | 根因 | 处理 |
|---|---|---|
| 返回 400,报签名不匹配 | .env.local 中 STRIPE_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") 之后的用意所在。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00