Integração OpenAI com WhatsApp: agente GPT em TypeScript

Conecte a OpenAI ao WhatsApp em TypeScript: verifique o webhook assinado, guarde o histórico, chame a Responses API e responda com uma chamada. Código completo, Node 22.

Um notebook mostrando linhas abstratas de código ligadas por uma linha verde brilhante a um celular cheio de balões de chat.

O essencial

  • Um agente de WhatsApp na API da OpenAI tem três partes: um webhook que recebe cada mensagem, uma chamada à Responses API com a conversa até ali e um POST que envia a resposta.
  • Verifique a assinatura Standard Webhooks no corpo bruto, grave o evento no banco e responda 204 antes de chamar o modelo, porque uma chamada ao modelo pode passar da janela de entrega de 10 segundos.
  • Os servidores da whasp não guardam mensagens, então você armazena a conversa e envia os últimos turnos ao modelo a cada requisição.
  • Monte o Idempotency-Key da resposta com o id da mensagem recebida, e um evento reenviado nunca gera uma segunda resposta.

Um agente de WhatsApp com a OpenAI precisa de três partes: um número de WhatsApp que envia cada mensagem recebida a um webhook, um servidor que verifica cada evento e chama a Responses API da OpenAI com a conversa até ali, e uma chamada de API que envia a resposta. Este tutorial monta as três em cerca de 120 linhas de TypeScript no Node 22, com a whasp fornecendo o número.

Como o agente funciona?

O agente roda em cinco passos, e só o primeiro e o último tocam o WhatsApp.

  1. Uma pessoa manda mensagem para o seu número.
  2. A whasp envia por POST um evento message.received assinado ao seu servidor. O evento carrega a única cópia que você vai receber, porque os servidores da whasp não guardam mensagens.
  3. Seu servidor verifica a assinatura, grava o evento e responde 204.
  4. Em segundo plano, ele carrega os turnos recentes do chat do seu próprio banco e chama a Responses API.
  5. Ele envia a resposta com POST /v1/messages.

Dois fatos sobre os números da whasp moldam este desenho. As pessoas escrevem primeiro: um número não consegue abrir um chat a frio (409 chat_not_open), então o agente só responde. E o seu banco de dados é a memória do agente, porque os servidores da whasp não guardam histórico. Todas as opções para colocar um agente de IA no WhatsApp estão comparadas em outro guia.

O que você precisa antes de começar?

Este tutorial precisa do Node 22.18 ou mais novo, de uma chave da API da OpenAI e de um número da whasp com chave de API.

  • Node 22.18 ou mais novo. A partir dessa versão, o Node roda arquivos .ts direto, removendo os tipos, então não há etapa de build. O node:sqlite vem embutido desde a 22.13 e mostra um aviso de recurso experimental no Node 22.
  • Uma chave da API da OpenAI.
  • Um número da whasp e uma chave de API, ambos no painel. Um número custa $49 a cada 30 dias, debitados de um saldo pré-pago.

Crie o projeto:

mkdir wa-agent && cd wa-agent
npm init -y && npm pkg set type=module
npm install hono @hono/node-server openai standardwebhooks

Depois crie um arquivo .env. O segredo do webhook vem da etapa de registro, perto do fim.

WHASP_API_KEY=wsk_...
WHASP_WEBHOOK_SECRET=whsec_...
OPENAI_API_KEY=sk-...

Como fica o servidor completo?

O agente inteiro é um arquivo, server.ts. Ele guarda os eventos recebidos e a conversa em um arquivo SQLite, então uma reinicialização não perde nada.

import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import OpenAI from "openai";
import { Webhook } from "standardwebhooks";

const WHASP = "https://api.whasp.ai/v1";
const MODEL = "gpt-6-luna";
const SYSTEM = `Você é a Ava, uma assistente simpática com quem as pessoas conversam no WhatsApp.
Escreva como uma pessoa mandando mensagem: curto, em texto simples, sem títulos nem tabelas em Markdown.
Responda no idioma em que a pessoa escreve. Faça no máximo uma pergunta por vez.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // exatas, em maiúsculas

type Message = { id: string; chat: string; text: string | null };
type WhaspEvent = { id: string; type: string; data: { message?: Message } };
type Turn = { role: "user" | "assistant"; content: string };

const openai = new OpenAI(); // lê OPENAI_API_KEY
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...

const db = new DatabaseSync("agent.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, body TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0);
  CREATE TABLE IF NOT EXISTS turns (
    id INTEGER PRIMARY KEY, ref TEXT UNIQUE, chat TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL);
  CREATE INDEX IF NOT EXISTS turns_chat ON turns (chat, id);
`);

