Collegare WhatsApp a ChatGPT: agente OpenAI in TypeScript

Collega OpenAI a WhatsApp in TypeScript: verifica il webhook firmato, salva la cronologia, chiama la Responses API e rispondi con una chiamata. Codice completo, Node 22.

Un laptop con righe di codice astratte collegate da una linea verde luminosa a un telefono pieno di fumetti di chat.

In breve

  • Un agente WhatsApp sull'API di OpenAI ha tre parti: un webhook che riceve ogni messaggio, una chiamata alla Responses API con la conversazione fin lì e una POST che invia la risposta.
  • Verifica la firma Standard Webhooks sul body grezzo, scrivi l'evento nel database e rispondi 204 prima di chiamare il modello, perché una chiamata al modello può durare più dei 10 secondi della finestra di consegna.
  • I server di whasp non conservano i messaggi, quindi la conversazione la salvi tu e a ogni richiesta mandi al modello gli ultimi turni.
  • Costruisci l'Idempotency-Key della risposta con l'id del messaggio in arrivo, così un evento rigiocato non può mai inviare una seconda risposta.

Un agente WhatsApp basato su OpenAI ha bisogno di tre parti: un numero WhatsApp che manda ogni messaggio in arrivo a un webhook, un server che verifica ogni evento e chiama la Responses API di OpenAI con la conversazione fin lì, e una chiamata API che invia la risposta. Questa guida costruisce le tre parti in circa 120 righe di TypeScript su Node 22, con whasp che fornisce il numero.

Come funziona l’agente?

L’agente funziona in cinque passaggi, e solo il primo e l’ultimo toccano WhatsApp.

  1. Una persona scrive al tuo numero.
  2. whasp fa una POST con un evento message.received firmato al tuo server. L’evento contiene l’unica copia che riceverai, perché i server di whasp non conservano i messaggi.
  3. Il tuo server verifica la firma, salva l’evento e risponde 204.
  4. In background, carica dal tuo database gli ultimi turni della chat e chiama la Responses API.
  5. Invia la risposta con POST /v1/messages.

Due fatti sui numeri whasp condizionano questa struttura. Le persone scrivono per prime: un numero non può aprire una chat a freddo (409 chat_not_open), quindi l’agente può solo rispondere. E il tuo database è la memoria dell’agente, perché i server di whasp non salvano alcuna cronologia. Tutte le strade per mettere un agente AI su WhatsApp sono messe a confronto in una guida a parte.

Cosa serve prima di cominciare?

Questa guida richiede Node 22.18 o più recente, una chiave API di OpenAI e un numero whasp con la sua chiave API.

  • Node 22.18 o più recente. Da questa versione Node esegue direttamente i file .ts rimuovendo i tipi, quindi non c’è nessun passaggio di build. node:sqlite è integrato dalla 22.13 e su Node 22 stampa un avviso di funzionalità sperimentale.
  • Una chiave API di OpenAI.
  • Un numero whasp e la sua chiave API, entrambi dalla dashboard. Un numero costa $49 ogni 30 giorni, addebitati da un saldo prepagato.

Crea il progetto:

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

Poi aggiungi un file .env. Il segreto del webhook arriva dalla registrazione, verso la fine.

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

Com’è fatto il server completo?

L’intero agente sta in un solo file, server.ts. Tiene gli eventi in arrivo e la conversazione in un file SQLite, quindi un riavvio non fa perdere nulla.

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 = `Sei Ava, l'assistente amichevole a cui le persone scrivono su WhatsApp.
Scrivi come una persona che manda messaggi: brevi, in testo semplice, senza titoli Markdown né tabelle.
Rispondi nella lingua in cui scrive la persona. Fai al massimo una domanda alla volta.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // esatto, maiuscolo

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(); // legge 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(); // body grezzo: verifica prima di fare il parse
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // Prima salva, poi rispondi. Un id evento ripetuto non inserisce nulla.
  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);
});

// Una corsia per chat: i messaggi di una persona in ordine, persone diverse in parallelo.
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); // resta done = 0: viene rieseguito al prossimo avvio
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "Per ora riesco a leggere solo messaggi di testo.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "Disiscrizione confermata. Scrivi quando vuoi per ricominciare.", `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, // la cronologia sta nel nostro database, non in quello di 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 replay non invia mai due volte
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Disiscritto, chat non aperta, numero non disponibile: riprovare non serve.
      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");

Perché il webhook salva l’evento prima di rispondere?

