waiaconnect

Guias

Como recebo os WhatsApp do WAIA Connect no Supabase com uma Edge Function?

Uma Edge Function recebe o webhook do Connect, confere que vem do Connect, grava a mensagem numa tabela do seu Postgres e, se você quiser, responde sozinha. São dois arquivos (a função e uma migração) e cinco comandos. Todo o código está abaixo.

Antes de tudoPublique a função SEM a verificação de JWT

O Supabase exige um JWT em cada chamada a uma Edge Function e o Connect não manda. Se você publicar com a configuração de sempre, cada entrega morre com 401 «Missing authorization header» e o seu código nunca roda.

Publique com npx supabase functions deploy connect-webhook --no-verify-jwt (ou coloque verify_jwt = false em supabase/config.toml). Ela não fica aberta: a função responde 401 a tudo o que não traz a assinatura do Connect.

TestadoEm 29/09/2026 rodamos esta mesma função no Deno 2.1.4 (a versão do runtime do Supabase) contra Postgres 15 e PostgREST, com um Connect falso: 32 de 32 casos (assinatura, novas tentativas, reentregas, ecos, arquivos, envios com falha, resposta recusada). Não testamos num projeto real do Supabase: a lista do final é para você fazer em dez minutos.

Antes de começar

  • Uma conta do WAIA Connect com um número conectado.
  • Um projeto no Supabase (o plano grátis basta) e Node.js no seu computador para rodar npx supabase.
  • Se quiser que responda: uma chave de API (wc_live_…). No painel: API → Criar API key. Aparece uma única vez.

1. As tabelas (migração)

Cria duas tabelas: whatsapp_messages (uma mensagem por linha) e whatsapp_errors (o que deu errado). As duas com RLS ligado e sem políticas: a chave pública do seu projeto não consegue lê-las nem escrevê-las. A função usa a chave secreta, que passa por cima do 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;

Baixar a migração (.sql)

🔑 O índice único em wamid (o identificador do WhatsApp de cada mensagem) é o que impede processar algo duas vezes: se o Connect tentar de novo, ou se a Meta reentregar a mesma mensagem, a segunda linha colide e a função não responde de novo.

2. A função

Um único arquivo, sem dependências estranhas. Se pedir mudanças a uma IA, peça para mexer só em buildReply: é ela que decide o que responder. O resto (a assinatura, a ordem das coisas, os duplicados) está aí para te proteger.

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

Baixar a função (index.ts)

Responde 200 na hora e trabalha depois (EdgeRuntime.waitUntil): o Connect espera 10 segundos e, se demorar mais, tenta de novo.

Nunca responde a um eco (message.echo, o que você manda pelo celular): você estaria falando consigo mesmo. Também não responde áudios nem fotos, só os grava.

Cada resposta leva um Idempotency-Key derivado da mensagem: mesmo que a função rode duas vezes, o Connect manda a resposta uma vez só.

Se um envio seu falhar, o Connect avisa com um message.status em failed. Com stage: "send" quer dizer que a Meta recusou o pedido e a mensagem não saiu; sem stage, a Meta aceitou e depois não conseguiu entregar. A função registra isso em whatsapp_errors com o código.

3. Os comandos

Da pasta do seu projeto. <PROJECT_REF> está na URL do seu projeto no 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="Obrigado, já respondemos"
npx supabase functions deploy connect-webhook --no-verify-jwt

CONNECT_WEBHOOK_SECRET é o segredo whsec_… do seu endpoint no Connect (passo 4; se ainda não criou, volte aqui e configure depois: os segredos mudam sem publicar de novo).

AUTO_REPLY_TEXT é opcional: vazio, a função só grava. SUPABASE_URL e a chave secreta o Supabase coloca sozinho; não configure (nem daria: os nomes que começam com SUPABASE_ são reservados).

4. Cadastre o webhook no Connect

  1. No painel: Webhooks → Adicionar endpoint.
  2. URL: https://<PROJECT_REF>.supabase.co/functions/v1/connect-webhook.
  3. Eventos: message.received, message.status e, se quiser gravar o que manda pelo celular, message.echo.
  4. Copie o segredo whsec_… (aparece uma única vez) e configure: npx supabase secrets set CONNECT_WEBHOOK_SECRET=whsec_….
  5. Aperte Testar: tem que dar 200. Depois mande um WhatsApp para você de outro celular e olhe a tabela whatsapp_messages no editor do Supabase.

Os limites do Supabase (e por que bastam)

  • 2 segundos de CPU por chamada (o que espera pela rede não conta). Verificar a assinatura e gravar uma linha levam milissegundos.
  • 150 segundos de relógio por chamada no plano grátis (400 nos pagos) e 256 MB de memória. A resposta sai dentro desse tempo, depois do 200.
  • Os segredos: até 100 por projeto, e mudam sem publicar a função de novo.
  • O Supabase não publica um limite de tamanho do pedido. As mensagens do Connect têm poucos kilobytes: os arquivos não vão dentro, vai o identificador deles.

Se algo não funcionar

  • Testar dá 401 «Missing authorization header»: a função ficou com a verificação de JWT. Publique de novo com --no-verify-jwt.
  • Testar dá 401 «bad signature»: o CONNECT_WEBHOOK_SECRET não é o deste endpoint (você o trocou?). Configure o atual com secrets set.
  • Testar dá 200 mas nada aparece: Testar manda um webhook.test, que a função aceita e não grava. Mande um WhatsApp de verdade. Se também não aparecer, olhe Edge Functions → connect-webhook → Logs e a tabela whatsapp_errors.
  • Grava mas não responde: olhe whatsapp_errors. reply_rejected com API_KEY_INVALID = a chave está errada; WINDOW_24H_EXPIRED = passaram mais de 24 horas desde que essa pessoa te escreveu (precisa de um modelo); config = falta CONNECT_API_KEY; sem erro = AUTO_REPLY_TEXT vazio.
  • A forma exata de cada evento? Receive the webhook (em inglês). Como mandar uma mensagem? Send a message.

Testar de verdade (lista para um teste real)

  1. Publique com --no-verify-jwt e aperte Testar no Connect: 200.
  2. Mande um WhatsApp de texto para o seu número de outro celular: aparece uma linha em whatsapp_messages, com direction = in e o texto. Se configurou AUTO_REPLY_TEXT, chega a resposta e a linha ganha replied_at.
  3. Mande uma foto: aparece a linha com type = image e não responde.
  4. Responda pelo celular (se assinou message.echo): aparece uma linha com direction = out e não responde.
  5. Em Webhooks, reenvie a mesma entrega (ou espere uma nova tentativa): não aparece linha nova e não sai uma segunda resposta.
  6. Troque uma letra do CONNECT_WEBHOOK_SECRET e aperte Testar: 401. Volte a colocar o certo.
  7. Configure uma chave de API errada e mande um WhatsApp: grava, não responde, e em whatsapp_errors aparece reply_rejected com API_KEY_INVALID.

Fontes (consultadas em 29/09/2026)

Conecte o seu primeiro número hoje

Provedor de tecnologia verificado pela Meta. Coexistence em um clique.

Começar