在 Supabase Edge Function 中接收 WhatsApp Webhook
Supabase Edge Function 可以直接作为号码的 Webhook 目的地。它用 hub.challenge 的值响应 Meta 的验证 GET 请求,把每条 POST 过来的消息写入 Postgres 表,并通过 Cloud API 回复。部署时请关闭 JWT 验证:Supabase 默认要求有效的 JWT,而 Meta 无法发送。
Supabase 对 Edge Function 的默认设置,会在您的处理程序运行之前拒绝任何没有有效 JWT 的请求。
supabase functions deploy 的参数,用于为某个函数关闭 JWT 验证。
Edge Function 的请求空闲超时;到时仍无响应则返回 504。
Edge Function 需要做什么?
两件事,分别对应两种方法。
收到 GET 时,它负责响应 Meta 的验证。从查询字符串中读取 hub.mode、hub.verify_token 和 hub.challenge,将验证令牌与您在目的地中填写的验证令牌比对,然后以纯文本正文返回 challenge,状态码为 200。令牌不匹配时返回 403。
收到 POST 时,它负责接收事件。解析请求体,从 entry、changes 和 value 中取出消息,写入表中,并返回 200。响应必须快:Meta 会把缓慢或失败的响应视为投递失败并重试,同一条消息就会收到两次。
在 EasyCoexistence 中,把该函数的 URL 和同一个验证令牌设为号码的 Webhook 目的地,Meta 即开始投递。
import { createClient } from "npm:@supabase/supabase-js@2";
const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
Deno.serve(async (req) => {
const url = new URL(req.url);
if (req.method === "GET") {
const ok = url.searchParams.get("hub.verify_token") === Deno.env.get("VERIFY_TOKEN");
return new Response(ok ? url.searchParams.get("hub.challenge") : "forbidden", { status: ok ? 200 : 403 });
}
if (url.searchParams.get("key") !== Deno.env.get("URL_SECRET")) return new Response("no", { status: 401 });
const body = await req.json();
const rows = (body.entry ?? []).flatMap((e: any) => e.changes ?? [])
.flatMap((c: any) => (c.value?.messages ?? []).map((m: any) => ({
wamid: m.id, sender: m.from, kind: m.type, text: m.text?.body ?? null,
phone_number_id: c.value.metadata?.phone_number_id, raw: m,
})));
if (rows.length) await db.from("whatsapp_messages").upsert(rows, { onConflict: "wamid" });
return new Response("ok");
});为什么函数对 Meta 返回 401?
因为 JWT 验证处于开启状态。Supabase 会在 Edge Function 的处理程序运行之前,先验证 Authorization 标头中的 JWT,这是每个函数的默认行为。Meta 不会发送这样的标头,所以平台会拒绝验证 GET 请求和每一次投递,您的代码根本看不到它们。
只需为这个函数关闭它。可以用 supabase functions deploy whatsapp --no-verify-jwt 部署,或者在 supabase/config.toml 中该函数的配置段下设置 verify_jwt = false。Supabase 的文档描述的正是这种情况:由外部 Webhook 服务方调用的公开端点。
平台的检查关闭后,函数需要自己的检查。签名标头对您不可用,因为 Meta 使用拥有该订阅的应用的 app secret 签名。请在目的地 URL 中加入一个很长的随机密钥作为查询参数,并拒绝不带它的请求,示例在 POST 中就是这样做的。
函数应如何存储和回复?
先存储,后回复。以 WhatsApp 消息 id 为唯一约束执行 upsert,这样 Meta 的重试会落在同一行,而不会产生重复记录。如果有多个号码指向该函数,请在每一行中保存接收消息的 phone_number_id。
对于耗时的工作,例如回复前调用模型或其他 API,请把任务交给 EdgeRuntime.waitUntil,并立即返回响应。函数会一直运行到该 promise 完成,而 Meta 已经拿到了 200。Supabase 在空闲 150 秒后停止等待并返回 504,所以先做完所有工作再响应的处理程序,最容易引发重试。
回复发送到 Cloud API 的消息端点,使用该号码的 phone number id 和访问令牌,二者都保存为函数密钥。在客户最后一条消息后的 24 小时内可以发送自由文本;超出之后,只能发送已获批准的消息模板。
从手机发出的消息会到达函数吗?
会。在 Coexistence 号码上,WhatsApp Business 应用仍可正常使用,当有人从应用回复时,Meta 会把这条外发消息的回显投递到同一个目的地,字段为 smb_message_echoes,数据在 message_echoes 数组中,而不是 messages。
请扩展解析器,同时读取两者,并保存一个 direction 列。这样表中就有完整的对话,包括客户的消息和企业的回复,这正是大多数人之后想要查询的内容:响应时间、未回复的对话、每位联系人最后一次入站的时间。
最后这个值也是所有发送路径所需的输入,因为它决定回复可以用自由文本,还是必须用消息模板。您可以基于该表建立视图,按联系人提供这个值,供应用的其他部分使用。
常见错误
- 部署时没有关闭 JWT 验证。Supabase 会在处理程序运行之前以 401 拒绝 Meta 的请求,一条消息也收不到。
- 把 challenge 包在 JSON 中返回。Meta 需要原始值作为正文,否则验证会悄无声息地失败。
- 在响应之前做耗时的工作。Meta 会重试它认为失败的请求,同一条消息会被处理两次。
- 关闭了 JWT 验证却没有任何自己的检查。任何人只要找到该 URL,就能向表中写入伪造的消息。
在 easycoexistence.com 连接号码,把它的 Webhook 目的地设为 Edge Function 的 URL 并填入您的验证令牌,再把 phone number id 和访问令牌保存为函数密钥。每个号码每月 US$ 9 起,随号码增加降至 US$ 1,前 3 天免费。
常见问题
Supabase 免费计划可以使用吗?
Edge Function 可在免费计划上运行,实际耗时上限为 150 秒,付费计划为 400 秒。响应迅速的 Webhook 处理程序远不会触及这两个上限。
函数中应该使用 service role 密钥吗?
只能在服务器端使用,像示例这样从函数的环境变量中读取。它会绕过行级安全策略,所以绝不能出现在浏览器中。
一个函数可以服务多个号码吗?
可以。把每个号码的 Webhook 目的地都指向它,并根据每个负载中的 phone_number_id 分支处理。
EasyCoexistence 能看到这些消息吗?
不能。Meta 会把每个 Webhook 直接投递到您的函数,EasyCoexistence 从不存储对话内容。
继续阅读
准备开始了吗?
几分钟即可设置 WhatsApp Coexistence,而不是几个月。应用会继续在手机上运行。
开始免费试用验证时间