How do I receive WAIA Connect's WhatsApp messages in Supabase with an Edge Function?
An Edge Function receives Connect's webhook, checks it really comes from Connect, stores the message in a table in your Postgres and, if you want, replies on its own. Two files (the function and a migration) and five commands. All the code is below.
Supabase requires a JWT on every call to an Edge Function, and Connect doesn't send one. Deploy with the usual settings and every delivery dies with 401 "Missing authorization header" — your code never runs.
Deploy with npx supabase functions deploy connect-webhook --no-verify-jwt (or set verify_jwt = false in supabase/config.toml). It isn't left open: the function answers 401 to anything without Connect's signature.
Before you start
- A WAIA Connect account with one connected number.
- A Supabase project (the free plan is enough) and Node.js on your computer to run
npx supabase. - If you want it to reply: an API key (
wc_live_…). In the panel: API → Create API key. It's shown only once.
1. The tables (migration)
Creates two tables: whatsapp_messages (one message per row) and whatsapp_errors (what went wrong). Both with RLS on and no policies: your project's public key can't read or write them. The function uses the secret key, which bypasses RLS.
-- WAIA Connect → Supabase: the two tables the Edge Function writes to.
-- Put this file in supabase/migrations/<timestamp>_connect_whatsapp.sql and run `supabase db push`
-- (or paste it in the SQL editor of your project).
-- One row per WhatsApp message (incoming, and the ones you send from the phone = echoes).
create table if not exists public.whatsapp_messages (
id bigint generated always as identity primary key,
wamid text not null, -- the WhatsApp message id
event_id text, -- Connect's evt_… (informative)
connection_id text, -- Connect's conn_… (the number it came in on)
direction text not null check (direction in ('in', 'out')),
contact text, -- the other party's phone number
contact_name text,
type text not null, -- text, image, audio, …
text text, -- only for text messages
sent_at timestamptz, -- when WhatsApp says it was sent
received_at timestamptz not null default now(),
replied_at timestamptz,
reply_message_id text -- Connect's msg_… of the auto-reply
);
-- 🔑 The reason nothing is processed twice: a Connect retry or a Meta re-delivery carries the
-- same wamid, and this index rejects the second row (the function then skips the reply).
create unique index if not exists whatsapp_messages_wamid_key on public.whatsapp_messages (wamid);
create index if not exists whatsapp_messages_contact_idx on public.whatsapp_messages (contact, received_at desc);
-- What went wrong: rejected replies, failed sends reported by Connect, config problems.
create table if not exists public.whatsapp_errors (
id bigint generated always as identity primary key,
at timestamptz not null default now(),
kind text not null, -- send_failed, delivery_failed, reply_rejected, config, handler…
message_id text, -- Connect's msg_… for a failed send
wamid text,
event_id text,
code text, -- e.g. WINDOW_24H_EXPIRED, API_KEY_INVALID
detail text
);
-- One row per failed message (a status can be re-delivered).
create unique index if not exists whatsapp_errors_failed_key on public.whatsapp_errors (kind, message_id);
-- RLS ON, and no policies: the public (anon) key cannot read or write these tables.
-- The Edge Function uses the project's secret key (or the legacy service_role key), which bypasses RLS.
alter table public.whatsapp_messages enable row level security;
alter table public.whatsapp_errors enable row level security;
🔑 The unique index on wamid (WhatsApp's id for each message) is what keeps anything from being processed twice: if Connect retries, or Meta re-delivers the same message, the second row collides and the function doesn't reply again.
2. The function
A single file, no unusual dependencies. If you ask an AI for changes, ask it to touch only buildReply: that's what decides the reply. Everything else (the signature, the order of things, duplicates) is there to protect you.
// WAIA Connect → Supabase Edge Function
// ─────────────────────────────────────────────────────────────────────────────
// Receives WAIA Connect webhooks, verifies the signature, stores every WhatsApp
// message in Postgres (once), and optionally auto-replies to incoming text.
//
// Deploy WITHOUT Supabase JWT verification — Connect does not send a Supabase token:
// supabase functions deploy connect-webhook --no-verify-jwt
// (or `verify_jwt = false` under [functions.connect-webhook] in supabase/config.toml)
// The HMAC signature below is what proves the request comes from Connect.
//
// Secrets (supabase secrets set NAME=value):
// CONNECT_WEBHOOK_SECRET whsec_… (Connect panel → Webhooks → your endpoint; shown once)
// CONNECT_API_KEY wc_live_… (only needed to auto-reply)
// AUTO_REPLY_TEXT optional; empty = store only, never reply
// CONNECT_API_BASE optional; default https://api.waiaconnect.com
// SUPABASE_URL and the secret key (SUPABASE_SECRET_KEYS, or the legacy SUPABASE_SERVICE_ROLE_KEY)
// are provided by Supabase automatically — you don't set them.
//
// Tables: run migration.sql first (whatsapp_messages, whatsapp_errors, RLS on).
// If you ask an AI to change this file, ask it to touch ONLY `buildReply`.
import { createClient } from "npm:@supabase/supabase-js@2";
const MAX_SKEW_SECONDS = 300; // Connect puts the timestamp inside the HMAC: reject replays.
const enc = new TextEncoder();
// ── Your reply logic (the only part you should need to change) ───────────────
// Return the text to send back, or null to send nothing.
function buildReply(text: string, contactName: string | null): string | null {
const fixed = (Deno.env.get("AUTO_REPLY_TEXT") ?? "").trim();
if (!fixed) return null;
void text;
void contactName;
return fixed;
}
// ── Signature: sha256=HMAC_SHA256(secret, `${timestamp}.${rawBody}`) ───────────
async function hmacHex(secret: string, data: string): Promise<string> {
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(data)));
return Array.from(sig, b => b.toString(16).padStart(2, "0")).join("");
}
// Constant time: the loop always walks the longer string, whatever matches.
function safeEqual(a: string, b: string): boolean {
const x = enc.encode(a);
const y = enc.encode(b);
let diff = x.length ^ y.length;
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
return diff === 0;
}
async function verify(req: Request, raw: string): Promise<string | null> {
const secret = Deno.env.get("CONNECT_WEBHOOK_SECRET") ?? "";
if (!secret) return "CONNECT_WEBHOOK_SECRET is not set";
const got = req.headers.get("x-connect-signature-256") ?? "";
const ts = Number(req.headers.get("x-connect-timestamp"));
if (!got || !Number.isFinite(ts)) return "missing signature headers";
if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return "timestamp outside the 5-minute window";
const want = "sha256=" + (await hmacHex(secret, `${ts}.${raw}`));
return safeEqual(got, want) ? null : "bad signature";
}
async function sha256Hex(s: string): Promise<string> {
const d = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(s)));
return Array.from(d, b => b.toString(16).padStart(2, "0")).join("");
}
// ── Postgres (secret key: bypasses RLS; the tables have no public policies) ──
// New projects: SUPABASE_SECRET_KEYS is a JSON dictionary ({"default": "sb_secret_…"}).
// Older projects: SUPABASE_SERVICE_ROLE_KEY (Supabase is retiring it by the end of 2026).
function secretKey(): string {
try {
const keys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}");
if (typeof keys?.default === "string" && keys.default) return keys.default;
} catch { /* not set or not JSON */ }
return Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
}
const db = () => createClient(Deno.env.get("SUPABASE_URL")!, secretKey(), { auth: { persistSession: false } });
// deno-lint-ignore no-explicit-any
type Json = any;
async function logError(kind: string, detail: string, extra: Record<string, unknown> = {}) {
const { error } = await db().from("whatsapp_errors").insert({ kind, detail: detail.slice(0, 1000), ...extra });
if (error) console.error("[connect] could not log error:", error.message, "|", kind, detail);
}
// Stores the message ONCE, keyed by the WhatsApp message id (unique index on wamid).
// Returns true only the first time — a Connect retry or a Meta re-delivery returns false.
async function storeOnce(row: Record<string, unknown>): Promise<boolean> {
const { data, error } = await db()
.from("whatsapp_messages")
.upsert(row, { onConflict: "wamid", ignoreDuplicates: true })
.select("id");
if (error) throw new Error("store failed: " + error.message);
return Array.isArray(data) && data.length === 1;
}
async function reply(env: Json, to: string, text: string, wamid: string) {
const key = Deno.env.get("CONNECT_API_KEY") ?? "";
if (!key) return logError("config", "CONNECT_API_KEY is not set", { wamid });
const base = (Deno.env.get("CONNECT_API_BASE") ?? "https://api.waiaconnect.com").replace(/\/+$/, "");
const res = await fetch(`${base}/v1/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
// Same key for the same incoming message: Connect never sends the reply twice.
// (A hash of the wamid: the raw wamid carries the contact's number inside.)
"Idempotency-Key": "supabase-reply-" + (await sha256Hex(wamid)).slice(0, 40)
},
body: JSON.stringify({ connectionId: env?.connection?.id, to, type: "text", text: { body: text } })
});
const body = await res.text();
if (!res.ok) {
let code = String(res.status);
try {
code = JSON.parse(body)?.error?.code ?? code;
} catch { /* not JSON */ }
return logError("reply_rejected", `Connect API ${res.status} ${code}`, { wamid, code });
}
let id: string | null = null;
try {
id = JSON.parse(body)?.id ?? null;
} catch { /* ignore */ }
await db().from("whatsapp_messages").update({ replied_at: new Date().toISOString(), reply_message_id: id }).eq("wamid", wamid);
}
// ── The work, done AFTER answering 200 ────────────────────────────────────────
async function handle(env: Json) {
const type = String(env?.type ?? "");
const data = env?.data ?? {};
if (type === "message.received" || type === "message.echo") {
const m = data.message ?? {};
const wamid = String(m.id ?? "");
if (!wamid) return logError("bad_event", "message without id", { event_id: env?.id ?? null });
const echo = type === "message.echo";
const contact = Array.isArray(data.contacts) ? data.contacts[0] : null;
const contactName = contact?.profile?.name ?? null;
const text = m.type === "text" ? String(m.text?.body ?? "") : null;
const first = await storeOnce({
wamid,
event_id: env?.id ?? null,
connection_id: env?.connection?.id ?? null,
direction: echo ? "out" : "in",
contact: echo ? String(m.to ?? "") : String(m.from ?? ""),
contact_name: echo ? null : contactName,
type: String(m.type ?? "unknown"),
text,
sent_at: m.timestamp ? new Date(Number(m.timestamp) * 1000).toISOString() : null
});
if (!first) return; // already processed: no second row, no second reply
if (echo || text === null) return; // never reply to an echo (you'd talk to yourself), nor to media
const out = buildReply(text, contactName);
if (out) await reply(env, String(m.from), out, wamid);
return;
}
if (type === "message.status" && data.status === "failed") {
const err = Array.isArray(data.errors) ? data.errors[0] : null;
// stage "send": Connect's call to Meta was rejected — the message never left.
// No stage: Meta accepted it and reported later that it wasn't delivered.
const { error } = await db()
.from("whatsapp_errors")
.upsert(
{
kind: data.stage === "send" ? "send_failed" : "delivery_failed",
message_id: String(data.messageId ?? ""),
code: data.failureCode ?? (err?.code != null ? String(err.code) : null),
detail: String(err?.title ?? "failed").slice(0, 1000)
},
{ onConflict: "kind,message_id", ignoreDuplicates: true }
);
if (error) console.error("[connect] could not store failed status:", error.message);
}
// Anything else (webhook.test, sent/delivered/read, account events): nothing to do.
}
Deno.serve(async req => {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const raw = await req.text(); // the exact bytes Connect signed
const bad = await verify(req, raw);
if (bad) return new Response(bad, { status: 401 });
let env: Json;
try {
env = JSON.parse(raw);
} catch {
return new Response("invalid JSON", { status: 400 });
}
// Answer 200 now; do the rest in the background (Connect waits 10 s, then retries).
const work = handle(env).catch(e => logError("handler", String(e?.message ?? e), { event_id: env?.id ?? null }));
// deno-lint-ignore no-explicit-any
const rt = (globalThis as any).EdgeRuntime;
if (rt?.waitUntil) rt.waitUntil(work);
else await work; // plain Deno (local tests): just finish before answering
return new Response("ok", { status: 200 });
});
Download the function (index.ts)
It answers 200 right away and works afterwards (EdgeRuntime.waitUntil): Connect waits 10 seconds and retries if it takes longer.
It never replies to an echo (message.echo, what you send from the phone): you'd be talking to yourself. It doesn't reply to audio or photos either — it only stores them.
Every reply carries an Idempotency-Key derived from the message: even if the function runs twice, Connect sends the reply once.
If one of your sends fails, Connect tells you with a message.status of failed. With stage: "send" it means Meta rejected the request and the message never left; without stage, Meta accepted it and later couldn't deliver it. The function writes it to whatsapp_errors with the code.
3. The commands
From your project's folder. <PROJECT_REF> is in your project's URL on Supabase (https://supabase.com/dashboard/project/<PROJECT_REF>).
npx supabase login
npx supabase init
npx supabase link --project-ref <PROJECT_REF>
npx supabase migration new connect_whatsapp
# → paste migration.sql into the new file under supabase/migrations/
npx supabase db push
npx supabase functions new connect-webhook
# → replace supabase/functions/connect-webhook/index.ts with the one on this page
npx supabase secrets set CONNECT_WEBHOOK_SECRET=whsec_... CONNECT_API_KEY=wc_live_... \
AUTO_REPLY_TEXT="Thanks, we'll get back to you"
npx supabase functions deploy connect-webhook --no-verify-jwtCONNECT_WEBHOOK_SECRET is the whsec_… secret of your endpoint in Connect (step 4; if you haven't created it yet, come back and set it later: secrets change without redeploying).
AUTO_REPLY_TEXT is optional: empty, the function only stores. Supabase sets SUPABASE_URL and the secret key on its own; don't set them (you couldn't: names starting with SUPABASE_ are reserved).
4. Register the webhook in Connect
- In the panel: Webhooks → Add endpoint.
- URL:
https://<PROJECT_REF>.supabase.co/functions/v1/connect-webhook. - Events: message.received, message.status and, if you want to store what you send from the phone, message.echo.
- Copy the
whsec_…secret (shown only once) and set it:npx supabase secrets set CONNECT_WEBHOOK_SECRET=whsec_…. - Press Test: it must say 200. Then send yourself a WhatsApp from another phone and look at the whatsapp_messages table in Supabase's editor.
Supabase's limits (and why they're enough)
- 2 seconds of CPU per call (waiting on the network doesn't count). Verifying the signature and storing a row take milliseconds.
- 150 seconds of wall clock per call on the free plan (400 on paid plans) and 256 MB of memory. The reply goes out within that time, after the 200.
- Secrets: up to 100 per project, and they change without redeploying the function.
- Supabase doesn't publish a request-size limit. Connect's messages are a few kilobytes: files don't travel inside, their id does.
If something doesn't work
- Test gives 401 "Missing authorization header": the function kept JWT verification. Redeploy with
--no-verify-jwt. - Test gives 401 "bad signature":
CONNECT_WEBHOOK_SECRETisn't this endpoint's (did you rotate it?). Set the current one withsecrets set. - Test gives 200 but nothing shows up:
Testsends awebhook.test, which the function accepts and doesn't store. Send a real WhatsApp. If that doesn't show up either, check Edge Functions → connect-webhook → Logs and the whatsapp_errors table. - It stores but doesn't reply: check whatsapp_errors.
reply_rejectedwithAPI_KEY_INVALID= wrong key;WINDOW_24H_EXPIRED= more than 24 hours since that person wrote to you (you need a template);config=CONNECT_API_KEYis missing; no error =AUTO_REPLY_TEXTis empty. - The exact shape of each event? Receive the webhook. How to send a message? Send a message.
Test it for real (a checklist)
- Deploy with
--no-verify-jwtand press Test in Connect: 200. - Send a text WhatsApp to your number from another phone: one row appears in whatsapp_messages, with
direction = inand the text. If you setAUTO_REPLY_TEXT, the reply arrives and the row getsreplied_at. - Send a photo: the row appears with
type = imageand it doesn't reply. - Reply from the phone (if you subscribed message.echo): a row appears with
direction = outand it doesn't reply. - In Webhooks, resend the same delivery (or wait for a retry): no new row appears and no second reply goes out.
- Change one letter of
CONNECT_WEBHOOK_SECRETand press Test: 401. Put the right one back. - Set a wrong API key and send a WhatsApp: it's stored, it doesn't reply, and whatsapp_errors shows
reply_rejectedwithAPI_KEY_INVALID.
Sources (checked on 2026-09-29)
Connect your first number today
Meta-verified technology provider. Coexistence in one click.
Get started