OpenAI WhatsApp integration: build a GPT agent in TypeScript

Connect OpenAI to WhatsApp in TypeScript: verify the signed webhook, keep the chat history, call the Responses API and reply with one API call. Full code, Node 22.

A laptop showing abstract code lines connected by a glowing green line to a phone full of chat bubbles.

Key takeaways

  • A WhatsApp agent on the OpenAI API is three parts: a webhook that receives each message, a call to the Responses API with the conversation so far, and one POST that sends the reply.
  • Verify the Standard Webhooks signature on the raw body, write the event to your database and answer 204 before calling the model, because a model call can outlast the 10-second delivery window.
  • whasp's servers keep no messages, so you store the conversation yourself and send the last turns to the model on every request.
  • Build the reply's Idempotency-Key from the inbound message id, so a replayed event can never send a second reply.

An OpenAI-powered WhatsApp agent needs three parts: a WhatsApp number that posts every incoming message to a webhook, a server that verifies each event and calls the OpenAI Responses API with the conversation so far, and one API call that sends the reply. This tutorial builds all three in about 120 lines of TypeScript on Node 22, with whasp providing the number.

How does the agent work?

The agent runs in five steps, and only the first and last touch WhatsApp.

  1. A person texts your number.
  2. whasp POSTs a signed message.received event to your server. It holds the only copy you get, because whasp’s servers keep no messages.
  3. Your server verifies the signature, stores the event and answers 204.
  4. In the background, it loads the chat’s recent turns from your own database and calls the Responses API.
  5. It sends the answer with POST /v1/messages.

Two facts about whasp numbers shape this design. People write first: a number cannot open a chat cold (409 chat_not_open), so the agent only ever answers. And your database is the agent’s memory, because whasp’s servers store no history. Every route for putting an agent on WhatsApp is compared in a separate guide.

What do you need before you start?

This tutorial needs Node 22.18 or newer, an OpenAI API key and a whasp number with an API key.

  • Node 22.18 or newer. From that version, Node runs .ts files directly by stripping the types, so there is no build step. node:sqlite has been built in since 22.13 and prints an experimental-feature warning on Node 22.
  • An OpenAI API key.
  • A whasp number and API key, both from the dashboard. A number costs $49 per 30 days from a prepaid balance.

Create the project:

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

Then add a .env file. The webhook secret comes from the registration step near the end.

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

What does the complete server look like?

The whole agent is one file, server.ts. It keeps incoming events and the conversation in a SQLite file, so a restart loses nothing.

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 = `You are Ava, a friendly assistant that people text on WhatsApp.
Write like a person texting: short, plain text, no Markdown headings or tables.
Answer in the language the person writes in. Ask at most one question at a time.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // exact, upper case

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(); // reads 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(); // raw body: verify before parsing
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // Store first, answer second. A repeated event id inserts nothing.
  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);
});

// One lane per chat: a person's messages are handled in order, people in parallel.
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); // stays done = 0: replayed at the next start
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "I can only read text messages for now.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "You are unsubscribed. Write any time to start again.", `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, // the history lives in our database, not in OpenAI's
    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, // a replay never sends twice
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Opted out, chat not open, number unavailable: retrying will not help.
      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");

Why does the webhook store the event before answering?

The webhook handler writes the event to SQLite before it answers, because whasp deletes an event’s body once it is delivered, and the message exists nowhere else. whasp counts any 2xx within 10 seconds as a success and otherwise retries up to 12 times. A model call can run longer than 10 seconds, so the handler cannot wait for it. Writing first and answering second means that once whasp sees 204, the message is safe on your disk.

Three details in the handler matter:

  • The raw body. c.req.text() returns the bytes whasp signed. The Webhook class from standardwebhooks checks webhook-id, webhook-timestamp (it rejects anything more than five minutes off) and webhook-signature against <id>.<timestamp>.<body>, so verifying a parsed and re-serialized object fails.
  • Duplicates. INSERT OR IGNORE on the event id turns a repeated delivery into a no-op, and the duplicate still gets a 204, or whasp would keep retrying it.
  • Replay and order. Rows with done = 0 are queued again at startup. The per-chat lane in enqueue runs one person’s messages in order while different people run in parallel.

The webhook guide covers what this version leaves out: worker retries, whasp’s retry schedule, ordering and testing.

How does the code call the OpenAI Responses API?

The agent calls openai.responses.create once per message. OpenAI recommends the Responses API for all new projects, and this request uses seven fields:

Field Value in the code Why
model gpt-6-luna The most efficient model OpenAI lists; swap it by changing MODEL.
instructions the system prompt A top-level field, so no system message in the history.
input the last 20 turns as { role, content } The API is stateless: you resend the history.
reasoning.effort low Chat rarely needs deep reasoning, and lower effort is faster.
max_output_tokens 1000 Caps the cost of one reply. Reasoning tokens count toward it.
store false Keeps OpenAI from saving the response object.
safety_identifier SHA-256 of the phone number Lets OpenAI trace abuse to one end user.

