Intégration OpenAI WhatsApp : créer un agent GPT en TypeScript

Connecter OpenAI à WhatsApp en TypeScript : webhook signé, historique de conversation, API Responses et réponse en un appel d'API. Code complet pour Node 22.

Un ordinateur portable affichant des lignes de code abstraites, reliées par un trait vert lumineux à un téléphone rempli de bulles de chat.

L’essentiel

  • Un agent WhatsApp sur l'API OpenAI se compose de trois parties : un webhook qui reçoit chaque message, un appel à l'API Responses avec la conversation jusque-là, et un POST qui envoie la réponse.
  • Vérifiez la signature Standard Webhooks sur le corps brut, écrivez l'événement dans votre base et répondez 204 avant d'appeler le modèle, car un appel au modèle peut dépasser la fenêtre de livraison de 10 secondes.
  • Les serveurs de whasp ne conservent aucun message : vous stockez vous-même la conversation et vous envoyez les derniers tours au modèle à chaque requête.
  • Construisez l'Idempotency-Key de la réponse à partir de l'identifiant du message entrant : un événement rejoué ne peut alors jamais envoyer une seconde réponse.

Un agent WhatsApp propulsé par OpenAI se compose de trois parties : un numéro WhatsApp qui envoie chaque message entrant à un webhook, un serveur qui vérifie chaque événement et appelle l’API Responses d’OpenAI avec la conversation jusque-là, et un appel d’API qui envoie la réponse. Ce tutoriel construit les trois en environ 120 lignes de TypeScript sur Node 22, avec whasp pour fournir le numéro.

Comment fonctionne l’agent ?

L’agent fonctionne en cinq étapes, et seules la première et la dernière touchent à WhatsApp.

  1. Une personne écrit à votre numéro.
  2. whasp envoie en POST un événement message.received signé à votre serveur. Cet événement contient la seule copie que vous recevrez, car les serveurs de whasp ne conservent aucun message.
  3. Votre serveur vérifie la signature, stocke l’événement et répond 204.
  4. En arrière-plan, il charge les derniers tours de la conversation depuis votre propre base et appelle l’API Responses.
  5. Il envoie la réponse avec POST /v1/messages.

Deux caractéristiques des numéros whasp déterminent cette conception. Les gens écrivent en premier : un numéro ne peut pas ouvrir une conversation à froid (409 chat_not_open), donc l’agent ne fait que répondre. Et votre base de données est la mémoire de l’agent, car les serveurs de whasp ne stockent aucun historique. Mettre un agent IA sur WhatsApp : toutes les options sont comparées dans un guide à part.

De quoi avez-vous besoin pour commencer ?

Ce tutoriel demande Node 22.18 ou plus récent, une clé d’API OpenAI et un numéro whasp avec sa clé d’API.

  • Node 22.18 ou plus récent. Depuis cette version, Node exécute directement les fichiers .ts en retirant les types : il n’y a donc pas d’étape de build. node:sqlite est intégré depuis la 22.13 et affiche un avertissement de fonctionnalité expérimentale sur Node 22.
  • Une clé d’API OpenAI.
  • Un numéro whasp et sa clé d’API, tous deux depuis le tableau de bord. Un numéro coûte $49 tous les 30 jours, prélevés sur un solde prépayé.

Créez le projet :

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

Ajoutez ensuite un fichier .env. Le secret du webhook vient de l’étape d’enregistrement, vers la fin.

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

À quoi ressemble le serveur complet ?

L’agent tient dans un seul fichier, server.ts. Il garde les événements entrants et la conversation dans un fichier SQLite : un redémarrage ne fait donc rien perdre.

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 = `Tu es Ava, une assistante sympathique que les gens contactent par message sur WhatsApp.
Écris comme une personne qui envoie un texto : court, en texte brut, sans titres ni tableaux Markdown.
Réponds dans la langue de la personne. Pose au plus une question à la fois.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // exacts, en majuscules

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(); // lit 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(); // corps brut : à vérifier avant de l'analyser
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // D'abord on stocke, ensuite on répond. Un identifiant d'événement déjà vu n'insère rien.
  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);
});

// Une file par conversation : les messages d'une personne sont traités dans l'ordre, les personnes en parallèle.
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); // reste à done = 0 : rejoué au prochain démarrage
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "Pour l'instant, je ne sais lire que les messages texte.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "Désinscription faite. Écris-moi quand tu veux pour reprendre.", `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, // l'historique vit dans notre base, pas chez 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, // un rejeu n'envoie jamais deux fois
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Désinscrit, conversation non ouverte, numéro indisponible : réessayer n'y changera rien.
      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");

Pourquoi le webhook stocke-t-il l’événement avant de répondre ?