Il gestore del webhook scrive l’evento su SQLite prima di rispondere, perché whasp elimina il corpo di un evento una volta consegnato e il messaggio non esiste da nessun’altra parte. whasp considera riuscita qualsiasi risposta 2xx entro 10 secondi e altrimenti ritenta fino a 12 volte. Una chiamata al modello può durare più di 10 secondi, quindi il gestore non può aspettarla. Scrivere prima e rispondere dopo significa che, quando whasp vede il 204, il messaggio è al sicuro sul tuo disco.

Tre dettagli del gestore contano:

  • Il body grezzo. c.req.text() restituisce i byte che whasp ha firmato. La classe Webhook di standardwebhooks controlla webhook-id, webhook-timestamp (rifiuta tutto ciò che sfasa di oltre cinque minuti) e webhook-signature rispetto a <id>.<timestamp>.<body>, quindi verificare un oggetto già parsato e serializzato di nuovo fallisce.
  • I duplicati. INSERT OR IGNORE sull’id dell’evento trasforma una consegna ripetuta in un’operazione nulla, e il duplicato riceve comunque un 204, altrimenti whasp continuerebbe a ritentarlo.
  • Replay e ordine. Le righe con done = 0 vengono rimesse in coda all’avvio. La corsia per chat in enqueue esegue in ordine i messaggi di una persona e in parallelo quelli di persone diverse.

La guida ai webhook WhatsApp copre ciò che questa versione lascia fuori: retry dei worker, tempi dei retry di whasp, ordine e test.

Come chiama il codice la Responses API di OpenAI?

L’agente chiama openai.responses.create una volta per messaggio. OpenAI raccomanda la Responses API per tutti i nuovi progetti, e questa richiesta usa sette campi:

Campo Valore nel codice Perché
model gpt-6-luna Il modello più efficiente nell’elenco di OpenAI; per cambiarlo modifichi MODEL.
instructions il system prompt Un campo di primo livello, quindi nessun messaggio di sistema nella cronologia.
input gli ultimi 20 turni come { role, content } L’API è stateless: la cronologia la rimandi tu.
reasoning.effort low In chat di rado serve un ragionamento profondo, e uno sforzo minore è più veloce.
max_output_tokens 1000 Mette un tetto al costo di una risposta. I token di ragionamento ci rientrano.
store false Evita che OpenAI salvi l’oggetto della risposta.
safety_identifier SHA-256 del numero di telefono Permette a OpenAI di ricondurre un abuso a un singolo utente finale.

Al 19 ottobre 2026 l’elenco dei modelli di OpenAI ha tre modelli di punta: gpt-6-luna ($0,10 in input e $0,50 in output per milione di token, pensato per i grandi volumi), gpt-6.1-sol ($2 e $10) e gpt-6-astra ($10 e $50, il più capace). Ai prezzi di Luna, una risposta che legge 1.500 token e ne scrive 150 costa circa $0,0002, più gli eventuali token di ragionamento.

La cronologia è tua di proposito. L’API è stateless, a meno che tu non colleghi previous_response_id o un oggetto Conversations, e gli oggetti risposta vengono salvati per 30 giorni per impostazione predefinita. Questo agente rimanda i turni di testo dal proprio database e imposta store: false, quindi cancellare o riassumere una conversazione non dipende mai dal lato di OpenAI. Il compromesso: per i modelli di ragionamento OpenAI raccomanda di rigiocare tutti gli elementi di output, non solo il testo. Una cronologia di solo testo perde gli elementi di ragionamento, cosa accettabile per risposte di chat brevi a basso sforzo.

safety_identifier porta un ID stabile e con hash per ogni utente finale. OpenAI dice di fare l’hash di username o email invece di inviarli, ed è per questo che il codice fa l’hash del numero di telefono.

Venti turni sono una finestra, non una memoria: chi torna dopo tre settimane ritrova solo i suoi ultimi venti messaggi. La memoria della conversazione tratta riassunti e fatti a lungo termine, e come far scrivere un agente AI come una persona tratta il system prompt.

Come risponde l’agente e cosa può andare storto?

L’agente risponde con una sola POST /v1/messages. Invia to e text e omette from, così whasp usa il numero a cui quella persona ha scritto per ultimo. L’API risponde 202 con il messaggio in stato queued, e un numero invia i suoi messaggi uno alla volta, a circa un secondo di distanza.

L’header Idempotency-Key è reply- più l’id del messaggio in arrivo. whasp conserva una chiave per 7 giorni, e la stessa chiave restituisce il primo messaggio senza mai inviare due volte. Un evento rigiocato non può quindi scrivere due volte a nessuno.

