OpenWA
OpenWA is a self-hosted, open-source WhatsApp API Gateway (MIT, NestJS 11 / TypeScript / Node 22). It wraps whatsapp-web.js (Puppeteer + Chromium) behind a REST API on port 2785, ships a React dashboard on 2886, and supports multi-session WhatsApp automation with webhooks, Socket.IO real-time events, pluggable storage/cache/database, and a plugin hook system.
Product surface
| Component | Port | Role |
|---|---|---|
openwa-api (NestJS) |
2785 |
REST + WebSocket + webhook dispatcher |
dashboard (React 19 + Vite) |
2886 |
Session/API-key/webhook UI |
| Swagger UI | 2785 |
/api/docs |
| Traefik (optional) | 80, 8080 |
Reverse proxy + dashboard (profile with-proxy) |
| PostgreSQL (optional) | 5432 |
Profile postgres |
| Redis (optional) | 6379 |
Profile redis, gates BullMQ queue |
| MinIO (optional) | 9000, 9001 |
Profile minio (S3-compatible storage) |
Global API prefix is /api (src/main.ts:128 app.setGlobalPrefix('api')). All paths below are relative to <baseUrl>/api.
Tech stack — verified
| Layer | Tech | Evidence |
|---|---|---|
| Framework | NestJS 11.x | package.json |
| WA engine | whatsapp-web.js 1.26.1 (Puppeteer + Chromium) |
package.json, src/engine/engine.factory.ts |
| Main DB | SQLite (always — stores API keys, audit log) | src/app.module.ts |
| Data DB | SQLite or PostgreSQL (sessions, messages, webhooks) | src/app.module.ts |
| ORM | TypeORM 0.3.x | package.json |
| Queue | BullMQ 5.x (gated by QUEUE_ENABLED) |
src/app.module.ts:31 |
| Cache | Redis (optional, gated by REDIS_ENABLED) |
src/common/cache/ |
| Storage | Local or S3/MinIO | src/common/storage/ |
| Real-time | Socket.IO 4.8 on namespace /events |
src/modules/events/events.gateway.ts |
| Validation | class-validator, class-transformer |
package.json |
| SDK | @openwa/sdk (JS/TS) + openwa (Python) |
sdk/javascript/, sdk/python/ |
Repository layout
src/
main.ts # Boot + .env precedence + first-run API key
app.module.ts # Composition; conditional QueueModule
config/configuration.ts # Env → config object
core/{hooks,plugins}/ # Plugin hook system
engine/ # IWhatsAppEngine interface + adapters
modules/
session/ # Session CRUD + in-memory engine map
message/ # Send & history endpoints
webhook/ # Per-session webhook subscriptions
events/ # Socket.IO gateway (/events namespace)
auth/ # API-key auth + roles
contact/ group/ label/ channel/ status/ catalog/
queue/ # BullMQ jobs (only if QUEUE_ENABLED)
health/ # /health, /health/live, /health/ready
infra/ settings/ stats/ audit/ docker/ plugins/
dashboard/ # React 19 + Vite SPA
sdk/{javascript,python}/ # Client libraries
traefik/{traefik.yml,dynamic.yml}
docker-compose.yml # Profiled production stack
docker-compose.dev.yml # SQLite + local dev quick-start
Dockerfile # Multi-stage Node 22-slim + Chromium
docs/ # 22 markdown design docs
Authentication — X-API-Key
Authentication is a hashed API key sent in the X-API-Key header (also accepted: Authorization: Bearer per main.ts:122 CORS allow-list). Keys are stored hashed in the main (SQLite) DB and have a role: ADMIN, OPERATOR, or VIEWER (src/modules/auth/entities/api-key.entity.ts).
curl -H "X-API-Key: owa_k1_xxxxxxxx..." http://localhost:2785/api/sessions
- Key format:
owa_k1_<32 bytes hex>(src/modules/auth/auth.service.ts:95) - First-run bootstrap: if no API keys exist, OpenWA creates one and writes it to
data/.api-key, then prints it in the startup banner. In dev (NODE_ENV=development) the bootstrap key is the literal stringdev-admin-key. - Role gating: write endpoints carry
@RequireRole(ApiKeyRole.OPERATOR); reads are largely public-by-key. ADMIN is required for API-key CRUD. - Optional scoping per key: IP allow-list (
allowedIps) and session allow-list (allowedSessions).
See authentication & API-key lifecycle for key creation/revocation flows.
Sessions — multi-account WhatsApp
Each WhatsApp account = one persisted Session row + one in-memory engine (IWhatsAppEngine) keyed by session id (src/modules/session/session.service.ts). Sessions go through:
CREATED → INITIALIZING → QR_READY → AUTHENTICATING → READY
↘ FAILED
any → DISCONNECTED (also forced on every API restart)
Minimum workflow to bring a session live:
# 1. Create
curl -X POST http://localhost:2785/api/sessions \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"bot-1"}'
# 2. Start (initialises Chromium + WA engine)
curl -X POST http://localhost:2785/api/sessions/$ID/start -H "X-API-Key: $KEY"
# 3. Fetch QR — returns SVG data while status = QR_READY
curl http://localhost:2785/api/sessions/$ID/qr -H "X-API-Key: $KEY"
# … scan with WhatsApp mobile …
# 4. Poll status (or subscribe via /events) until status = READY
curl http://localhost:2785/api/sessions/$ID -H "X-API-Key: $KEY"
Chat ids follow WhatsApp Web conventions: <phone>@c.us for direct, <id>@g.us for groups, <id>@newsletter for channels. Invalid format = silent send failure.
Full lifecycle, in-memory engine map, proxy options, and reset-on-restart behaviour: operation.md.
REST API — controller catalog
All routes are mounted under /api. Roles in parentheses are required when stricter than the default VIEWER-or-higher.
| Domain | Routes | File |
|---|---|---|
| Sessions | POST /sessions (OPERATOR), GET /sessions, GET /sessions/:id, DELETE /sessions/:id (OPERATOR), POST /sessions/:id/start (OPERATOR), POST /sessions/:id/stop (OPERATOR), GET /sessions/:id/qr, GET /sessions/:id/groups, GET /sessions/stats/overview |
session.controller.ts |
| Messages | GET /sessions/:sessionId/messages, POST /sessions/:sessionId/messages/send-text, …/send-image, …/send-video, …/send-audio, …/send-document, …/send-location, …/send-contact, …/send-sticker, …/reply, …/forward, …/react, GET …/:chatId/:messageId/reactions, POST …/delete, POST …/send-bulk, GET …/batch/:batchId, POST …/batch/:batchId/cancel |
message.controller.ts |
| Webhooks | POST/GET/GET :id/PUT :id/DELETE :id under /sessions/:sessionId/webhooks, plus POST :id/test |
webhook.controller.ts |
| API keys | POST /auth/api-keys (ADMIN), GET, GET :id, PUT :id, DELETE :id, POST :id/revoke |
auth.controller.ts |
| Auth probe | GET /auth/validate (header X-API-Key) |
auth-validate.controller.ts |
| Health | GET /health, GET /health/live, GET /health/ready (public; K8s-shaped) |
health.controller.ts |
| Contact/Group/Label/Channel/Status/Catalog | Per-module controllers under /sessions/:sessionId/... |
modules/<name>/ |
| Infra | Settings, stats, export/import for migration | modules/infra/ |
Authoritative shapes are the controller files plus the auto-published OpenAPI at http://localhost:2785/api/docs. Full route table with role/auth notes: rest-api.md.
Webhooks — per-session, signed, retried
Webhooks are created per session (POST /api/sessions/:sessionId/webhooks) with a URL, an events array, and optional secret/custom headers. Delivery is via BullMQ when QUEUE_ENABLED=true (with retries) and synchronous otherwise.
Headers emitted on every delivery:
| Header | Meaning |
|---|---|
X-OpenWA-Event |
Event name (e.g. message.received) |
X-OpenWA-Delivery-Id |
Unique per delivery attempt's parent dispatch |
X-OpenWA-Idempotency-Key |
Stable per (event × subject) — dedupe key for consumers |
X-OpenWA-Retry-Count |
0 on first attempt, then attemptsMade |
X-OpenWA-Signature |
sha256=<hex hmac> of the JSON body using webhook.secret (omitted if no secret) |
Defaults: WEBHOOK_TIMEOUT=10000 ms, WEBHOOK_MAX_RETRIES=3, WEBHOOK_RETRY_DELAY=5000 ms.
Subscribable event types (also valid on the WebSocket — src/modules/events/dto/ws-messages.dto.ts):
message.received, message.sent, message.ack, message.revoked, session.status, session.qr, session.authenticated, session.disconnected, group.join, group.leave, group.update — plus * for all.
Signature verification, delivery flow, queue vs sync, idempotency semantics, retry timing: webhooks-and-events.md.
Real-time — Socket.IO /events
Same event taxonomy as webhooks, delivered over Socket.IO on the /events namespace. The protocol is custom JSON (not the Socket.IO event emitter): clients send {type:'subscribe', sessionId, events, requestId?} and receive {type:'event', ...} / {type:'subscribed', ...} / {type:'error', ...} / {type:'pong'} server-side.
import { io } from 'socket.io-client';
const sock = io('http://localhost:2785/events', { auth: { apiKey: KEY } });
sock.emit('message', { type: 'subscribe', sessionId: '*', events: ['message.received'] });
sock.on('message', (msg) => { /* handle msg.type === 'event' */ });
Wire format, auth modes, room layout (session:<id>:<event>), and ping/pong: webhooks-and-events.md#websocket.
Deployment
Three documented paths:
| Path | Command |
|---|---|
| Local dev (SQLite, no Redis/queue, all in one) | docker compose -f docker-compose.dev.yml up -d |
| Production basic | docker compose up -d |
| Production full stack (Postgres + Redis + MinIO + Traefik + Dashboard) | docker compose --profile full up -d |
The image is multi-stage node:22-slim with Chromium baked in and PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium. Container health check is node -e "require('http').get('http://localhost:2785/api/health', ...)" every 30 s.
Compose profiles (postgres, redis, minio, with-dashboard, with-proxy, full), volume layout (openwa-data:/app/data), Docker socket mount (/var/run/docker.sock:ro — used by modules/docker for orchestration), Traefik static/dynamic config, K8s health probes: deployment.md.
Configuration — env-driven, three-layer precedence
Bootstrapping (src/main.ts:11-65) loads dotenv with override: false so the highest priority wins, and it's process env:
process.env > ./.env > ./data/.env.generated
.env.generated is the file the dashboard writes; it is the lowest priority, not the highest. A common pitfall is expecting dashboard saves to override Docker environment: blocks — they don't.
If no data/.env.generated exists on first boot, OpenWA writes one with SQLite, local storage, queue disabled, and prints a welcome banner with the API key.
Two TypeORM databases are wired in app.module.ts:
mainconnection — always SQLite. Stores API keys, audit log. Stays node-local (don't try to migrate it).dataconnection — SQLite or PostgreSQL. Stores sessions, messages, webhooks. Synchronize defaultstruefor SQLite,falsefor Postgres (thennpm run migration:run:prod).
Full env-var reference, .env.minimal, dual-DB diagram, and QUEUE_ENABLED gating: configuration.md.
SDKs
import { OpenWAClient } from '@openwa/sdk';
const client = new OpenWAClient({ baseUrl: 'http://localhost:2785', apiKey: KEY });
await client.sessions.create({ name: 'bot-1' });
await client.sessions.start(id);
await client.messages.sendText(id, { chatId: '628123456789@c.us', text: 'hi' });
The published @openwa/sdk ships a minimal hand-written surface (sdk/javascript/src/index.ts) and is intended to be regenerated from the OpenAPI spec — for endpoints it doesn't yet wrap, call client.request('POST', '/api/...', body) or use fetch with X-API-Key. A Python SDK lives at sdk/python/openwa/.
SDK install, typed wrappers, generation flow, and Python equivalents: sdk.md.
Architecture & extensibility
OpenWA's pluggability is enforced through interface boundaries, not feature flags alone:
IWhatsAppEngine(src/engine/interfaces/) — abstractswhatsapp-web.js; alternative engines (Baileys, etc.) can be dropped behind it viaengine.factory.ts. Selected byENGINE_TYPE.StorageService— local-fs or S3/MinIO, swapped viaSTORAGE_TYPE.CacheService— in-memory or Redis, gated byREDIS_ENABLED.- Plugin hooks —
core/hooks/runs named hook chains. The message-send pipeline firesmessage:sendingbefore dispatch (plugins can modify/block) andmessage:senton success. Plugins are auto-loaded whenPLUGINS_ENABLED=true. - Queue gating —
QueueModuleisrequired only whenQUEUE_ENABLED === 'true'(src/app.module.ts:31-38). Without it, webhook retries are best-effort sync only.
Module map, hook list, plugin loader, scaling model (engines pinned to nodes; API keys stay local; data DB can be shared): architecture.md.
Gotchas
A handful of behaviours that bite people who read the docs but skip the code:
.env.generatedis the lowest precedence, not the highest.- Every API restart force-resets all
READY/INITIALIZING/QR_READY/AUTHENTICATINGsessions toDISCONNECTED(engines are in-memory; restart loses them). QUEUE_ENABLED=false(the default) means webhooks have no retry — synchronous best-effort dispatch.synchronize: trueis the SQLite default and is dangerous in production; flip to migrations for Postgres.- API keys live in the SQLite
mainDB. Switching thedataDB to Postgres doesn't move them. phoneandpushNamearenulluntil the session reachesREADY.- Chat-id format must be
<phone>@c.us/<id>@g.us— wrong format = silent failure. - Chromium is mandatory; the Docker image hard-codes
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium.
Full annotated list with file:line references: gotchas.md.
Reference index
| Reference | What's inside |
|---|---|
| deployment.md | Dockerfile stages, compose profiles, Traefik, K8s probes, volumes |
| configuration.md | Env-var reference, precedence, dual-DB layout, queue gating |
| operation.md | Session lifecycle, QR auth, API-key bootstrap & lifecycle, dashboard |
| rest-api.md | Full controller→route catalog, role decorators, request/response shapes |
| webhooks-and-events.md | Webhook headers, HMAC, retries, idempotency, Socket.IO /events wire format |
| sdk.md | JS/TS and Python client usage, raw fetch fallback, regeneration notes |
| architecture.md | Module map, engine/storage/cache pluggability, plugin hooks, scaling |
| gotchas.md | Non-obvious behaviours verified against the source |