Your personal WhatsApp backbone. A single self-hosted service that maintains a persistent WhatsApp connection and exposes everything through a clean REST API, real-time WebSocket stream, and webhook system.
Every message, media file, contact, group, call, presence change, and status update is captured and stored in a local SQLite database. Connect all your projects — AI agents, auto-reply bots, dashboards, analytics — to one hub.
WhatsApp <--> Baileys Connection <--> Event Bus <--> SQLite DB
|
REST API + WebSocket
|
Webhook Dispatcher
|
Your projects subscribe
- Full message capture — text, images, video, audio, documents, stickers, locations, contacts, reactions, polls, view-once, forwards, quotes, edits, deletes
- Media auto-download — organized by date in
data/media/ - Contacts & groups — names, profile pics, participants, roles, invite codes
- Presence tracking — online/offline/typing/recording status log
- Call log — incoming/outgoing, video/voice, duration
- Status/Stories — captured and stored
- Message receipts — sent/delivered/read/played timestamps per recipient
- Webhook system — HMAC-signed payloads, event filtering, toggle on/off, SSRF protection
- WebSocket streaming — real-time events with optional event type filtering, ticket-based auth
- Full-text search — search across all messages
- Web dashboard — 10-page interactive UI for browsing everything
- Security hardening — database encryption at rest, webhook secret encryption, configurable security toggles
- Docker-ready — single
docker compose upto deploy
git clone https://github.com/rafa-rrayes/whatsapp-hub.git
cd whatsapp-hub
cp .env.example .envEdit .env and set a strong API key:
# Generate a random key
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"docker compose up -dOpen http://localhost:3100 in your browser, enter your API key, and scan the QR code with WhatsApp.
Or check the container logs:
docker compose logs -f# Check connection status
curl -H "x-api-key: YOUR_KEY" http://localhost:3100/api/connection/status
# Search messages
curl -H "x-api-key: YOUR_KEY" "http://localhost:3100/api/messages/search?q=hello"
# Send a message
curl -X POST -H "x-api-key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"jid": "[email protected]", "text": "Hello from WhatsApp Hub!"}' \
http://localhost:3100/api/actions/send/textInteractive API docs are available in the dashboard at GET /api.
All requests require an API key via one of:
- Header:
x-api-key: YOUR_KEY(recommended) - Header:
Authorization: Bearer YOUR_KEY - Query param:
?api_key=YOUR_KEY(disabled whenSECURITY_DISABLE_HTTP_QUERY_AUTH=true)
Connection
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/connection/status |
Connection status & JID |
| GET | /api/connection/qr |
QR code as base64 data URL |
| GET | /api/connection/qr/image |
QR code as PNG |
| POST | /api/connection/restart |
Restart connection |
| POST | /api/connection/new-qr |
Clear session, generate new QR |
| POST | /api/connection/logout |
Logout |
Messages
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/messages |
Query messages with filters |
| GET | /api/messages/search?q= |
Full-text search |
| GET | /api/messages/stats |
Statistics & breakdown |
| GET | /api/messages/:id |
Single message by ID |
Query parameters for /api/messages:
| Param | Description |
|---|---|
chat |
Filter by chat JID |
from |
Filter by sender JID |
from_me |
true / false |
type |
Message type (text, image, video, audio, document, sticker, etc.) |
search |
Text search in message body |
before / after |
Unix timestamp range |
has_media |
true / false |
limit / offset |
Pagination (default: 50) |
order |
asc or desc (default: desc) |
Chats
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/chats |
List all chats (sorted by last message) |
| GET | /api/chats/:jid |
Chat details + recent messages |
Contacts
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/contacts |
List all contacts |
| GET | /api/contacts/:jid |
Single contact |
| GET | /api/contacts/:jid/profile-pic |
Profile picture URL |
Groups
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/groups |
List all groups |
| GET | /api/groups/:jid |
Group + participants |
| GET | /api/groups/:jid/metadata |
Fresh metadata from WhatsApp |
| GET | /api/groups/:jid/invite-code |
Invite code |
| PUT | /api/groups/:jid/subject |
Update subject |
| PUT | /api/groups/:jid/description |
Update description |
| POST | /api/groups/:jid/participants |
Manage members |
| POST | /api/groups/sync |
Sync all groups from WhatsApp |
Actions (Send)
| Method | Endpoint | Body |
|---|---|---|
| POST | /api/actions/send/text |
{ jid, text, quoted_id? } |
| POST | /api/actions/send/image |
{ jid, base64|url, caption?, mime_type? } |
| POST | /api/actions/send/video |
{ jid, base64|url, caption? } |
| POST | /api/actions/send/audio |
{ jid, base64|url, ptt? } |
| POST | /api/actions/send/document |
{ jid, base64|url, filename, mime_type, caption? } |
| POST | /api/actions/send/sticker |
{ jid, base64|url } |
| POST | /api/actions/send/location |
{ jid, latitude, longitude, name?, address? } |
| POST | /api/actions/send/contact |
{ jid, contact_jid, name } |
| POST | /api/actions/react |
{ jid, message_id, emoji } |
| POST | /api/actions/read |
{ jid, message_ids[] } |
| POST | /api/actions/presence |
{ type, jid? } |
| PUT | /api/actions/profile-status |
{ status } |
Media
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/media/stats |
Media statistics |
| GET | /api/media/:id |
Media metadata |
| GET | /api/media/:id/download |
Download file |
| GET | /api/media/by-message/:msgId |
Get media by message ID |
Webhooks
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/webhooks |
List subscriptions |
| POST | /api/webhooks |
Create { url, secret?, events? } |
| DELETE | /api/webhooks/:id |
Delete |
| PUT | /api/webhooks/:id/toggle |
Toggle active |
Webhook payloads include X-Hub-Event and X-Hub-Signature (HMAC-SHA256) headers.
WebSocket
Connect to ws://your-server:3100/ws for real-time events. Max 20 concurrent connections with automatic ping/pong cleanup.
Authentication (one of):
| Method | Usage |
|---|---|
| Ticket (recommended) | POST /api/ws/ticket → connect with ?ticket=TOKEN |
| Header | x-api-key: YOUR_KEY (non-browser clients) |
| Query param (legacy) | ?api_key=YOUR_KEY |
Ticket auth requires SECURITY_WS_TICKET_AUTH=true. Tickets are one-time use and expire after 30 seconds.
Optional event filter: ?ticket=TOKEN&events=wa.messages.upsert,wa.presence.update
Settings
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/settings |
List runtime settings with defaults |
| PUT | /api/settings |
Update settings { logLevel?, autoDownloadMedia?, maxMediaSizeMB? } |
Stats & Events
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/stats |
Full dashboard overview |
| GET | /api/stats/events |
Query event audit log |
| GET | /api/stats/events/types |
Event type counts |
| DELETE | /api/stats/events/prune?days=30 |
Prune old events |
| Category | Details |
|---|---|
| Messages | Text, images, video, audio, documents, stickers, locations, contacts, reactions, polls, view-once, forwarded, quoted, edits, deletes |
| Media | Auto-downloaded to data/media/ in date-organized folders |
| Contacts | Names, phone numbers, profile pics, business status |
| Groups | Metadata, descriptions, participants, roles, invite codes |
| Chats | Archive/pin/mute status, unread counts, last message preview |
| Receipts | Sent, delivered, read, played timestamps per recipient |
| Presence | Online/offline/typing/recording status log |
| Calls | Incoming/outgoing, video/voice, status, duration |
| Stories | Status updates captured and stored |
| Labels | WhatsApp Business labels |
| Events | Full audit trail with timestamps |
import requests
API = "http://localhost:3100/api"
HEADERS = {"x-api-key": "YOUR_KEY"}
# Get unread chats
chats = requests.get(f"{API}/chats", headers=HEADERS).json()
unread = [c for c in chats["data"] if c["unread_count"] > 0]
# Search messages
tasks = requests.get(f"{API}/messages/search", params={"q": "TODO"}, headers=HEADERS).json()
# Reply
requests.post(f"{API}/actions/send/text", headers=HEADERS, json={
"jid": "[email protected]",
"text": "Got it! I'll handle that."
})import WebSocket from "ws";
// With ticket auth (recommended — requires SECURITY_WS_TICKET_AUTH=true)
const res = await fetch("http://localhost:3100/api/ws/ticket", {
method: "POST",
headers: { "x-api-key": "YOUR_KEY" },
});
const { ticket } = await res.json();
const ws = new WebSocket(`ws://localhost:3100/ws?ticket=${ticket}&events=wa.messages.upsert`);
// Or with header auth (non-browser)
// const ws = new WebSocket("ws://localhost:3100/ws", { headers: { "x-api-key": "YOUR_KEY" } });
ws.on("message", (data) => {
const event = JSON.parse(data);
console.log("New message:", event.type, event.data);
});| Variable | Default | Description |
|---|---|---|
PORT |
3100 |
API server port |
API_KEY |
(required) | Authentication key (min 16 characters) |
DATA_DIR |
./data |
Data directory path |
MEDIA_DIR |
./data/media |
Media storage path |
AUTO_DOWNLOAD_MEDIA |
true |
Auto-download media files |
MAX_MEDIA_SIZE_MB |
100 |
Max file size to download (0 = unlimited) |
LOG_LEVEL |
info |
Pino log level |
SESSION_NAME |
default |
Baileys auth session name |
BEHIND_PROXY |
false |
Set true behind a TLS reverse proxy (enables HSTS, CSP upgrade-insecure-requests, trust proxy) |
CORS_ORIGINS |
(auto) | Allowed CORS origins (comma-separated, or *). Default: localhost + LAN IPs on configured port |
WEBHOOK_URLS |
— | Comma-separated webhook URLs |
WEBHOOK_SECRET |
— | HMAC secret for webhook signatures |
Migration notice: Starting with this version,
SECURITY_WS_TICKET_AUTHandSECURITY_DISABLE_HTTP_QUERY_AUTHnow default to ON (previously OFF). If you rely on query-string API key auth or raw WebSocket connections with?api_key=, explicitly setSECURITY_WS_TICKET_AUTH=falseand/orSECURITY_DISABLE_HTTP_QUERY_AUTH=falsein your.envfile or Docker Compose environment.
| Variable | Default | Description |
|---|---|---|
SECURITY_WS_TICKET_AUTH |
true |
Use one-time tickets for WebSocket auth instead of api_key in URL |
SECURITY_DISABLE_HTTP_QUERY_AUTH |
true |
Disable ?api_key= query parameter on HTTP endpoints |
SECURITY_ENCRYPT_DATABASE |
false |
Encrypt SQLite database at rest (requires ENCRYPTION_KEY) |
SECURITY_ENCRYPT_WEBHOOK_SECRETS |
false |
Encrypt webhook HMAC secrets at rest (requires ENCRYPTION_KEY) |
SECURITY_STRIP_RAW_MESSAGES |
false |
Omit raw_message field from API responses |
ENCRYPTION_KEY |
— | Master encryption key (min 16 chars). Required by database and webhook secret encryption |
SECURITY_AUTO_PRUNE |
false |
Auto-prune old presence and event log entries every 6 hours |
PRESENCE_RETENTION_DAYS |
7 |
Days to keep presence log entries (when auto-prune enabled) |
EVENT_RETENTION_DAYS |
30 |
Days to keep event log entries (when auto-prune enabled) |
SECURITY_HASH_EVENT_JIDS |
false |
Hash phone numbers in event log for privacy (one-way) |
SECURITY_SEC_FETCH_CHECK |
false |
Block cross-site browser requests via Sec-Fetch-Site header |
Always-on hardening (no configuration needed): Bearer token parsing fix, WebSocket ping/pong heartbeat, input validation on group operations and order parameters, media URL fetch size limits + timeout, SSRF re-validation at webhook delivery, per-API-key rate limiting.
WhatsApp Hub serves plain HTTP by default. If you place it behind a TLS-terminating reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc.), set:
BEHIND_PROXY=trueThis enables:
- HSTS — tells browsers to always use HTTPS for this host
upgrade-insecure-requestsin Content-Security-Policy — tells browsers to load sub-resources over HTTPStrust proxyin Express — reads the real client IP fromX-Forwarded-For
Leave it at false (the default) when accessing the app directly over HTTP, otherwise all asset loads will fail and you'll see a blank page.
data/
├── whatsapp-hub.db # SQLite database (WAL mode, optionally encrypted)
├── auth/default/ # Baileys session credentials
└── media/
└── 2025/01/15/ # Date-organized media files
When SECURITY_ENCRYPT_DATABASE=true, the database is encrypted at rest (AES-256). Existing unencrypted databases are automatically migrated on first start (a backup is created beforehand). No special build steps or system libraries are needed — encryption support is bundled.
npm install
cd frontend && npm install && cd ..
cp .env.example .env
# Set your API_KEY in .env
npm run devThe frontend dev server runs separately:
cd frontend
npm run devBackend: Node.js, TypeScript, Express, Baileys, better-sqlite3 (with encryption), Pino
Frontend: React, TypeScript, Vite, Tailwind CSS, Radix UI, Zustand, TanStack Query, Recharts