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.

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.
- Uma pessoa manda mensagem para o seu número.
- A whasp envia por POST um evento
message.receivedassinado ao seu servidor. O evento carrega a única cópia que você vai receber, porque os servidores da whasp não guardam mensagens. - Seu servidor verifica a assinatura, grava o evento e responde
204. - Em segundo plano, ele carrega os turnos recentes do chat do seu próprio banco e chama a Responses API.
- 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
.tsdireto, removendo os tipos, então não há etapa de build. Onode:sqlitevem 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 classeWebhookdostandardwebhooksconferewebhook-id,webhook-timestamp(ela rejeita qualquer valor com mais de cinco minutos de diferença) ewebhook-signaturecontra<id>.<timestamp>.<body>, então verificar um objeto já interpretado e serializado de novo falha. - Duplicatas.
INSERT OR IGNOREno id do evento transforma uma entrega repetida em uma operação nula, e a duplicata ainda recebe um204, ou a whasp continuaria tentando. - Replay e ordem. As linhas com
done = 0voltam para a fila na inicialização. A fila por chat emenqueueroda 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.
-
Inicie um túnel para a whasp alcançar o seu notebook.
cloudflared tunnel --url http://localhost:3000imprime um endereço temporáriotrycloudflare.come não exige conta na Cloudflare. -
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" }' -
Copie o
secret(ele começa comwhsec_) para o.enve rodenode --env-file=.env server.ts. -
Envie um evento de teste com
curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". A resposta informadeliverede o código de status que o seu servidor devolveu. -
Mande mensagem para o seu número.
GET /v1/numbers/pickdevolve umwa_linkque 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.


