DEV Community

Cover image for I built a lightweight WhatsApp gateway that stores nothing
Alexandru Bau
Alexandru Bau

Posted on AI-assisted

I built a lightweight WhatsApp gateway that stores nothing

You built an agent. It calls tools, remembers context, maybe even books meetings. Then someone asks: "Can I just WhatsApp it?"

You have two options. The official WhatsApp Cloud API means business verification, message templates and per-conversation pricing. That's heavy for a prototype or an internal assistant. Unofficial tools like WAHA and OpenWA are powerful, but they're built to automate WhatsApp. I wanted something built to let an agent talk, so I built Whappr.

Heads-up: Whappr is under active development. Until v1 is released, any update may include breaking changes, so pin exact versions of the Docker image and the npm packages.

What Whappr is

Whappr is a self-hosted Docker container that links to a WhatsApp account the same way WhatsApp Web does. Then it does two jobs:

  1. Inbound: it sends every event (new message, edit, reaction) to your agent as an HMAC-signed webhook.
  2. Outbound: your agent replies through a small REST API protected by a bearer token.

Under the hood, it's a thin Hono API in front of whatsapp-web.js. A few choices make it a good fit for agents:

  • One webhook per event, in order. Every event carries data.chat.id, so each conversation can go to its own agent instance.
  • No database. Messages pass straight through and are never stored. Your agent owns its memory.
  • Agent UX built in. It handles "typing…", read receipts, quoted replies and voice notes in both directions.
  • Typed end to end. @whappr/client and @whappr/protocol run on Node and edge runtimes, and @whappr/flue plugs into the Flue agent framework.

It intentionally leaves out status updates, channels, broadcasts, group admin, polls, payments and calls.

From zero to a reply in five minutes

1. Run the gateway and point it at your agent:

docker run -p 3000:3000 \
  -e SECRET_KEY=$(openssl rand -hex 32) \
  -e WEBHOOK_URL=https://example.com/webhook \
  -v whappr-data:/whappr/apps/gateway/data \
  ghcr.io/whappr/whappr-gateway:latest
Enter fullscreen mode Exit fullscreen mode

2. Pair a spare WhatsApp number. Open http://localhost:3000, enter your SECRET_KEY, and scan the QR code (Linked devices → Link a device). The volume keeps the session, so you only scan once.

3. Handle the webhook. This handler checks the signature, acknowledges right away, then replies while WhatsApp shows "typing…":

import { createWhapprClient, type Message, type WebhookPayload } from '@whappr/client';
import { verifyWebhook, WHAPPR_SIGNATURE_HEADER, WHAPPR_TIMESTAMP_HEADER } from '@whappr/protocol';

const secret = process.env.SECRET_KEY!;
const whappr = createWhapprClient({ baseUrl: 'http://localhost:3000', secret });

export async function webhook(request: Request): Promise<Response> {
  const body = await request.text();
  const valid = await verifyWebhook(
    secret,
    request.headers.get(WHAPPR_TIMESTAMP_HEADER) ?? '',
    body,
    request.headers.get(WHAPPR_SIGNATURE_HEADER) ?? '',
  );
  if (!valid) return new Response(null, { status: 401 });

  const event: WebhookPayload = JSON.parse(body);
  if (event.type === 'message.received') {
    reply(event.data).catch(console.error); // don't block: each webhook gets 5 seconds
  }
  return new Response(null, { status: 204 });
}

async function reply(message: Message) {
  const chatId = message.chat.id;
  await whappr.chats.whileTyping(chatId, async () => {
    const text = await myAgent.run(message.text ?? ''); // your LLM call
    await whappr.messages.send({ to: chatId, text, replyTo: message.id });
  });
}
Enter fullscreen mode Exit fullscreen mode

Two details matter in production. First, ack first, think later: events arrive in order, so one slow handler holds up every message behind it. Second, whileTyping refreshes itself: WhatsApp clears "typing…" after about 25 seconds, and slow models often take longer than that.

Three features worth showing off

Voice notes. Voice messages arrive as kind: 'voice'. Download the audio and pass it to any speech-to-text API:

if (event.type === 'message.received' && event.data.kind === 'voice') {
  const audio = await whappr.messages.media.download(event.data.id); // a Blob
}
Enter fullscreen mode Exit fullscreen mode

To reply with a voice note, send OGG/Opus with as: 'voice'.

A model that can only reply to its own chat. Build the reply tool once per conversation. The model picks the text, and your code fixes the recipient, so a prompt injection can't send messages to another number:

export const replyInChat = (chatId: string) => defineTool({
  name: 'send_whatsapp_message',
  description: 'Send a WhatsApp message to this conversation.',
  input: v.object({ text: v.pipe(v.string(), v.minLength(1)) }),
  async run({ data }) {
    const message = await client.messages.send({ to: chatId, text: data.text });
    return { output: { messageId: message.id } };
  },
});
Enter fullscreen mode Exit fullscreen mode

Filtering at the source. Use WEBHOOK_EVENT_TYPES=message.received and WEBHOOK_CHAT_IDS=120363…@g.us to keep your agent in the one team group it belongs to. Events that don't match never leave the gateway.

Whappr vs WAHA vs OpenWA

All three are self-hosted, open source and unofficial. WAHA and OpenWA are full WhatsApp platforms. Whappr connects one agent to one number and does nothing else.

WAHA OpenWA Whappr
License Apache-2.0 MIT MIT
Engines WEBJS, WPP, NOWEB, GOWS whatsapp-web.js, Baileys whatsapp-web.js
Sessions 1 to hundreds Multiple 1 per container
Storage Pluggable SQLite/PostgreSQL, Redis, S3 None
Webhooks Optional HMAC, retries HMAC-signed HMAC always on, one attempt, ordered
Agent extras n8n MCP server, chat-scoped keys Per-chat routing, whileTyping, Flue channel
  • Pick WAHA for many numbers, browserless engines, channels/status automation or n8n flows.
  • Pick OpenWA for a full dashboard, stored history, SDKs in several languages, or a built-in MCP server.
  • Pick Whappr for a TypeScript agent and a gateway that stays out of the way: no database, no stored messages, and the conversation details already handled.

The fine print

All three tools drive WhatsApp Web, not Meta's official Cloud API, so WhatsApp can ban the number you link. Use a dedicated number, only message people who expect it, and keep a fallback. For regulated or large-scale use, use the official API.

Whappr's own trade-offs: each webhook is delivered once, with no retries. You get one number per container. It runs Chromium, so plan for the RAM. And again: it's pre-1.0, so expect breaking changes until v1 and pin exact versions.

Try it

Whappr is MIT-licensed at github.com/whappr/whappr. I'd love your take in the comments:

  • What's your agent missing on WhatsApp?
  • Which framework should get an adapter next: Mastra, LangGraph.js or the Vercel AI SDK?
  • Did one-shot, strictly ordered webhooks ever cause you problems?

If it saves you a weekend, a star helps others find it. And please, use a spare number.

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

The one-attempt delivery limit is clear. I'd distinguish ordered webhook arrival from ordered replies in this example: reply(event.data) runs in the background, so message B can finish its model call and send before message A. A useful fixture is two messages in one chat with A deliberately slow, plus a third in another chat that should stay independent. A per-chat queue on the agent side could preserve reply order without making one conversation block all others. Separately, crash the handler after its 204 but before the reply and document who, if anyone, can recover that accepted event.