# Bot de WhatsApp con ChatGPT: agente con la API de OpenAI

> Conecta OpenAI a WhatsApp con TypeScript: verifica el webhook firmado, guarda el historial, llama a la Responses API y responde con una sola llamada. Código en Node 22.

Source: https://whasp.ai/es/blog/bot-whatsapp-chatgpt-api-openai · 2026-10-20

## Lo esencial

- Un agente de WhatsApp sobre la API de OpenAI tiene tres piezas: un webhook que recibe cada mensaje, una llamada a la Responses API con la conversación hasta ese momento y un POST que envía la respuesta.
- Verifica la firma de Standard Webhooks sobre el cuerpo en bruto, escribe el evento en tu base de datos y responde 204 antes de llamar al modelo, porque una llamada al modelo puede superar la ventana de entrega de 10 segundos.
- Los servidores de whasp no guardan mensajes, así que guardas tú la conversación y envías al modelo los últimos turnos en cada petición.
- Construye la Idempotency-Key de la respuesta a partir del id del mensaje entrante, para que un evento repetido nunca envíe una segunda respuesta.

Un agente de WhatsApp con la API de OpenAI necesita tres piezas: un número de WhatsApp que envía cada mensaje entrante a un webhook, un servidor que verifica cada evento y llama a la Responses API de OpenAI con la conversación hasta ese momento, y una llamada a la API que envía la respuesta. Este tutorial construye las tres en unas 120 líneas de TypeScript sobre Node 22, con whasp como proveedor del número.

## ¿Cómo funciona el agente?

El agente funciona en cinco pasos, y solo el primero y el último tocan WhatsApp.

1. Una persona escribe a tu número.
2. whasp envía por POST un evento `message.received` firmado a tu servidor. Ese evento lleva la única copia que vas a recibir, porque los servidores de whasp no guardan mensajes.
3. Tu servidor verifica la firma, guarda el evento y responde `204`.
4. En segundo plano, carga de tu propia base de datos los últimos turnos del chat y llama a la Responses API.
5. Envía la respuesta con `POST /v1/messages`.

