Log inStart Free Trial
Meta Tech Provider

Receive WhatsApp webhooks in a Supabase Edge Function

A Supabase Edge Function can be the number's webhook destination directly. It answers Meta's verification GET with the hub.challenge value, inserts each POSTed message into a Postgres table, and replies through the Cloud API. Deploy it with JWT verification turned off, because Supabase requires a valid JWT by default and Meta cannot send one.

verify_jwt = true

Supabase's default for Edge Functions, which rejects any request without a valid JWT before your handler runs.

--no-verify-jwt

The supabase functions deploy flag that disables JWT verification for a function.

150 s

Request idle timeout for an Edge Function; no response by then returns 504.

What does the Edge Function have to do?

Two things, on two methods.

On GET, it answers Meta's verification. Read hub.mode, hub.verify_token and hub.challenge from the query string, compare the token with the one you entered as the destination's verify token, and return the challenge as a plain text body with status 200. Return 403 when the token does not match.

On POST, it receives events. Parse the body, pull the messages out of entry, changes and value, insert them into a table, and return 200. Keep the response fast: Meta treats a slow or failed answer as a failed delivery and retries, which means the same message twice.

Set the function's URL as the number's webhook destination in EasyCoexistence, with the same verify token, and Meta starts delivering.

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");
});

Why does the function return 401 to Meta?

Because JWT verification is on. Supabase validates a JWT in the Authorization header before an Edge Function's handler runs, and that is the default for every function. Meta sends no such header, so the platform rejects both the verification GET and every delivery, and your code never sees them.

Turn it off for this function only. Either deploy with supabase functions deploy whatsapp --no-verify-jwt, or set verify_jwt = false under the function's section in supabase/config.toml. Supabase's docs describe exactly this case, a public endpoint called by an outside webhook provider.

With the platform check off, the function needs its own. The signature header is not available to you, since Meta signs with the app secret of the app that owns the subscription. Put a long random secret in the destination URL as a query parameter and reject requests without it, as the example does on POST.

How should the function store and reply?

Store first, reply later. Upsert on the WhatsApp message id with a unique constraint, so Meta's retries land on the same row instead of creating a duplicate. Keep the receiving phone_number_id in each row if more than one number points at the function.

For anything slow, such as calling a model or another API before replying, hand the work to EdgeRuntime.waitUntil and return the response straight away. The function keeps running until that promise settles, while Meta already has its 200. Supabase stops waiting at 150 seconds of idle time and returns 504, so a handler that does all its work before responding is the one most likely to cause retries.

Replies go to the Cloud API messages endpoint with the number's phone number id and token, stored as function secrets. Inside 24 hours of the customer's last message free text delivers; outside it only an approved template does.

Do messages sent from the phone reach the function?

Yes. On a Coexistence number the WhatsApp Business app keeps working, and when someone replies from it Meta delivers an echo of that outgoing message to the same destination, under the smb_message_echoes field and a message_echoes array rather than messages.

Extend the parser to read both and store a direction column. The table then holds the full conversation, the customer's lines and the business's answers, which is what most people want to query afterwards: response times, unanswered threads, the last inbound time per contact.

That last value is also the input every sending path needs, since it decides whether a reply can be free text or has to be a template. A view over the table can expose it per contact for the rest of your app.

Common mistakes

  • Deploying with JWT verification left on. Supabase rejects Meta's requests with 401 before the handler runs, and no message ever arrives.
  • Returning the challenge wrapped in JSON. Meta needs the raw value as the body, or verification fails silently.
  • Doing slow work before responding. Meta retries what it sees as failures, and the same message is processed twice.
  • Turning off JWT verification without any check of your own. Anyone who finds the URL can post fake messages into the table.
Doing this with EasyCoexistence

Connect the number at easycoexistence.com, set its webhook destination to the Edge Function's URL with your verify token, and store the phone number id and token as function secrets. From US$ 9 per number per month, falling to US$ 1 as you add numbers, with the first 3 days free.

Frequently asked questions

Does this work on the Supabase free plan?

Edge Functions run on the free plan, with a 150 second wall clock limit against 400 seconds on paid plans. A webhook handler that responds quickly stays far inside either.

Should I use the service role key in the function?

Only server-side, as here, read from the function's environment. It bypasses row level security, so it must never reach a browser.

Can one function serve several numbers?

Yes. Point every number's webhook destination at it and branch on the phone_number_id in each payload.

Does EasyCoexistence see the messages?

No. Meta delivers each webhook straight to your function, and EasyCoexistence never stores conversation content.

Keep reading

Ready to get started?

Set up WhatsApp Coexistence in minutes, not months. The app keeps working on the phone.

Start Free Trial

Verified on

WhatsApp Webhooks in Supabase Edge Functions