Le gestionnaire de webhook écrit l’événement dans SQLite avant de répondre, car whasp supprime le corps d’un événement dès qu’il est livré, et le message n’existe nulle part ailleurs. whasp compte comme réussie toute réponse 2xx en moins de 10 secondes, et sinon il retente jusqu’à 12 fois. Un appel au modèle peut durer plus de 10 secondes : le gestionnaire ne peut donc pas l’attendre. Écrire d’abord et répondre ensuite garantit que, dès que whasp voit 204, le message est à l’abri sur votre disque.

Trois détails du gestionnaire comptent :

  • Le corps brut. c.req.text() renvoie les octets que whasp a signés. La classe Webhook de standardwebhooks vérifie webhook-id, webhook-timestamp (elle rejette tout écart de plus de cinq minutes) et webhook-signature par rapport à <id>.<timestamp>.<body> : vérifier un objet analysé puis resérialisé échoue.
  • Les doublons. INSERT OR IGNORE sur l’identifiant d’événement transforme une livraison répétée en opération sans effet, et le doublon reçoit quand même un 204, sinon whasp continuerait à le retenter.
  • Rejeu et ordre. Les lignes avec done = 0 sont remises en file au démarrage. La file par conversation dans enqueue traite les messages d’une personne dans l’ordre, tandis que des personnes différentes sont traitées en parallèle.

Le guide du webhook couvre ce que cette version laisse de côté : les nouvelles tentatives du worker, le calendrier de relance de whasp, l’ordre des événements et les tests.

Comment le code appelle-t-il l’API Responses d’OpenAI ?

L’agent appelle openai.responses.create une fois par message. OpenAI recommande l’API Responses pour tous les nouveaux projets, et cette requête utilise sept champs :

Champ Valeur dans le code Pourquoi
model gpt-6-luna Le modèle le plus efficient qu’OpenAI liste ; changez MODEL pour en prendre un autre.
instructions le system prompt Un champ de premier niveau : pas de message system dans l’historique.
input les 20 derniers tours sous la forme { role, content } L’API est sans état : vous renvoyez l’historique.
reasoning.effort low Un chat demande rarement un raisonnement profond, et un effort plus faible va plus vite.
max_output_tokens 1000 Plafonne le coût d’une réponse. Les tokens de raisonnement comptent dans cette limite.
store false Empêche OpenAI de sauvegarder l’objet de réponse.
safety_identifier SHA-256 du numéro de téléphone Permet à OpenAI de remonter un abus jusqu’à un utilisateur final.

Au 19 octobre 2026, la liste des modèles d’OpenAI compte trois modèles phares : gpt-6-luna ($0,10 en entrée et $0,50 en sortie par million de tokens, conçu pour les gros volumes), gpt-6.1-sol ($2 et $10) et gpt-6-astra ($10 et $50, le plus capable). Aux prix de Luna, une réponse qui lit 1 500 tokens et en écrit 150 coûte environ $0,0002, plus les éventuels tokens de raisonnement.

L’historique est le vôtre, volontairement. L’API est sans état, sauf si vous chaînez previous_response_id ou attachez un objet Conversations, et les objets de réponse sont conservés 30 jours par défaut. Cet agent renvoie les tours de texte depuis sa propre base et met store: false : supprimer ou résumer une conversation ne dépend donc jamais du côté d’OpenAI. Le compromis : pour les modèles de raisonnement, OpenAI recommande de rejouer tous les éléments de sortie, pas seulement le texte. Un historique de texte seul laisse tomber les éléments de raisonnement, ce qui est acceptable pour de courtes réponses de chat à effort faible.

safety_identifier porte un identifiant stable et haché pour chaque utilisateur final. OpenAI demande de hacher le nom d’utilisateur ou l’e-mail plutôt que de l’envoyer tel quel, et c’est pourquoi le code hache le numéro de téléphone.

Vingt tours forment une fenêtre, pas une mémoire : un utilisateur qui revient après trois semaines ne retrouve que ses vingt derniers messages. Le guide Agent IA WhatsApp avec mémoire couvre les résumés et les faits à long terme, et Prompt pour agent IA WhatsApp couvre le system prompt.

Comment l’agent répond-il, et qu’est-ce qui peut mal tourner ?

L’agent répond avec un seul POST /v1/messages. Il envoie to et text et omet from, si bien que whasp utilise le numéro auquel cette personne a écrit en dernier. L’API répond 202 avec le message queued, et un numéro envoie ses messages un par un, à environ une seconde d’écart.

L’en-tête Idempotency-Key vaut reply- suivi de l’identifiant du message entrant. whasp conserve une clé pendant 7 jours, et la même clé renvoie le premier message sans jamais envoyer deux fois. Un événement rejoué ne peut donc écrire deux fois à personne.

