waiaconnect

Guide

Come ricevo i WhatsApp di WAIA Connect in Supabase con una Edge Function?

Una Edge Function riceve il webhook di Connect, controlla che arrivi da Connect, salva il messaggio in una tabella del tuo Postgres e, se vuoi, risponde da sola. Sono due file (la funzione e una migrazione) e cinque comandi. Tutto il codice è qui sotto.

Prima di tuttoPubblica la funzione SENZA la verifica del JWT

Supabase richiede un JWT a ogni chiamata a una Edge Function e Connect non lo invia. Se pubblichi con le impostazioni di sempre, ogni consegna muore con 401 «Missing authorization header» e il tuo codice non gira mai.

Pubblicala con npx supabase functions deploy connect-webhook --no-verify-jwt (oppure metti verify_jwt = false in supabase/config.toml). Non resta aperta: la funzione risponde 401 a tutto ciò che non porta la firma di Connect.

ProvatoIl 29/09/2026 abbiamo eseguito questa stessa funzione su Deno 2.1.4 (la versione del runtime di Supabase) contro Postgres 15 e PostgREST, con un Connect finto: 32 casi su 32 (firma, nuovi tentativi, riconsegne, echi, file, invii falliti, risposta rifiutata). Non l'abbiamo provata su un progetto Supabase reale: la lista in fondo è per farlo tu in dieci minuti.

Prima di iniziare

  • Un account WAIA Connect con un numero collegato.
  • Un progetto Supabase (il piano gratuito basta) e Node.js sul tuo computer per eseguire npx supabase.
  • Se vuoi che risponda: una chiave API (wc_live_…). Nel pannello: API → Crea API key. Si vede una sola volta.

1. Le tabelle (migrazione)

Crea due tabelle: whatsapp_messages (un messaggio per riga) e whatsapp_errors (quello che è andato storto). Entrambe con RLS attivo e nessuna policy: la chiave pubblica del tuo progetto non può leggerle né scriverle. La funzione usa la chiave segreta, che scavalca 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;

Scarica la migrazione (.sql)

🔑 L'indice univoco su wamid (l'identificativo WhatsApp di ogni messaggio) è ciò che impedisce di elaborare qualcosa due volte: se Connect riprova, o se Meta riconsegna lo stesso messaggio, la seconda riga va in conflitto e la funzione non risponde di nuovo.

2. La funzione

Un solo file, senza dipendenze strane. Se chiedi modifiche a un'IA, chiedile di toccare solo buildReply: è quella che decide cosa rispondere. Il resto (la firma, l'ordine delle cose, i duplicati) serve a proteggerti.

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

Scarica la funzione (index.ts)

Risponde 200 subito e lavora dopo (EdgeRuntime.waitUntil): Connect aspetta 10 secondi e, se ci vuole di più, riprova.

Non risponde mai a un eco (message.echo, quello che mandi dal telefono): parleresti con te stesso. Non risponde nemmeno ad audio o foto, li salva e basta.

Ogni risposta porta un Idempotency-Key derivato dal messaggio: anche se la funzione gira due volte, Connect invia la risposta una sola volta.

Se un tuo invio fallisce, Connect lo segnala con un message.status in failed. Con stage: "send" significa che Meta ha rifiutato la richiesta e il messaggio non è partito; senza stage, Meta l'ha accettato e poi non è riuscita a consegnarlo. La funzione lo annota in whatsapp_errors con il codice.

3. I comandi

Dalla cartella del tuo progetto. <PROJECT_REF> è nell'URL del tuo progetto su 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="Grazie, ti rispondiamo subito"
npx supabase functions deploy connect-webhook --no-verify-jwt

CONNECT_WEBHOOK_SECRET è il segreto whsec_… del tuo endpoint in Connect (passo 4; se non l'hai ancora creato, torna qui e impostalo dopo: i segreti cambiano senza ripubblicare).

AUTO_REPLY_TEXT è facoltativo: vuoto, la funzione salva e basta. SUPABASE_URL e la chiave segreta li imposta Supabase da solo; non impostarli (non potresti: i nomi che iniziano con SUPABASE_ sono riservati).

4. Registra il webhook in Connect

  1. Nel pannello: Webhook → Aggiungi endpoint.
  2. URL: https://<PROJECT_REF>.supabase.co/functions/v1/connect-webhook.
  3. Eventi: message.received, message.status e, se vuoi salvare quello che mandi dal telefono, message.echo.
  4. Copia il segreto whsec_… (si vede una sola volta) e impostalo: npx supabase secrets set CONNECT_WEBHOOK_SECRET=whsec_….
  5. Premi Prova: deve dire 200. Poi mandati un WhatsApp da un altro telefono e guarda la tabella whatsapp_messages nell'editor di Supabase.

I limiti di Supabase (e perché bastano)

  • 2 secondi di CPU per chiamata (l'attesa della rete non conta). Verificare la firma e salvare una riga richiedono millisecondi.
  • 150 secondi di orologio per chiamata nel piano gratuito (400 in quelli a pagamento) e 256 MB di memoria. La risposta parte entro quel tempo, dopo il 200.
  • I segreti: fino a 100 per progetto, e cambiano senza ripubblicare la funzione.
  • Supabase non pubblica un limite di dimensione della richiesta. I messaggi di Connect pesano pochi kilobyte: i file non viaggiano dentro, viaggia il loro identificativo.

Se qualcosa non funziona

  • Prova dà 401 «Missing authorization header»: la funzione ha tenuto la verifica del JWT. Ripubblica con --no-verify-jwt.
  • Prova dà 401 «bad signature»: CONNECT_WEBHOOK_SECRET non è quello di questo endpoint (l'hai ruotato?). Imposta quello attuale con secrets set.
  • Prova dà 200 ma non compare nulla: Prova invia un webhook.test, che la funzione accetta e non salva. Mandati un WhatsApp vero. Se non compare nemmeno quello, guarda Edge Functions → connect-webhook → Logs e la tabella whatsapp_errors.
  • Salva ma non risponde: guarda whatsapp_errors. reply_rejected con API_KEY_INVALID = la chiave è sbagliata; WINDOW_24H_EXPIRED = sono passate più di 24 ore da quando quella persona ti ha scritto (serve un modello); config = manca CONNECT_API_KEY; nessun errore = AUTO_REPLY_TEXT vuoto.
  • La forma esatta di ogni evento? Receive the webhook (in inglese). Come si invia un messaggio? Send a message.

Provarlo davvero (lista per una prova reale)

  1. Pubblica con --no-verify-jwt e premi Prova in Connect: 200.
  2. Manda un WhatsApp di testo al tuo numero da un altro telefono: compare una riga in whatsapp_messages, con direction = in e il testo. Se hai impostato AUTO_REPLY_TEXT, arriva la risposta e la riga ottiene replied_at.
  3. Manda una foto: compare la riga con type = image e non risponde.
  4. Rispondi dal telefono (se hai sottoscritto message.echo): compare una riga con direction = out e non risponde.
  5. In Webhook, reinvia la stessa consegna (o aspetta un nuovo tentativo): non compare una riga nuova e non parte una seconda risposta.
  6. Cambia una lettera di CONNECT_WEBHOOK_SECRET e premi Prova: 401. Rimetti quello giusto.
  7. Imposta una chiave API sbagliata e manda un WhatsApp: si salva, non risponde, e in whatsapp_errors compare reply_rejected con API_KEY_INVALID.

Fonti (consultate il 29/09/2026)

Connect your first number today

Meta-verified technology provider. Coexistence in one click.

Get started