const app = new Hono();

app.post("/whasp/webhook", async (c) => {
  const body = await c.req.text(); // corpo bruto: verifique antes de fazer o parse
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // Grave primeiro, responda depois. Um id de evento repetido não insere nada.
  const { changes } = db.prepare("INSERT OR IGNORE INTO inbox (id, body) VALUES (?, ?)").run(event.id, body);
  if (changes > 0) enqueue(event);
  return c.body(null, 204);
});

// Uma fila por chat: as mensagens de uma pessoa são tratadas em ordem, pessoas diferentes em paralelo.
const lanes = new Map<string, Promise<void>>();
function enqueue(event: WhaspEvent) {
  const key = event.data.message?.chat ?? "system";
  const run = (lanes.get(key) ?? Promise.resolve()).then(() => handle(event));
  lanes.set(key, run);
  void run.finally(() => lanes.get(key) === run && lanes.delete(key));
}

async function handle(event: WhaspEvent) {
  try {
    if (event.type === "message.received") await reply(event.data.message!);
    db.prepare("UPDATE inbox SET done = 1 WHERE id = ?").run(event.id);
  } catch (err) {
    console.error(event.id, err); // continua com done = 0: reprocessado na próxima inicialização
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "Por enquanto só consigo ler mensagens de texto.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "Você foi descadastrado. Escreva quando quiser para recomeçar.", `reply-${m.id}`, true);
  }

  const turn = db.prepare("INSERT OR IGNORE INTO turns (ref, chat, role, content) VALUES (?, ?, ?, ?)");
  turn.run(m.id, m.chat, "user", text);
  const history = db
    .prepare("SELECT role, content FROM (SELECT * FROM turns WHERE chat = ? ORDER BY id DESC LIMIT 20) ORDER BY id")
    .all(m.chat) as Turn[];

  const response = await openai.responses.create({
    model: MODEL,
    instructions: SYSTEM,
    input: history,
    reasoning: { effort: "low" },
    max_output_tokens: 1000,
    store: false, // o histórico fica no nosso banco, não no da OpenAI
    safety_identifier: createHash("sha256").update(m.chat).digest("hex"),
  });
  const answer = response.output_text.trim();
  if (!answer) throw new Error(`empty answer, status ${response.status}`);

  await send(m.chat, answer.slice(0, 4096), `reply-${m.id}`);
  turn.run(`reply-${m.id}`, m.chat, "assistant", answer);
}