La funzione send smista gli errori in tre gruppi:

Risposta Cosa significa Cosa fa il codice
403 chat_opted_out, 409 chat_not_open, 409 number_unavailable, 409 idempotency_conflict Riprovare non serve: la persona si è disiscritta, non ha mai scritto, il numero è fuori servizio o la stessa risposta è già in corso. Lo scrive nel log e scarta la risposta.
429 rate_limited (oltre 100 richieste ogni 10 secondi per account), 429 number_daily_limit, qualsiasi 5xx Temporaneo. Prova quattro volte aspettando 2, 4 e 8 secondi, poi lancia un errore; l’evento viene rigiocato al prossimo avvio.
Qualsiasi altro 4xx Un bug dalla tua parte, per esempio una chiave API sbagliata. Lancia un errore; l’evento resta in sospeso finché non lo correggi.

STOP viene gestito due volte. whasp disiscrive una chat quando un messaggio è esattamente STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END o QUIT (distingue maiuscole e minuscole), o qualsiasi grafia di opt out, e l’invio successivo riceve 403. Il codice controlla prima le stesse parole, salta il modello e invia un solo messaggio di saluto con override_optout: true. Le richieste educate come «per favore smetti» spettano a te: tieni un tuo flag e smetti di chiamare il modello per quella chat.

I contenuti multimediali arrivano come file. Una foto o un messaggio vocale non hanno text nell’evento, quindi il codice risponde con una riga breve. Trascrivere messaggi vocali e foto su WhatsApp mostra come scaricarli e trascriverli.

Come lo avvii e lo metti online?

Lo avvii in cinque passaggi.

  1. Avvia un tunnel così whasp può raggiungere il tuo laptop. cloudflared tunnel --url http://localhost:3000 stampa un indirizzo temporaneo trycloudflare.com e non richiede un account Cloudflare.

  2. Registra l’endpoint. La risposta contiene il segreto di firma:

    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 il secret (inizia con whsec_) in .env, poi esegui node --env-file=.env server.ts.

  4. Invia un evento di prova con curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". La risposta riporta delivered e lo status code che il tuo server ha restituito.

  5. Scrivi al tuo numero. GET /v1/numbers/pick restituisce un wa_link che apre la chat su un telefono; scrivi «ciao» e aspetta la risposta.

In produzione, esegui un solo processo Node su un host con HTTPS e un disco persistente per agent.db. Le piattaforme con dischi effimeri, o le configurazioni con più istanze, richiedono un database condiviso come Postgres e un lock per chat al posto delle corsie in memoria. GET /v1/webhook/deliveries elenca le consegne degli ultimi 7 giorni.

Cosa costruire dopo?

Parti da ciò che gli utenti notano per primo: una memoria della conversazione che dura settimane e un system prompt che fa scrivere l’agente come una persona. Poi rendi più affidabile la consegna con la guida ai webhook WhatsApp, oppure guarda lo stesso agente per collegare WhatsApp a Claude.

Domande

Come collego ChatGPT a WhatsApp?

Costruisci un agente sull'API di OpenAI: ricevi i messaggi WhatsApp su un webhook, chiama la Responses API con la conversazione e rimanda la risposta con un'API di messaggistica come la POST /v1/messages di whasp.

Quale modello OpenAI conviene usare per un bot WhatsApp?

Per le risposte in chat parti da gpt-6-luna, il modello più efficiente di OpenAI, a $0,10 in input e $0,50 in output per milione di token al 19 ottobre 2026. Passa a gpt-6.1-sol se le risposte richiedono più ragionamento.

La Responses API di OpenAI ricorda la conversazione?

Solo se glielo chiedi. Per impostazione predefinita una richiesta è indipendente: la conversazione prosegue rimandando la cronologia, passando previous_response_id o collegando un oggetto Conversations. Questa guida rimanda la propria cronologia e imposta store a false.

Come evito che un bot WhatsApp risponda due volte allo stesso messaggio?

Deduplica gli eventi in arrivo sull'id dell'evento e invia ogni risposta con un Idempotency-Key costruito con l'id del messaggio in arrivo. Così una consegna ritentata o un replay dopo un crash non possono inviare una seconda risposta.

L'agente può iniziare una conversazione?

No. Su whasp un numero può rispondere solo a chi gli ha scritto per primo, e un invio a chiunque altro restituisce 409 chat_not_open. Dai ai nuovi utenti un link wa.me, così possono aprire la chat.

Tutti gli articoli