As of 19 October 2026, OpenAI’s model list has three flagship models: gpt-6-luna ($0.10 input and $0.50 output per million tokens, built for high-volume work), gpt-6.1-sol ($2 and $10) and gpt-6-astra ($10 and $50, the most capable). At Luna’s prices, a reply that reads 1,500 tokens and writes 150 costs about $0.0002, plus any reasoning tokens.

The history is yours on purpose. The API is stateless unless you chain previous_response_id or attach a Conversations object, and response objects are saved for 30 days by default. This agent resends text turns from its own database and sets store: false, so deleting or summarizing a conversation never depends on OpenAI’s side. The trade-off: for reasoning models OpenAI recommends replaying every output item, not just the text. A text-only history drops the reasoning items, which is acceptable for short chat replies at low effort.

safety_identifier carries a stable, hashed ID per end user. OpenAI says to hash the username or email instead of sending it, which is why the code hashes the phone number.

Twenty turns is a window, not memory: a user back after three weeks gets only their last twenty messages. Conversation memory covers summaries and long-term facts, and writing for WhatsApp covers the system prompt.

How does the agent reply, and what can go wrong?

The agent replies with one POST /v1/messages. It sends to and text and leaves from out, so whasp uses the number that person last wrote to. The API answers 202 with the message queued, and a number sends its messages one at a time, about a second apart.

The Idempotency-Key header is reply- plus the inbound message id. whasp keeps a key for 7 days, and the same key returns the first message and never sends twice. A replayed event therefore cannot double-text anyone.

The send function sorts errors into three groups:

Response What it means What the code does
403 chat_opted_out, 409 chat_not_open, 409 number_unavailable, 409 idempotency_conflict Retrying will not help: the person opted out, never wrote, the number is down, or the same reply is already in flight. Logs it and drops the reply.
429 rate_limited (over 100 requests per 10 seconds per account), 429 number_daily_limit, any 5xx Temporary. Tries four times, waiting 2, 4 and 8 seconds, then throws; the event is replayed at the next start.
Any other 4xx A bug on your side, such as a wrong API key. Throws; the event stays pending until you fix it.

STOP is handled twice. whasp opts a chat out when a message is exactly STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END or QUIT (case sensitive), or any spelling of opt out, and the next send gets 403. The code checks the same words first, skips the model, and sends one goodbye with override_optout: true. Polite requests such as “please stop” are yours to catch: keep your own flag and stop calling the model for that chat.

Media arrives as a file. A photo or a voice note has no text in the event, so the code answers with a short line. Voice notes, photos and files shows how to download and transcribe them.

How do you run it and put it online?

You run it in five steps.

  1. Start a tunnel so whasp can reach your laptop. cloudflared tunnel --url http://localhost:3000 prints a temporary trycloudflare.com address and needs no Cloudflare account.

  2. Register the endpoint. The response holds the signing secret:

    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. Copy the secret (it starts with whsec_) into .env, then run node --env-file=.env server.ts.

  4. Send a test event with curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". The answer reports delivered and the status code your server returned.

  5. Text your number. GET /v1/numbers/pick returns a wa_link that opens the chat on a phone; write “hi” and wait for the reply.

For production, run one Node process on a host with HTTPS and a persistent disk for agent.db. Platforms with ephemeral disks, or setups with several instances, need a shared database such as Postgres and a lock per chat instead of the in-memory lanes. GET /v1/webhook/deliveries lists the last 7 days of deliveries.

What should you build next?

Start with what users notice first: memory that survives weeks and a system prompt that reads like a person texting. Then harden delivery with the webhook guide, or see the same agent on Claude.

Questions

How do I connect ChatGPT to WhatsApp?

You build an agent on the OpenAI API: receive WhatsApp messages on a webhook, call the Responses API with the conversation, and send the answer back with a messaging API such as whasp's POST /v1/messages.

Which OpenAI model should a WhatsApp bot use?

For chat replies, start with gpt-6-luna, OpenAI's most efficient model at $0.10 input and $0.50 output per million tokens on 19 October 2026. Move up to gpt-6.1-sol if the answers need more reasoning.

Does the OpenAI Responses API remember the conversation?

Not unless you ask it to. A request is independent by default, and you continue a thread by resending the history, passing previous_response_id or attaching a Conversations object. This tutorial resends its own history and sets store to false.

How do I stop a WhatsApp bot from replying twice to one message?

Dedupe incoming events on the event id and send each reply with an Idempotency-Key built from the inbound message id. Then a retried delivery or a replay after a crash cannot send a second reply.

Can the agent start a conversation?

No. On whasp a number can only answer people who wrote to it first, and a send to anyone else returns 409 chat_not_open. Give new users a wa.me link so they can start the chat.

All articles