async function send(to: string, text: string, key: string, overrideOptout = false) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`${WHASP}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.WHASP_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key, // um replay nunca envia duas vezes
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Descadastrado, chat não aberto, número indisponível: tentar de novo não adianta.
      console.warn("whasp refused the reply:", res.status, await res.text());
      return;
    }
    if ((res.status !== 429 && res.status < 500) || attempt === 4) throw new Error(`whasp ${res.status}`);
    await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 2 s, 4 s, 8 s
  }
}

for (const row of db.prepare("SELECT body FROM inbox WHERE done = 0 ORDER BY rowid").all()) {
  enqueue(JSON.parse(row.body as string));
}
serve({ fetch: app.fetch, port: 3000 });
console.log("Listening on :3000");

Por que o webhook grava o evento antes de responder?

O handler do webhook grava o evento no SQLite antes de responder, porque a whasp apaga o corpo de um evento depois que ele é entregue, e a mensagem não existe em nenhum outro lugar. A whasp conta qualquer 2xx em até 10 segundos como sucesso e, caso contrário, tenta de novo até 12 vezes. Uma chamada ao modelo pode durar mais de 10 segundos, então o handler não pode esperar por ela. Gravar primeiro e responder depois significa que, quando a whasp vê o 204, a mensagem já está segura no seu disco.

Três detalhes do handler importam:

  • O corpo bruto. c.req.text() devolve os bytes que a whasp assinou. A classe Webhook do standardwebhooks confere webhook-id, webhook-timestamp (ela rejeita qualquer valor com mais de cinco minutos de diferença) e webhook-signature contra <id>.<timestamp>.<body>, então verificar um objeto já interpretado e serializado de novo falha.
  • Duplicatas. INSERT OR IGNORE no id do evento transforma uma entrega repetida em uma operação nula, e a duplicata ainda recebe um 204, ou a whasp continuaria tentando.
  • Replay e ordem. As linhas com done = 0 voltam para a fila na inicialização. A fila por chat em enqueue roda as mensagens de uma pessoa em ordem, enquanto pessoas diferentes rodam em paralelo.

O guia de tratamento de webhooks do WhatsApp cobre o que esta versão deixa de fora: novas tentativas do worker, o calendário de tentativas da whasp, ordem e testes.

Como o código chama a Responses API da OpenAI?

O agente chama openai.responses.create uma vez por mensagem. A OpenAI recomenda a Responses API para todos os projetos novos, e esta requisição usa sete campos:

Campo Valor no código Por quê
model gpt-6-luna O modelo mais eficiente que a OpenAI lista; troque mudando MODEL.
instructions o system prompt Um campo de nível superior, então não há mensagem de sistema no histórico.
input os últimos 20 turnos como { role, content } A API não guarda estado: você reenvia o histórico.
reasoning.effort low Chat raramente exige raciocínio profundo, e menos esforço é mais rápido.
max_output_tokens 1000 Limita o custo de uma resposta. Os tokens de raciocínio contam para o limite.
store false Impede que a OpenAI salve o objeto da resposta.
safety_identifier SHA-256 do número de telefone Permite que a OpenAI rastreie um abuso até um usuário final.

Em 19 de outubro de 2026, a lista de modelos da OpenAI tem três modelos principais: gpt-6-luna ($0,10 de entrada e $0,50 de saída por milhão de tokens, feito para trabalho de alto volume), gpt-6.1-sol ($2 e $10) e gpt-6-astra ($10 e $50, o mais capaz). Nos preços do Luna, uma resposta que lê 1.500 tokens e escreve 150 custa cerca de $0,0002, mais os eventuais tokens de raciocínio.

O histórico é seu de propósito. A API não guarda estado, a menos que você encadeie previous_response_id ou anexe um objeto Conversations, e os objetos de resposta ficam salvos por 30 dias por padrão. Este agente reenvia turnos de texto do próprio banco e define store: false, então apagar ou resumir uma conversa nunca depende do lado da OpenAI. A contrapartida: para modelos de raciocínio, a OpenAI recomenda reenviar todos os itens de saída, não só o texto. Um histórico só de texto descarta os itens de raciocínio, o que é aceitável para respostas curtas de chat com esforço baixo.

O safety_identifier leva um ID estável e com hash por usuário final. A OpenAI diz para aplicar hash ao nome de usuário ou ao e-mail em vez de enviá-lo, e por isso o código aplica hash ao número de telefone.

Vinte turnos são uma janela, não memória: um usuário que volta depois de três semanas recebe só as últimas vinte mensagens. Memória de conversa cobre resumos e fatos de longo prazo, e escrever prompts para o WhatsApp cobre o system prompt.

Como o agente responde, e o que pode dar errado?

O agente responde com um POST /v1/messages. Ele envia to e text e deixa from de fora, então a whasp usa o número para o qual aquela pessoa escreveu por último. A API responde 202 com a mensagem em queued, e um número envia as mensagens uma de cada vez, com cerca de um segundo de intervalo.

O header Idempotency-Key é reply- mais o id da mensagem recebida. A whasp guarda uma chave por 7 dias, e a mesma chave devolve a primeira mensagem e nunca envia duas vezes. Um evento reenviado, portanto, não consegue mandar mensagem em dobro para ninguém.

A função send separa os erros em três grupos:

Resposta O que significa O que o código faz
403 chat_opted_out, 409 chat_not_open, 409 number_unavailable, 409 idempotency_conflict Tentar de novo não adianta: a pessoa se descadastrou (opt-out), nunca escreveu, o número está fora do ar ou a mesma resposta já está em andamento. Registra no log e descarta a resposta.
429 rate_limited (mais de 100 requisições por 10 segundos por conta), 429 number_daily_limit, qualquer 5xx Temporário. Tenta quatro vezes, esperando 2, 4 e 8 segundos, e então lança o erro; o evento é reprocessado na próxima inicialização.
Qualquer outro 4xx Um bug do seu lado, como uma chave de API errada. Lança o erro; o evento fica pendente até você corrigir.

O STOP é tratado duas vezes. A whasp descadastra um chat (opt-out) quando a mensagem é exatamente STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END ou QUIT (diferencia maiúsculas de minúsculas), ou qualquer grafia de opt out, e o próximo envio recebe 403. O código confere as mesmas palavras primeiro, pula o modelo e envia uma despedida com override_optout: true. Pedidos educados como “por favor, pare” ficam por sua conta: mantenha uma flag própria e pare de chamar o modelo para aquele chat.

A mídia chega como arquivo. Uma foto ou uma mensagem de voz não tem text no evento, então o código responde com uma linha curta. Transcrever áudios e fotos do WhatsApp mostra como baixar e transcrever.

Como rodar e colocar no ar?

Você roda em cinco passos.

  1. Inicie um túnel para a whasp alcançar o seu notebook. cloudflared tunnel --url http://localhost:3000 imprime um endereço temporário trycloudflare.com e não exige conta na Cloudflare.

  2. Registre o endpoint. A resposta traz o segredo de assinatura:

    curl -X PUT https://api.whasp.ai/v1/webhook \
      -H "Authorization: Bearer $WHASP_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://YOUR-TUNNEL.trycloudflare.com/whasp/webhook" }'
  3. Copie o secret (ele começa com whsec_) para o .env e rode node --env-file=.env server.ts.

  4. Envie um evento de teste com curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". A resposta informa delivered e o código de status que o seu servidor devolveu.

  5. Mande mensagem para o seu número. GET /v1/numbers/pick devolve um wa_link que abre o chat em um celular; escreva “oi” e espere a resposta.

Em produção, rode um processo Node em um host com HTTPS e disco persistente para o agent.db. Plataformas com disco efêmero, ou configurações com várias instâncias, precisam de um banco compartilhado, como o Postgres, e de um lock por chat no lugar das filas em memória. GET /v1/webhook/deliveries lista as entregas dos últimos 7 dias.

O que construir em seguida?

Comece pelo que os usuários notam primeiro: memória que dura semanas e um system prompt que soa como uma pessoa mandando mensagem. Depois reforce a entrega com o guia de webhooks ou veja o mesmo agente com o Claude.

Perguntas

Como conectar o ChatGPT ao WhatsApp?

Você cria um agente na API da OpenAI: recebe as mensagens do WhatsApp em um webhook, chama a Responses API com a conversa e devolve a resposta por uma API de mensagens, como o POST /v1/messages da whasp.

Qual modelo da OpenAI usar em um bot de WhatsApp?

Para respostas de chat, comece com o gpt-6-luna, o modelo mais eficiente da OpenAI, a $0,10 de entrada e $0,50 de saída por milhão de tokens em 19 de outubro de 2026. Suba para o gpt-6.1-sol se as respostas pedirem mais raciocínio.

A Responses API da OpenAI lembra da conversa?

Só se você pedir. Cada requisição é independente por padrão, e você continua uma conversa reenviando o histórico, passando previous_response_id ou anexando um objeto Conversations. Este tutorial reenvia o próprio histórico e define store como false.

Como impedir que um bot de WhatsApp responda duas vezes à mesma mensagem?

Deduplique os eventos recebidos pelo id do evento e envie cada resposta com um Idempotency-Key montado com o id da mensagem recebida. Assim, uma entrega repetida ou um replay depois de uma queda não consegue enviar uma segunda resposta.

O agente pode iniciar uma conversa?

Não. Na whasp, um número só responde a quem escreveu primeiro, e um envio para qualquer outra pessoa devolve 409 chat_not_open. Dê aos novos usuários um link wa.me para que eles comecem o chat.

Todos os artigos