登入開始免費試用
Meta Tech Provider

在 Supabase Edge Function 中接收 WhatsApp Webhook

Supabase Edge Function 可以直接作為號碼的 Webhook 目的地。它以 hub.challenge 的值回應 Meta 的驗證 GET,將每個 POST 傳來的訊息寫入 Postgres 資料表,並經 Cloud API 回覆。部署時請關閉 JWT 驗證,因為 Supabase 預設要求有效的 JWT,而 Meta 無法提供。

verify_jwt = true

Supabase 對 Edge Function 的預設設定:任何沒有有效 JWT 的請求,會在您的處理程式執行前被拒絕。

--no-verify-jwt

supabase functions deploy 的旗標,用來為函數停用 JWT 驗證。

150 s

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 就會開始傳送。

supabase/functions/whatsapp/index.ts
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 是用擁有該訂閱的應用程式的應用程式密鑰簽署。請在目的地 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 完成這項設定

在 easycoexistence.com 連接號碼,將其 Webhook 目的地設為 Edge Function 的 URL 並填入您的驗證權杖,再把 phone number id 和存取權杖存為函數密鑰。每個號碼每月 US$ 9 起,隨號碼增加降至 US$ 1,首 3 天免費。

常見問題

這可以在 Supabase 免費方案上使用嗎?

可以。Edge Function 在免費方案上運行,實際執行時間上限為 150 秒,付費方案則為 400 秒。快速回應的 Webhook 處理程式遠遠不會超出任何一個上限。

函數中應該使用 service role key 嗎?

只可在伺服器端使用,如本例所示,從函數的環境變數讀取。它會繞過列層級安全性,所以絕不可以到達瀏覽器。

一個函數可以服務多個號碼嗎?

可以。將每個號碼的 Webhook 目的地都指向它,再根據每個負載中的 phone_number_id 分流處理。

EasyCoexistence 看得到這些訊息嗎?

看不到。Meta 會把每個 Webhook 直接傳送到您的函數,而 EasyCoexistence 從不儲存對話內容。

繼續閱讀

準備開始嗎?

幾分鐘內設定 WhatsApp Coexistence,而非幾個月。應用程式在手機上仍可繼續運作。

開始免費試用

驗證日期

WhatsApp Webhook 與 Supabase Edge Function