Dos datos sobre los números de whasp condicionan este diseño. La gente escribe primero: un número no puede abrir un chat en frío (409 `chat_not_open`), así que el agente solo responde. Y tu base de datos es la memoria del agente, porque los servidores de whasp no almacenan historial. [Poner un agente de IA en WhatsApp en 2026: todas las opciones](https://whasp.ai/es/blog/agente-ia-whatsapp-opciones) compara cada vía en otra guía.

## ¿Qué necesitas antes de empezar?

Este tutorial necesita Node 22.18 o superior, una clave de API de OpenAI y un número de whasp con su clave de API.

- **Node 22.18 o superior.** Desde esa versión, [Node ejecuta archivos `.ts` directamente](https://nodejs.org/api/typescript.html) quitándoles los tipos, así que no hay paso de compilación. [`node:sqlite`](https://nodejs.org/api/sqlite.html) viene incluido desde la 22.13 y muestra una advertencia de función experimental en Node 22.
- **Una clave de API de OpenAI.**
- **Un número de whasp y su clave de API**, ambos desde el panel. Un número cuesta $49 cada 30 días, descontados de un saldo prepago.

Crea el proyecto:

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

Después añade un archivo `.env`. El secreto del webhook sale del paso de registro, casi al final.

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

## ¿Cómo es el servidor completo?

El agente entero es un solo archivo, `server.ts`. Guarda los eventos entrantes y la conversación en un archivo SQLite, así que un reinicio no pierde 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 = `Eres Ava, una asistente amable a la que la gente escribe por WhatsApp.
Escribe como una persona por chat: corto, en texto plano, sin encabezados ni tablas en Markdown.
Responde en el idioma en que te escriba la persona. Haz como máximo una pregunta a la vez.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // exactas, en mayú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(); // lee 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(); // cuerpo en bruto: verifica antes de parsear
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // Primero guarda, después responde. Un id de evento repetido no inserta 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);
});

// Un carril por chat: los mensajes de una persona se procesan en orden, y las personas en 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); // queda con done = 0: se reprocesa en el siguiente arranque
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "Por ahora solo puedo leer mensajes de texto.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "Te has dado de baja. Escríbeme cuando quieras para empezar de nuevo.", `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, // el historial vive en nuestra base de datos, no en la de 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, // una repetición nunca envía dos veces
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Baja, chat no abierto, número no disponible: reintentar no sirve de nada.
      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 qué el webhook guarda el evento antes de responder?

El manejador del webhook escribe el evento en SQLite antes de responder, porque whasp elimina el cuerpo de un evento una vez entregado, y el mensaje no existe en ningún otro sitio. whasp da por bueno cualquier 2xx recibido en 10 segundos y, si no, reintenta hasta 12 veces. Una llamada al modelo puede durar más de 10 segundos, así que el manejador no puede esperarla. Si escribes primero y respondes después, en cuanto whasp ve `204` el mensaje ya está a salvo en tu disco.

Tres detalles del manejador importan:

- **El cuerpo en bruto.** `c.req.text()` devuelve los bytes que whasp firmó. La clase `Webhook` de [`standardwebhooks`](https://www.standardwebhooks.com) comprueba `webhook-id`, `webhook-timestamp` (rechaza cualquier valor a más de cinco minutos de diferencia) y `webhook-signature` contra `<id>.<timestamp>.<body>`, así que verificar un objeto ya parseado y vuelto a serializar falla.
- **Duplicados.** `INSERT OR IGNORE` sobre el id del evento convierte una entrega repetida en una operación vacía, y el duplicado igualmente recibe un `204`, o whasp seguiría reintentándolo.
- **Repetición y orden.** Las filas con `done = 0` se vuelven a encolar al arrancar. El carril por chat de `enqueue` procesa en orden los mensajes de una persona mientras personas distintas se procesan en paralelo.

La guía del [webhook de WhatsApp](https://whasp.ai/es/blog/webhook-whatsapp-firma-reintentos-duplicados) cubre lo que esta versión deja fuera: reintentos del worker, el calendario de reintentos de whasp, el orden y las pruebas.

## ¿Cómo llama el código a la Responses API de OpenAI?

El agente llama a `openai.responses.create` una vez por mensaje. [OpenAI recomienda la Responses API para todos los proyectos nuevos](https://developers.openai.com/api/docs/guides/migrate-to-responses), y esta petición usa siete campos:

| Campo | Valor en el código | Por qué |
| --- | --- | --- |
| `model` | `gpt-6-luna` | El modelo más eficiente que lista OpenAI; cámbialo modificando `MODEL`. |
| `instructions` | el system prompt | Un campo de primer nivel, así que no hay mensaje de sistema en el historial. |
| `input` | los últimos 20 turnos como `{ role, content }` | La API no guarda estado: tú reenvías el historial. |
| `reasoning.effort` | `low` | Un chat rara vez necesita razonamiento profundo, y menos esfuerzo es más rápido. |
| `max_output_tokens` | `1000` | Limita el coste de una respuesta. Los tokens de razonamiento cuentan para el límite. |
| `store` | `false` | Evita que OpenAI guarde el objeto de respuesta. |
| `safety_identifier` | SHA-256 del número de teléfono | Permite a OpenAI rastrear un abuso hasta un usuario final. |

Al 19 de octubre de 2026, [la lista de modelos de OpenAI](https://developers.openai.com/api/docs/models) incluye tres modelos principales: `gpt-6-luna` ($0,10 de entrada y $0,50 de salida por millón de tokens, pensado para trabajo de alto volumen), `gpt-6.1-sol` ($2 y $10) y `gpt-6-astra` ($10 y $50, el más capaz). Con los precios de Luna, una respuesta que lee 1.500 tokens y escribe 150 cuesta unos $0,0002, más los tokens de razonamiento.

El historial es tuyo a propósito. La API no guarda estado salvo que encadenes `previous_response_id` o adjuntes un [objeto Conversations](https://developers.openai.com/api/docs/guides/conversation-state), y los objetos de respuesta se guardan 30 días por defecto. Este agente reenvía turnos de texto desde su propia base de datos y fija `store: false`, así que borrar o resumir una conversación nunca depende de OpenAI. La contrapartida: con modelos de razonamiento, OpenAI recomienda reproducir todos los elementos de salida, no solo el texto. Un historial solo de texto pierde los elementos de razonamiento, algo aceptable para respuestas cortas de chat con esfuerzo bajo.

`safety_identifier` lleva un ID estable y con hash por usuario final. OpenAI indica que se [aplique un hash al nombre de usuario o al correo](https://developers.openai.com/api/docs/guides/safety-best-practices) en lugar de enviarlos tal cual, y por eso el código aplica un hash al número de teléfono.

Veinte turnos son una ventana, no memoria: un usuario que vuelve tras tres semanas solo recibe sus últimos veinte mensajes. Para los resúmenes y los datos a largo plazo, mira [agente de IA en WhatsApp con memoria](https://whasp.ai/es/blog/memoria-agente-ia-whatsapp); para el system prompt, [prompts para un agente de IA en WhatsApp que suene humano](https://whasp.ai/es/blog/prompts-agente-ia-whatsapp-humano).

## ¿Cómo responde el agente y qué puede salir mal?

El agente responde con un único `POST /v1/messages`. Envía `to` y `text` y omite `from`, así que whasp usa el número al que esa persona escribió por última vez. La API responde `202` con el mensaje en estado `queued`, y un número envía sus mensajes de uno en uno, con un segundo de diferencia aproximadamente.

La cabecera `Idempotency-Key` es `reply-` más el id del mensaje entrante. whasp conserva una clave durante 7 días, y la misma clave devuelve el primer mensaje y nunca envía dos veces. Así, un evento repetido no puede escribirle dos veces a nadie.

La función `send` clasifica los errores en tres grupos:

| Respuesta | Qué significa | Qué hace el código |
| --- | --- | --- |
| 403 `chat_opted_out`, 409 `chat_not_open`, 409 `number_unavailable`, 409 `idempotency_conflict` | Reintentar no sirve: la persona se dio de baja, nunca escribió, el número no está disponible o la misma respuesta ya está en curso. | Lo registra y descarta la respuesta. |
| 429 `rate_limited` (más de 100 solicitudes por 10 segundos por cuenta), 429 `number_daily_limit`, cualquier 5xx | Temporal. | Lo intenta cuatro veces, esperando 2, 4 y 8 segundos, y luego lanza una excepción; el evento se reprocesa en el siguiente arranque. |
| Cualquier otro 4xx | Un fallo de tu lado, como una clave de API equivocada. | Lanza una excepción; el evento queda pendiente hasta que lo arregles. |

**STOP se gestiona dos veces.** whasp da de baja un chat cuando un mensaje es exactamente `STOP`, `UNSUBSCRIBE`, `OPTOUT`, `CANCEL`, `END` o `QUIT` (distingue mayúsculas de minúsculas), o cualquier grafía de `opt out`, y el siguiente envío recibe 403. El código comprueba primero las mismas palabras, se salta el modelo y envía una despedida con `override_optout: true`. Las peticiones educadas como "por favor, para" te tocan a ti: guarda tu propia marca y deja de llamar al modelo para ese chat.

**Los archivos multimedia llegan como archivo.** Una foto o una nota de voz no trae `text` en el evento, así que el código responde con una línea corta. [Transcribir audios de WhatsApp y leer fotos](https://whasp.ai/es/blog/transcribir-audios-whatsapp-agente-ia) muestra cómo descargarlos y transcribirlos.

## ¿Cómo lo ejecutas y lo pones en línea?

Lo ejecutas en cinco pasos.

1. Arranca un túnel para que whasp pueda llegar a tu máquina. `cloudflared tunnel --url http://localhost:3000` imprime una dirección temporal `trycloudflare.com` y [no necesita cuenta de Cloudflare](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/).
2. Registra el endpoint. La respuesta contiene el secreto de firma:

   ```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. Copia el `secret` (empieza por `whsec_`) en `.env` y ejecuta `node --env-file=.env server.ts`.
4. Envía un evento de prueba con `curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY"`. La respuesta informa `delivered` y el código de estado que devolvió tu servidor.
5. Escribe a tu número. `GET /v1/numbers/pick` devuelve un `wa_link` que abre el chat en un teléfono; escribe "hola" y espera la respuesta.

En producción, ejecuta un solo proceso de Node en un host con HTTPS y un disco persistente para `agent.db`. Las plataformas con discos efímeros, o las configuraciones con varias instancias, necesitan una base de datos compartida como Postgres y un bloqueo por chat en lugar de los carriles en memoria. `GET /v1/webhook/deliveries` lista las entregas de los últimos 7 días.

## ¿Qué construir después?

Empieza por lo que los usuarios notan primero: un [agente de IA en WhatsApp con memoria](https://whasp.ai/es/blog/memoria-agente-ia-whatsapp) que recuerde durante semanas y unos [prompts para un agente de IA en WhatsApp que suene humano](https://whasp.ai/es/blog/prompts-agente-ia-whatsapp-humano). Después refuerza la entrega con la guía del [webhook de WhatsApp](https://whasp.ai/es/blog/webhook-whatsapp-firma-reintentos-duplicados), o mira el mismo agente como [bot de WhatsApp con Claude](https://whasp.ai/es/blog/bot-whatsapp-claude-api-anthropic).

## Preguntas

### ¿Cómo conecto ChatGPT a WhatsApp?

Construyes un agente sobre la API de OpenAI: recibes los mensajes de WhatsApp en un webhook, llamas a la Responses API con la conversación y devuelves la respuesta con una API de mensajería como el POST /v1/messages de whasp.

### ¿Qué modelo de OpenAI debe usar un bot de WhatsApp?

Para respuestas de chat, empieza con gpt-6-luna, el modelo más eficiente de OpenAI, a $0,10 de entrada y $0,50 de salida por millón de tokens el 19 de octubre de 2026. Sube a gpt-6.1-sol si las respuestas necesitan más razonamiento.

### ¿La Responses API de OpenAI recuerda la conversación?

Solo si se lo pides. Por defecto cada petición es independiente, y continúas un hilo reenviando el historial, pasando previous_response_id o adjuntando un objeto Conversations. Este tutorial reenvía su propio historial y fija store en false.

### ¿Cómo evito que un bot de WhatsApp responda dos veces a un mismo mensaje?

Deduplica los eventos entrantes por el id del evento y envía cada respuesta con una Idempotency-Key construida a partir del id del mensaje entrante. Así, una entrega reintentada o una repetición tras una caída no pueden enviar una segunda respuesta.

### ¿Puede el agente iniciar una conversación?

No. En whasp un número solo puede responder a quien le escribió primero, y un envío a cualquier otra persona devuelve 409 chat_not_open. Da a los usuarios nuevos un enlace wa.me para que abran el chat.
