# 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.

Source: https://whasp.ai/pt/blog/integracao-openai-whatsapp-agente-gpt · 2026-10-20

## 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](https://whasp.ai/pt/blog/colocar-agente-ia-whatsapp-opcoes) 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](https://nodejs.org/api/typescript.html), removendo os tipos, então não há etapa de build. O [`node:sqlite`](https://nodejs.org/api/sqlite.html) 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:

```bash
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.

```bash
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.

```ts
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`](https://www.standardwebhooks.com) 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](https://whasp.ai/pt/blog/webhook-whatsapp-assinatura-tentativas-duplicatas) 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](https://developers.openai.com/api/docs/guides/migrate-to-responses), 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](https://developers.openai.com/api/docs/models) 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](https://developers.openai.com/api/docs/guides/conversation-state), 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](https://developers.openai.com/api/docs/guides/safety-best-practices) 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](https://whasp.ai/pt/blog/memoria-conversa-agente-ia-whatsapp) cobre resumos e fatos de longo prazo, e [escrever prompts para o WhatsApp](https://whasp.ai/pt/blog/prompt-agente-ia-whatsapp-formatacao) 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](https://whasp.ai/pt/blog/transcrever-audio-whatsapp-agente-ia) 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](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/).
2. Registre o endpoint. A resposta traz o segredo de assinatura:

   ```bash
   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](https://whasp.ai/pt/blog/memoria-conversa-agente-ia-whatsapp) e um [system prompt que soa como uma pessoa mandando mensagem](https://whasp.ai/pt/blog/prompt-agente-ia-whatsapp-formatacao). Depois reforce a entrega com o [guia de webhooks](https://whasp.ai/pt/blog/webhook-whatsapp-assinatura-tentativas-duplicatas) ou veja o [mesmo agente com o Claude](https://whasp.ai/pt/blog/bot-whatsapp-claude-api-anthropic).

## 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.
