waiaconnect

Guías

¿Cómo recibo los WhatsApp de WAIA Connect en Supabase con una Edge Function?

Una Edge Function recibe el webhook de Connect, comprueba que viene de Connect, guarda el mensaje en una tabla de tu Postgres y, si querés, contesta sola. Son dos archivos (la función y una migración) y cinco comandos. Abajo está todo el código.

Antes que nadaPublicá la función SIN la verificación de JWT

Supabase pide un JWT en cada llamada a una Edge Function y Connect no lo manda. Si publicás con la configuración de siempre, cada entrega muere con 401 «Missing authorization header» y tu código nunca corre.

Publicala con npx supabase functions deploy connect-webhook --no-verify-jwt (o poné verify_jwt = false en supabase/config.toml). No queda abierta: la función rechaza con 401 todo lo que no traiga la firma de Connect.

ProbadoEl 29/09/2026 corrimos esta misma función en Deno 2.1.4 (la versión del runtime de Supabase) contra Postgres 15 y PostgREST, con un Connect falso: 32 de 32 casos (firma, reintentos, reentregas, ecos, archivos, envíos fallidos, respuesta rechazada). No la probamos en un proyecto de Supabase real: la lista del final es para hacerlo vos en diez minutos.

Antes de empezar

  • Una cuenta de WAIA Connect con un número conectado.
  • Un proyecto de Supabase (el plan gratis alcanza) y Node.js en tu computadora para correr npx supabase.
  • Si querés que conteste: una clave de API (wc_live_…). En el panel: API → Crear API key. Se muestra una sola vez.

1. Las tablas (migración)

Crea dos tablas: whatsapp_messages (un mensaje por fila) y whatsapp_errors (lo que salió mal). Las dos con RLS encendido y sin políticas: la clave pública de tu proyecto no puede leerlas ni escribirlas. La función usa la clave secreta, que pasa por encima de 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;

Descargar la migración (.sql)

🔑 El índice único sobre wamid (el identificador de WhatsApp de cada mensaje) es lo que hace que nada se procese dos veces: si Connect reintenta, o si Meta vuelve a entregar el mismo mensaje, la segunda fila choca y la función no contesta de nuevo.

2. La función

Un solo archivo, sin dependencias raras. Si le pedís cambios a una IA, pedile que toque sólo buildReply: es la que decide qué contestar. Lo demás (la firma, el orden de las cosas, los duplicados) está para protegerte.

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

Descargar la función (index.ts)

Contesta 200 enseguida y trabaja después (EdgeRuntime.waitUntil): Connect espera 10 segundos, y si tarda más reintenta.

Nunca contesta un eco (message.echo, lo que mandás desde el teléfono): te estarías hablando a vos mismo. Tampoco contesta audios ni fotos, sólo los guarda.

Cada respuesta lleva un Idempotency-Key derivado del mensaje: aunque la función corra dos veces, Connect manda la respuesta una sola vez.

Si un envío tuyo falla, Connect avisa con un message.status en failed. Con stage: "send" significa que Meta rechazó el pedido y el mensaje no salió; sin stage, Meta lo aceptó y después no lo pudo entregar. La función lo anota en whatsapp_errors con el código.

3. Los comandos

Desde la carpeta de tu proyecto. <PROJECT_REF> está en la URL de tu proyecto en 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="Gracias, ya te respondemos"
npx supabase functions deploy connect-webhook --no-verify-jwt

CONNECT_WEBHOOK_SECRET es el secreto whsec_… de tu endpoint en Connect (paso 4; si todavía no lo creaste, volvé acá y cargalo después: los secretos se cambian sin volver a publicar).

AUTO_REPLY_TEXT es opcional: vacío, la función sólo guarda. SUPABASE_URL y la clave secreta los pone Supabase solo; no los cargues (tampoco podrías: los nombres que empiezan con SUPABASE_ están reservados).

4. Registrá el webhook en Connect

  1. En el panel: Webhooks → Agregar endpoint.
  2. URL: https://<PROJECT_REF>.supabase.co/functions/v1/connect-webhook.
  3. Eventos: message.received, message.status y, si querés guardar lo que mandás desde el teléfono, message.echo.
  4. Copiá el secreto whsec_… (se muestra una sola vez) y cargalo: npx supabase secrets set CONNECT_WEBHOOK_SECRET=whsec_….
  5. Apretá Probar: tiene que decir 200. Después mandate un WhatsApp desde otro teléfono y mirá la tabla whatsapp_messages en el editor de Supabase.

Los límites de Supabase (y por qué alcanzan)

  • 2 segundos de CPU por llamada (lo que espera a la red no cuenta). Verificar la firma y guardar una fila usan milésimas.
  • 150 segundos de reloj por llamada en el plan gratis (400 en los pagos) y 256 MB de memoria. La respuesta sale dentro de ese tiempo, después del 200.
  • Los secretos: hasta 100 por proyecto, y cambian sin volver a publicar la función.
  • Supabase no publica un límite de tamaño del pedido. Los mensajes de Connect son de pocos kilobytes: los archivos no viajan adentro, viaja su identificador.

Si algo no anda

  • Probar da 401 «Missing authorization header»: la función quedó con la verificación de JWT. Volvé a publicar con --no-verify-jwt.
  • Probar da 401 «bad signature»: el CONNECT_WEBHOOK_SECRET no es el de este endpoint (¿lo rotaste?). Cargá el actual con secrets set.
  • Probar da 200 pero no aparece nada: Probar manda un webhook.test, que la función acepta y no guarda. Mandate un WhatsApp de verdad. Si tampoco aparece, mirá Edge Functions → connect-webhook → Logs y la tabla whatsapp_errors.
  • Se guarda pero no contesta: mirá whatsapp_errors. reply_rejected con API_KEY_INVALID = la clave está mal; WINDOW_24H_EXPIRED = pasaron más de 24 horas desde que esa persona te escribió (hace falta una plantilla); config = falta CONNECT_API_KEY; sin error = AUTO_REPLY_TEXT vacío.
  • ¿La forma exacta de cada evento? Recibir el webhook. ¿Cómo se manda un mensaje? Enviar un mensaje.

Probarlo de verdad (lista para una prueba real)

  1. Publicá con --no-verify-jwt y apretá Probar en Connect: 200.
  2. Mandá un WhatsApp de texto a tu número desde otro teléfono: aparece una fila en whatsapp_messages, con direction = in y el texto. Si cargaste AUTO_REPLY_TEXT, te llega la respuesta y la fila gana replied_at.
  3. Mandá una foto: aparece la fila con type = image y no contesta.
  4. Contestá desde el celular (si suscribiste message.echo): aparece una fila con direction = out y no contesta.
  5. En Webhooks, reenviá la misma entrega (o esperá un reintento): no aparece una fila nueva y no sale una segunda respuesta.
  6. Cambiá una letra de CONNECT_WEBHOOK_SECRET y apretá Probar: 401. Volvé a poner el correcto.
  7. Poné una clave de API equivocada y mandá un WhatsApp: se guarda, no contesta, y en whatsapp_errors aparece reply_rejected con API_KEY_INVALID.

Fuentes (consultadas el 29/09/2026)

Conectá tu primer número hoy

Verificado por Meta como proveedor de tecnología. Coexistence en un click.

Empezar