A self-hosted WhatsApp automation system. Send and receive WhatsApp messages over a simple REST interface, on a server you control β or plug it into AI tools through the built-in MCP server. Built on Baileys, runs in Docker, stores everything in SQLite.
| Send anything | Text, images, documents, video, voice notes, locations, contacts, and polls. |
| Message actions | React, edit, delete, and forward. |
| Conversations | Presence and typing indicators, read receipts, status posting, and channels. |
| Groups & profile | Create and manage groups (participants, invite links, settings); set your name, status, and picture; block and unblock. |
| Receive & react | Incoming messages persisted to SQLite, with per-session webhooks (retry, backoff, optional HMAC signing) and a Server-Sent Events stream. |
| Multi-session | Run several numbers from one instance. QR or phone-code pairing over HTTP. |
| MCP server | Built-in MCP server so AI agents (Claude Code, Claude Desktop, and friends) can read chats and send messages as native tools. |
| Built to run | Auto-reconnect, per-session send queue with light rate limiting, one Docker container with two mounted volumes. |
- A running Docker engine with
docker compose. On macOS that's Docker Desktop, OrbStack, or colima; on Linux, Docker Engine. The daemon must actually be running before you start. - A phone with WhatsApp, to pair the number once via QR.
- Node 22+ only if you want to run it outside Docker (local development).
cp .env.example .env # set WA_API_KEY to any secret string
docker compose up -d --build
curl -s localhost:4000/api/health # {"status":"ok"}The container listens inside on port 3000 and is published to 127.0.0.1:4000 by the compose file.
Pair a number:
POST /api/sessions/default/start- Open the QR:
GET /api/default/auth/qr(PNG), scan it from the phone under Linked Devices - Poll
GET /api/sessions/defaultuntilstatusisWORKING
Send a document:
curl -X POST localhost:4000/api/sendFile \
-H "x-api-key: $WA_API_KEY" -H 'content-type: application/json' \
-d '{"session":"default","chatId":"15551234567@s.whatsapp.net",
"file":{"data":"<base64>","mimetype":"application/pdf","filename":"report.pdf"},
"caption":"here you go"}'npm install
npm run build && npm start # or: npm run dev
npm test # vitestEvery route except /api/health requires X-Api-Key: <WA_API_KEY>. There are two route groups: a clean, versioned /v1 API (recommended for new work) and a set of flatter /api endpoints with the session in the request body, convenient for simple clients.
| Method | Path | Body / notes |
|---|---|---|
| GET / POST | /v1/sessions |
list / create {name} |
| GET / DELETE | /v1/sessions/:name |
status (with qr) / logout |
| POST | /v1/sessions/:name/start|stop|restart |
lifecycle |
| POST | /v1/sessions/:name/messages |
{chatId, type, text?, caption?, media?, location?, contact?, poll?} |
| POST | /v1/sessions/:name/react |
{chatId, msgId, emoji, fromMe?} |
| POST | /v1/sessions/:name/edit |
{chatId, msgId, text} |
| POST | /v1/sessions/:name/delete |
{chatId, msgId, fromMe?} |
| POST | /v1/sessions/:name/forward |
{toChatId, fromChatId, msgId} |
| POST | /v1/sessions/:name/presence |
{type, chatId?} |
| GET | /v1/sessions/:name/chats |
recent chats |
| GET | /v1/sessions/:name/chats/:chatId/messages?limit= |
history |
| GET | /v1/sessions/:name/contacts/check?phone= |
is on WhatsApp |
| GET | /v1/sessions/:name/contacts/:chatId/picture |
profile picture url |
| POST | /v1/sessions/:name/contacts/:chatId/block |
{blocked} |
| PUT | /v1/sessions/:name/profile/name|status |
{name} / {status} |
| POST/GET | /v1/sessions/:name/groups |
create {subject, participants} / list |
| GET | /v1/sessions/:name/groups/:groupId |
metadata |
| POST | /v1/sessions/:name/groups/:groupId/participants |
{participants, action} |
| PUT | /v1/sessions/:name/groups/:groupId/subject|description |
update |
| GET/POST | /v1/sessions/:name/groups/:groupId/invite |
get / invite/revoke |
| POST | /v1/sessions/:name/groups/accept |
{code} |
| POST | /v1/sessions/:name/status |
{text?, media?, statusJidList?} |
| POST/GET/DELETE | /v1/sessions/:name/channels |
create / metadata / follow / delete |
| POST | /v1/sessions/:name/auth/pairing-code |
{phone} (alternative to QR) |
| PUT | /v1/sessions/:name/webhooks |
{urls:[...]} |
| GET | /v1/events |
Server-Sent Events stream |
Flatter endpoints with the session in the body: sendText, sendImage, sendFile, sendVoice, sendVideo, sendLocation, sendContact, sendPoll, sendSeen, reaction, startTyping/stopTyping, session lifecycle, :session/auth/qr, :session/chats/:chatId/messages, :session/groups, contacts/check-exists.
Media takes {data} (base64) or {url} (Pigeon fetches it server-side). Chat ids are <number>@s.whatsapp.net for people and <id>@g.us for groups.
Pigeon ships an MCP (Model Context Protocol) server so AI tools like Claude Code can use WhatsApp directly. It runs over stdio and talks to a running Pigeon instance via the REST API.
Tools: session_status, list_chats, read_messages, read_contact, send_message, send_media, delete_message, mark_read, check_contact, list_groups, create_group, add_participants.
read_contact merges a person's messages across their real number and any privacy-masked @lid chat, since WhatsApp can split one contact across two chats. create_group and add_participants manage group membership.
Register it with your MCP client, e.g. in a .mcp.json:
{
"mcpServers": {
"pigeon": {
"command": "node",
"args": ["/path/to/pigeon/dist/mcp.js"]
}
}
}It reads WA_API_KEY (and optional WA_API_URL, WA_SESSION) from the environment, falling back to the .env in the project root. Build first with npm run build.
Set WA_MCP_READONLY=true to run the MCP server in draft-only mode. send_message and send_media stop hitting WhatsApp: instead of sending, they return the composed draft (sent: false) for you to review. The read tools (session_status, list_chats, read_messages, check_contact) keep working. Use this when you want an assistant to draft replies but never send on its own. There's no per-call override, so it's a hard guarantee for that session.
WhatsApp restricts accounts that message strangers in bursts, especially right after linking a new device. The send guard enforces sane limits on cold sends β first contact with a number that has never messaged you. Warm replies and group messages pass through untouched.
A cold send is blocked when it lands inside the post-link cooldown, too soon after the previous cold send, or over the hourly/daily caps. The send fails with a reason explaining which limit it hit. Warm replies (any number that has messaged you) and group/broadcast/newsletter sends are never gated.
The limits and their defaults:
| Setting | Default | What it does |
|---|---|---|
WA_SEND_GUARD |
on |
master switch; off disables all cold-send limits |
WA_GUARD_POST_CONNECT_MS |
120000 (2 min) |
after the session links or relinks, cold sends are paused for this long |
WA_GUARD_COLD_MIN_GAP_MS |
60000 (1 min) |
minimum gap between two cold sends |
WA_GUARD_COLD_PER_HOUR |
5 |
max cold sends per rolling hour |
WA_GUARD_COLD_PER_DAY |
20 |
max cold sends per rolling day |
Two ways to override on a per-send basis:
- Pass
"force": trueon a single send to bypass the guard for one deliberate, vetted message (works over the HTTP API and the MCP send tools). - Set
WA_SEND_GUARD=offto turn the guard off entirely for the session.
All five settings are read from the environment (falling back to .env in the project root). Edit .env, then restart so the new values load:
# .env β example: loosen to 10/hour, 40/day, 30s between cold sends
WA_GUARD_COLD_PER_HOUR=10
WA_GUARD_COLD_PER_DAY=40
WA_GUARD_COLD_MIN_GAP_MS=30000# Docker: recreate the container so it picks up the new .env values
docker compose up -d
# Local (npm): restart the process
npm run build && npm startTo turn the guard off, or shorten the post-link cooldown to 30s:
# .env
WA_SEND_GUARD=off
# or keep it on but shorten the cooldown
WA_GUARD_POST_CONNECT_MS=30000The docker-compose.yml passes each guard variable through from .env with the defaults above, so anything you don't set keeps its default.
| Variable | Default | Purpose |
|---|---|---|
WA_API_KEY |
(required) | shared API key for the X-Api-Key header |
WA_PORT |
4000 |
listen port |
WA_HOST |
127.0.0.1 |
bind address |
WA_DATA_DIR |
./data |
SQLite location (sessions + history) |
WA_MEDIA_DIR |
./media |
downloaded media |
WA_MEDIA_LIFETIME_DAYS |
180 |
media cleanup window |
WA_LOG_LEVEL |
info |
pino log level |
WA_WEBHOOK_SECRET |
(unset) | if set, sign webhooks with an x-pigeon-signature HMAC |
WA_API_URL |
http://127.0.0.1:4000 |
Pigeon base URL (MCP server only) |
WA_SESSION |
default |
session the MCP server operates on |
WA_MCP_READONLY |
false |
MCP server only; when true, send tools draft instead of sending |
WA_SEND_GUARD |
on |
anti-spam guard on cold (first-contact) sends; off to disable |
WA_GUARD_POST_CONNECT_MS |
120000 |
pause cold sends for this long after the session links |
WA_GUARD_COLD_MIN_GAP_MS |
60000 |
minimum gap between two cold sends |
WA_GUARD_COLD_PER_HOUR |
5 |
max cold sends per rolling hour |
WA_GUARD_COLD_PER_DAY |
20 |
max cold sends per rolling day |
HTTP (Fastify)
|- /v1/* versioned API ----+
|- /api/* flat endpoints ---+
v one core
SessionManager . MessageService . MediaService
HistoryStore . WebhookDispatcher . SQLite auth state
|
Baileys socket(s)
SQLite under WA_DATA_DIR holds session auth state and message history. Media lives under WA_MEDIA_DIR with a lifetime cleanup. Both are mounted volumes, so they survive container rebuilds.
Pigeon is not affiliated with, endorsed by, or connected to WhatsApp or Meta. It uses Baileys, an unofficial library, to automate a WhatsApp account. This is not officially supported, and accounts using automation can be restricted. Use it at your own risk, on a number you control.
Pigeon is built for personal automation, prototyping, and development. It has no bulk-send features by design. Message people who have agreed to hear from you, respect local law, and for business or marketing messaging at scale use the official WhatsApp Business Platform.
Built by Jean Galea.
If you want WhatsApp, or any messaging, done properly for a business on the official APIs, that's what AgentVania does.
MIT. See LICENSE.
