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

Source: https://whasp.ai/blog/whatsapp-ai-agent-openai · 2026-10-20

## 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](https://whasp.ai/blog/put-ai-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](https://nodejs.org/api/typescript.html) by stripping the types, so there is no build step. [`node:sqlite`](https://nodejs.org/api/sqlite.html) 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:

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

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

```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 = `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`](https://www.standardwebhooks.com) 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](https://whasp.ai/blog/whatsapp-webhook-handling) 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](https://developers.openai.com/api/docs/guides/migrate-to-responses), 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](https://developers.openai.com/api/docs/models) 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](https://developers.openai.com/api/docs/guides/conversation-state), 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](https://developers.openai.com/api/docs/guides/safety-best-practices) 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](https://whasp.ai/blog/whatsapp-ai-agent-memory) covers summaries and long-term facts, and [writing for WhatsApp](https://whasp.ai/blog/write-for-whatsapp-ai-agent-prompts) 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](https://whasp.ai/blog/whatsapp-voice-notes-ai-agent) 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](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/).
2. Register the endpoint. The response holds the signing secret:

   ```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. 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](https://whasp.ai/blog/whatsapp-ai-agent-memory) and a [system prompt that reads like a person texting](https://whasp.ai/blog/write-for-whatsapp-ai-agent-prompts). Then harden delivery with the [webhook guide](https://whasp.ai/blog/whatsapp-webhook-handling), or see the [same agent on Claude](https://whasp.ai/blog/whatsapp-ai-agent-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.