La fonction send range les erreurs en trois groupes :

Réponse Ce que cela signifie Ce que fait le code
403 chat_opted_out, 409 chat_not_open, 409 number_unavailable, 409 idempotency_conflict Réessayer n’y changera rien : la personne s’est désinscrite, n’a jamais écrit, le numéro est hors service, ou la même réponse est déjà en cours d’envoi. Consigne l’erreur dans les logs et abandonne la réponse.
429 rate_limited (plus de 100 requêtes par 10 secondes et par compte), 429 number_daily_limit, tout 5xx Temporaire. Essaie quatre fois en attendant 2, 4 et 8 secondes, puis lève une exception ; l’événement est rejoué au prochain démarrage.
Tout autre 4xx Un bug de votre côté, comme une clé d’API erronée. Lève une exception ; l’événement reste en attente jusqu’à ce que vous corrigiez.

STOP est géré deux fois. whasp désinscrit une conversation quand un message est exactement STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END ou QUIT (sensible à la casse), ou toute graphie de opt out, et l’envoi suivant reçoit un 403. Le code vérifie d’abord les mêmes mots, saute le modèle et envoie un seul au revoir avec override_optout: true. Les demandes polies comme « arrête, s’il te plaît » vous reviennent : gardez votre propre indicateur et cessez d’appeler le modèle pour cette conversation.

Les médias arrivent sous forme de fichier. Une photo ou un message vocal n’a pas de text dans l’événement, donc le code répond par une courte phrase. Transcrire un message vocal WhatsApp avec un agent IA montre comment les télécharger et les transcrire.

Comment le lancer et le mettre en ligne ?

Vous le lancez en cinq étapes.

  1. Démarrez un tunnel pour que whasp puisse joindre votre ordinateur portable. cloudflared tunnel --url http://localhost:3000 affiche une adresse trycloudflare.com temporaire et n’exige aucun compte Cloudflare.

  2. Enregistrez l’endpoint. La réponse contient le secret de signature :

    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. Copiez le secret (il commence par whsec_) dans .env, puis lancez node --env-file=.env server.ts.

  4. Envoyez un événement de test avec curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". La réponse indique delivered et le code de statut que votre serveur a renvoyé.

  5. Écrivez à votre numéro. GET /v1/numbers/pick renvoie un wa_link qui ouvre la conversation sur un téléphone ; écrivez « salut » et attendez la réponse.

Pour la production, lancez un seul processus Node sur un hébergeur en HTTPS avec un disque persistant pour agent.db. Les plateformes à disque éphémère, ou les installations à plusieurs instances, ont besoin d’une base partagée comme Postgres et d’un verrou par conversation à la place des files en mémoire. GET /v1/webhook/deliveries liste les livraisons des 7 derniers jours.

Que construire ensuite ?

Commencez par ce que les utilisateurs remarquent en premier : un agent IA WhatsApp avec mémoire qui tient des semaines et un prompt pour agent IA WhatsApp qui se lit comme quelqu’un qui écrit un texto. Renforcez ensuite la livraison avec le guide du webhook, ou voyez le même agent sur Claude.

Questions

Comment connecter ChatGPT à WhatsApp ?

Vous construisez un agent sur l'API OpenAI : vous recevez les messages WhatsApp sur un webhook, vous appelez l'API Responses avec la conversation, puis vous renvoyez la réponse avec une API de messagerie comme le POST /v1/messages de whasp.

Quel modèle OpenAI choisir pour un bot WhatsApp ?

Pour des réponses de chat, commencez par gpt-6-luna, le modèle le plus efficient d'OpenAI, à $0,10 en entrée et $0,50 en sortie par million de tokens au 19 octobre 2026. Passez à gpt-6.1-sol si les réponses demandent plus de raisonnement.

L'API Responses d'OpenAI se souvient-elle de la conversation ?

Pas sans qu'on le lui demande. Une requête est indépendante par défaut, et vous poursuivez un fil en renvoyant l'historique, en passant previous_response_id ou en attachant un objet Conversations. Ce tutoriel renvoie son propre historique et met store à false.

Comment empêcher un bot WhatsApp de répondre deux fois au même message ?

Dédoublonnez les événements entrants sur l'identifiant d'événement et envoyez chaque réponse avec une Idempotency-Key construite à partir de l'identifiant du message entrant. Une livraison retentée ou un rejeu après un plantage ne peut alors pas envoyer une seconde réponse.

L'agent peut-il lancer une conversation ?

Non. Sur whasp, un numéro ne peut répondre qu'aux personnes qui lui ont écrit en premier, et un envoi vers quelqu'un d'autre renvoie 409 chat_not_open. Donnez aux nouveaux utilisateurs un lien wa.me pour qu'ils ouvrent la conversation.

Tous les articles