README.md
# openwa
> Expert on the OpenWA self-hosted WhatsApp API Gateway — deployment, sessions, REST API, webhooks, real-time events, SDKs, plugins, and the non-obvious behaviours you only learn by reading the source.
## What it does
`openwa` makes Claude fluent in **OpenWA** — the open-source NestJS + `whatsapp-web.js` WhatsApp gateway that runs on port 2785 with a React dashboard on 2886. The skill covers:
- **Deployment** — Dockerfile stages, compose profiles (`postgres`, `redis`, `minio`, `with-dashboard`, `with-proxy`, `full`), Traefik, K8s probes.
- **Operation** — session lifecycle (`CREATED → QR_READY → AUTHENTICATING → READY`), QR auth flow, API-key bootstrap and roles (`VIEWER`/`OPERATOR`/`ADMIN`), dashboard.
- **REST API** — the full controller→route catalog, verified against `src/modules/*/*.controller.ts` decorators, with role-gating notes and request/response shapes.
- **Webhooks & events** — `X-OpenWA-Event` / `X-OpenWA-Signature` / `X-OpenWA-Idempotency-Key` headers, HMAC SHA-256 verification, BullMQ retries (gated by `QUEUE_ENABLED`), and the Socket.IO `/events` subscription protocol.
- **SDKs** — `@openwa/sdk` (JS/TS) and the `openwa` Python package, plus OpenAPI regeneration from `/api/docs-json`.
- **Architecture** — `IWhatsAppEngine` adapter boundary, plugin hooks (`message:sending`, `message:sent`), dual-DB layout (`main` SQLite for API keys, `data` SQLite/Postgres for everything else), scaling constraints.
- **Gotchas** — the surprises: `.env.generated` is the *lowest* precedence, restarts force-disconnect every session, `QUEUE_ENABLED=false` means zero webhook retries, `synchronize: true` on Postgres is dangerous, dev mode bootstraps the literal key `dev-admin-key`.
The skill is grounded in primary-source code reads (`src/`, `docker-compose*.yml`, `Dockerfile`, `sdk/`, `dashboard/`) — not just the `docs/` directory — so file:line citations and the gotchas list are accurate against the running implementation.
## When to use it
- *"Deploy OpenWA with PostgreSQL and Redis behind Traefik."*
- *"Set up a webhook that fires on `message.received` and verify the signature in Node."*
- *"How do I scale OpenWA to 50 WhatsApp accounts?"*
- *"Write a TypeScript client that subscribes to all `message.*` events for all sessions."*
- *"Add a plugin that blocks outgoing messages containing a banned word."*
- *"Why does OpenWA disconnect every session when I restart the container?"*
**Not the right skill if** the task is generic WhatsApp Business Cloud API work (that's Meta's official API, totally different surface), or `whatsapp-web.js`-as-a-library work *outside* OpenWA (use the upstream docs directly — OpenWA wraps it, doesn't replace it).
## Example walkthrough
**Prompt**
> Set up OpenWA with Postgres + Redis + S3 (MinIO) behind Traefik, then write a TypeScript service that listens for `message.received` over WebSocket and replies "got it" via REST.
**What the skill does**
1. **Stack up** — recommends `docker compose --profile full up -d`, points at the relevant volumes (`openwa-data`, `postgres-data`, etc.), reminds the user to set `QUEUE_ENABLED=true` and `DATABASE_SYNCHRONIZE=false` for the Postgres production deploy.
2. **API key** — surfaces that the first key is printed in the API startup banner and written to `data/.api-key`; not the dashboard.
3. **Session bring-up** — `POST /api/sessions` → `POST /api/sessions/:id/start` → `GET /api/sessions/:id/qr` → poll until `READY`.
4. **Listener service** — generates a Node script using `socket.io-client` on `http://host:2785/events` with the custom JSON envelope (`{type:'subscribe', sessionId:'*', events:['message.received']}`), and a `fetch` call to `POST /api/sessions/:id/messages/send-text` with `chatId: '<from>@c.us'` for the reply.
5. **Gotcha** — flags that `chatId` must be the raw `<phone>@c.us` form (no `+`, no leading zero) or the send silently fails.
## Installation
```bash
npx skills add thatjuan/agent-skills --skill openwa
```
## Bundled resources
| File | Purpose |
|------|---------|
| `SKILL.md` | Tech stack, repo layout, auth, sessions, REST API summary, deployment paths, configuration overview, gotchas, reference index |
| `references/deployment.md` | Dockerfile stages, compose profiles, volume layout, Traefik config, K8s probes, local non-Docker dev |
| `references/configuration.md` | Full env-var reference, dotenv precedence, `.env.minimal`, dual-DB architecture, queue gating |
| `references/operation.md` | API-key lifecycle and roles, session state machine, QR auth flow, dashboard, audit log, multi-session sizing |
| `references/rest-api.md` | Verified controller→route catalog (sessions, messages, webhooks, api-keys, health, aux domains), Swagger location |
| `references/webhooks-and-events.md` | Webhook entity, headers, HMAC signing, retries/idempotency, queue vs sync, Socket.IO `/events` protocol |
| `references/sdk.md` | `@openwa/sdk` usage, Python equivalent, OpenAPI client regeneration, `socket.io-client` for real-time, n8n integration |
| `references/architecture.md` | Module map, pluggable boundaries, engine adapter interface, plugin hooks, scaling model |
| `references/gotchas.md` | 13 verified surprises with file:line citations |
## Tips
- **Always set `QUEUE_ENABLED=true` for production.** Webhook retries are off by default. The cost is one Redis instance.
- **Treat the OpenAPI spec as the source of truth.** Hit `http://localhost:2785/api/docs-json` to regenerate clients — the hand-written `@openwa/sdk` only wraps a subset.
- **Pin session ids to nodes if you scale horizontally.** No built-in coordinator; two processes touching the same session id will corrupt the Puppeteer profile.
- **Back up `data/` as a whole.** Losing the `main` SQLite means losing every API key. Losing `sessions/` means a fresh QR scan for every account.
- **Use `POST /api/sessions/:sid/webhooks/:wid/test`** during integration — it fires a synthetic payload with all the real headers (`X-OpenWA-Signature`, idempotency key, etc.) so consumers can be verified offline.
- **Watch `message.ack` events**, not the send response. The REST send returns once the message is queued to the engine; final delivery state lands on the event stream.
- **Don't expose `NODE_ENV=development` publicly** — first-boot bootstrap creates the literal key `dev-admin-key`.
## Related skills
- [`drizzle-orm`](../drizzle-orm/) — alternative TypeORM-style integrations if you're swapping out OpenWA's persistence (OpenWA itself uses TypeORM)
- [`heroui`](../heroui/) — if you're building a custom OpenWA front-end alongside or in place of the bundled React dashboard
- [`temporal`](../temporal/) — durable orchestration over OpenWA webhooks for long-running WhatsApp workflows
references/architecture.md
# Architecture
How OpenWA composes its NestJS modules, where its pluggability lives, and how to extend it.
## Contents
- [Module map](#module-map)
- [Pluggable boundaries](#pluggable-boundaries)
- [Engine adapters](#engine-adapters)
- [Plugin hook system](#plugin-hook-system)
- [Sessions are in-memory](#sessions-are-in-memory)
- [Scaling model](#scaling-model)
## Module map
`src/app.module.ts` composes the application. Domain modules (NestJS `@Module`) — each owns a controller, service, entity, and DTOs:
```
core/
hooks/ — named hook chains (HookService)
plugins/ — plugin loader, runs at startup if PLUGINS_ENABLED=true
engine/
interfaces/ — IWhatsAppEngine
adapters/ — whatsapp-web.js adapter (others can plug in)
engine.factory.ts — picks adapter via ENGINE_TYPE
common/
cache/ — CacheService (memory or Redis, gated by REDIS_ENABLED)
storage/ — StorageService (local-fs or S3, gated by STORAGE_TYPE)
services/ — logger, graceful shutdown
modules/
session — Session CRUD, engines: Map<sessionId, IWhatsAppEngine>
message — send & history; fires hook 'message:sending' before, 'message:sent' after
webhook — webhook CRUD + dispatcher; queues to BullMQ when enabled
events — Socket.IO gateway @WebSocketGateway('/events')
auth — API-key guard, role decorators, key CRUD
queue — BullMQ; only loaded if QUEUE_ENABLED=true (conditional require)
health — K8s probes
contact / group / label / channel / status / catalog — WhatsApp object CRUD
infra / settings / stats / audit / docker / plugins — admin surface
database/ — TypeORM migrations
```
## Pluggable boundaries
Five swap points enforced by interface, not just config:
| Boundary | Interface | Selector |
|---|---|---|
| WhatsApp engine | `IWhatsAppEngine` (`src/engine/interfaces/`) | `ENGINE_TYPE` |
| Object storage | `StorageService` (`src/common/storage/storage.service.ts`) | `STORAGE_TYPE` (`local` / `s3`) |
| Cache | `CacheService` (`src/common/cache/`) | `REDIS_ENABLED` |
| Data DB | TypeORM driver | `DATABASE_TYPE` (`sqlite` / `postgres`) |
| Queue | BullMQ | `QUEUE_ENABLED` (also requires Redis) |
The main DB (API keys, audit) is **not** pluggable — always SQLite, always node-local.
## Engine adapters
`src/engine/engine.factory.ts` picks an adapter by `ENGINE_TYPE`. Today only `whatsapp-web.js` ships. `IWhatsAppEngine` is the contract:
```typescript
interface IWhatsAppEngine {
start(): Promise<void>;
stop(): Promise<void>;
getQR(): Promise<string | null>; // SVG/base64
sendTextMessage(chatId: string, text: string): Promise<{messageId: string}>;
sendMediaMessage(chatId: string, media: MediaInput): Promise<…>;
// …
on(event: EngineEvent, handler: (payload) => void): void;
}
```
Adding a Baileys adapter: implement `IWhatsAppEngine`, register in the factory, expose a new `ENGINE_TYPE` value. Sessions remain transport-agnostic.
## Plugin hook system
`src/core/hooks/` provides a named hook chain — multiple handlers per hook, executed in registration order.
Built-in hook points (search `executeHook(` in `src/modules/`):
| Hook | When | Handler signature |
|---|---|---|
| `message:sending` | Before `sendTextMessage` (et al.) — handlers may modify payload or **block** | `(ctx: { sessionId, chatId, payload }) => Promise<void \| { block: true }>` |
| `message:sent` | After WA returns a message id | `(ctx: { sessionId, messageId, payload }) => Promise<void>` |
| `message:received` | When engine emits incoming message | `(ctx) => Promise<void>` |
| `session:status` | On status transitions | `(ctx: { sessionId, status }) => Promise<void>` |
Plugins live under `./plugins/*` (configurable via `PLUGINS_DIR`). The loader (`src/core/plugins/plugin.loader.ts`) scans, instantiates plugin classes, and calls their `register(hookService)` method.
A minimal plugin:
```typescript
// plugins/log-outgoing/index.ts
export default class LogOutgoing {
register(hooks) {
hooks.on('message:sending', async (ctx) => {
console.log('sending', ctx.sessionId, ctx.payload.chatId);
});
}
}
```
Plugins are auto-loaded when `PLUGINS_ENABLED=true`. They share the API process, so a buggy plugin can crash the gateway — keep them defensive.
## Sessions are in-memory
`SessionService` holds `engines: Map<string, IWhatsAppEngine>` keyed by session id. This map is the **only** place engines live. Persistence on disk:
- `data/<DATABASE_NAME>.sqlite` — session rows
- `SESSION_DATA_PATH/<sessionId>/` — Puppeteer profile + WhatsApp Web auth blobs
Implications:
- **Process restart drops every engine.** `SessionService` resets all active rows to `DISCONNECTED` on boot. You must call `POST /api/sessions/:id/start` for each session you want back up (no auto-restart by default).
- **Two API processes don't share sessions.** Each has its own `engines` map. Starting the same `sessionId` in two processes is a corruption hazard — only one Puppeteer profile can own a WhatsApp Web auth at a time.
- **Memory roof.** Each engine is one Chromium process; per-session RSS ~80-150 MB.
## Scaling model
Single-node:
```
[clients] → [openwa-api : 2785] → [Chromium × N]
│
├─ data DB (sqlite or external Postgres)
├─ main DB (sqlite, local)
├─ storage (local or S3)
└─ Redis (cache + BullMQ, if enabled)
```
Multi-node: there is **no built-in coordinator**. To horizontally scale you must:
1. Run multiple `openwa-api` processes, each on its own node.
2. Give each node its own `SESSION_DATA_PATH` volume (sessions are node-pinned).
3. Share the `data` DB (Postgres) across nodes — sessions, messages, webhooks become globally visible.
4. Route requests for a given `sessionId` to the node that owns it (consistent hash, session affinity in the load balancer, or a custom router).
5. Decide what to do with API keys: either accept per-node key sets (each node's `main` SQLite) or shared-volume the `main` DB on a network FS (risky; the gateway holds locks).
Background reading on this: `docs/13-horizontal-scaling.md` in the OpenWA repo.
references/configuration.md
# Configuration
Environment-variable reference and how OpenWA loads them.
## Contents
- [Loading precedence](#loading-precedence)
- [First-boot bootstrap](#first-boot-bootstrap)
- [Env-var reference](#env-var-reference)
- [Dual-database architecture](#dual-database-architecture)
- [Queue gating](#queue-gating)
- [Rate limiting](#rate-limiting)
- [`.env.minimal`](#envminimal)
## Loading precedence
`src/main.ts:11-65` loads dotenv with `override: false`. **The first source to set a variable wins.** The order is:
```
process.env (highest — Docker `environment:`, shell, systemd)
↓
./.env (project root, user-committed)
↓
./data/.env.generated (lowest — written by the dashboard's settings UI)
```
This is the opposite of what most people expect. Dashboard saves are the lowest-priority layer, not the highest. To make a dashboard-saved value win, remove the matching key from `process.env` and `.env`.
## First-boot bootstrap
If `./data/.env.generated` does not exist on boot, OpenWA writes one (`src/main.ts:29-65`) with safe defaults:
```env
PORT=2785
NODE_ENV=production
DATABASE_TYPE=sqlite
DATABASE_NAME=./data/openwa.sqlite
DATABASE_SYNCHRONIZE=true
ENGINE_TYPE=whatsapp-web.js
SESSION_DATA_PATH=./data/sessions
PUPPETEER_HEADLESS=true
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./data/media
REDIS_ENABLED=false
QUEUE_ENABLED=false
```
If no API keys exist in the DB, `auth.service.ts` creates one and writes its plaintext to `./data/.api-key`, then prints it in the startup banner. In `NODE_ENV=development` the bootstrap key is the literal `dev-admin-key` (no random hex) — never run dev mode on the public internet.
## Env-var reference
### Core
| Var | Default | Notes |
|---|---|---|
| `NODE_ENV` | `production` | `development` enables verbose logs and the literal `dev-admin-key` bootstrap key |
| `PORT` | `2785` | API listener |
| `LOG_LEVEL` | `info` | `error\|warn\|info\|debug` |
| `BASE_URL` | — | Used in the startup banner; no functional effect on routing |
| `DASHBOARD_URL` | — | Banner only |
| `CORS_ORIGINS` | `*` | Comma-separated. `*` is rejected with credentials |
| `DASHBOARD_ENABLED` | `true` | Toggles compose service |
| `PROXY_ENABLED` | `true` | Toggles Traefik service |
### Engine (WhatsApp)
| Var | Default | Notes |
|---|---|---|
| `ENGINE_TYPE` | `whatsapp-web.js` | Only one adapter ships today |
| `SESSION_DATA_PATH` | `./data/sessions` | Per-session Puppeteer profile + WA auth |
| `PUPPETEER_HEADLESS` | `true` | Set `false` to debug locally |
| `PUPPETEER_ARGS` | `--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu` | Comma-separated; passed to Chromium |
| `PUPPETEER_EXECUTABLE_PATH` | `/usr/bin/chromium` (Docker) | Path to Chromium binary |
### Data database (sessions, messages, webhooks)
Connection name `data` in `app.module.ts`.
| Var | Default | Notes |
|---|---|---|
| `DATABASE_TYPE` | `sqlite` | or `postgres` |
| `DATABASE_NAME` | `./data/openwa.sqlite` | SQLite path **or** Postgres db name |
| `DATABASE_HOST` | — | Postgres only |
| `DATABASE_PORT` | `5432` | Postgres only |
| `DATABASE_USERNAME` | — | Postgres only |
| `DATABASE_PASSWORD` | — | Postgres only |
| `DATABASE_SSL` | `false` | Postgres TLS |
| `DATABASE_POOL_SIZE` | `10` | Postgres pool |
| `DATABASE_SYNCHRONIZE` | `true` for SQLite, `false` for Postgres | **Never `true` in prod Postgres** |
| `DATABASE_LOGGING` | `false` | TypeORM query log |
| `POSTGRES_BUILTIN` | `false` | Set `true` to enable the built-in compose Postgres service |
### Main database (API keys, audit)
Connection name `main` — always SQLite. Path is fixed (no env var). Stays node-local.
### Redis + queue
| Var | Default | Notes |
|---|---|---|
| `REDIS_ENABLED` | `false` | Master switch for Redis cache + queue |
| `REDIS_BUILTIN` | `false` | Use compose `redis` service |
| `REDIS_HOST` | `localhost` | |
| `REDIS_PORT` | `6379` | |
| `REDIS_PASSWORD` | — | |
| `QUEUE_ENABLED` | `false` | Conditionally loads `QueueModule`. **Required for webhook retries** |
### Storage
| Var | Default | Notes |
|---|---|---|
| `STORAGE_TYPE` | `local` | `local` or `s3` |
| `STORAGE_LOCAL_PATH` | `./data/media` | Local media path |
| `STORAGE_S3_BUCKET` | — | S3 bucket name |
| `STORAGE_S3_REGION` | `us-east-1` | |
| `STORAGE_S3_ENDPOINT` | — | For MinIO/Cloudflare R2 |
| `STORAGE_S3_ACCESS_KEY` | — | |
| `STORAGE_S3_SECRET_KEY` | — | |
| `STORAGE_S3_FORCE_PATH_STYLE` | `false` | MinIO needs `true` |
| `MINIO_BUILTIN` | `false` | Use compose `minio` service |
### Webhooks
| Var | Default | Notes |
|---|---|---|
| `WEBHOOK_TIMEOUT` | `10000` | ms per delivery attempt |
| `WEBHOOK_MAX_RETRIES` | `3` | Only effective with `QUEUE_ENABLED=true` |
| `WEBHOOK_RETRY_DELAY` | `5000` | ms; backoff base |
### Rate limiting (`src/config/configuration.ts`)
Three-tier `@nestjs/throttler` config:
| Var | Default | Window |
|---|---|---|
| `RATE_LIMIT_SHORT_TTL` | `1000` | per second |
| `RATE_LIMIT_SHORT_LIMIT` | `10` | |
| `RATE_LIMIT_MEDIUM_TTL` | `60000` | per minute |
| `RATE_LIMIT_MEDIUM_LIMIT` | `100` | |
| `RATE_LIMIT_LONG_TTL` | `3600000` | per hour |
| `RATE_LIMIT_LONG_LIMIT` | `1000` | |
### Plugins
| Var | Default | Notes |
|---|---|---|
| `PLUGINS_ENABLED` | `true` | Toggles plugin auto-discovery in `core/plugins` |
| `PLUGINS_DIR` | `./plugins` | Where the loader scans |
## Dual-database architecture
```
src/app.module.ts
TypeOrmModule.forRootAsync({ name: 'main', ... }) ← SQLite, always
Entities: ApiKey, AuditLog
TypeOrmModule.forRootAsync({ name: 'data', ... }) ← SQLite or Postgres
Entities: Session, Message, Webhook, …
```
Implications:
- API keys are **not** shared across nodes. If you front two OpenWA processes with a load balancer, each has its own key set. Either co-locate keys (shared volume for `main.sqlite`) or accept per-node keys.
- Switching `data` from SQLite to Postgres is a database migration — the `main` DB is untouched.
- The two connections are independent: a transient Postgres outage breaks data writes but doesn't kick out API auth.
## Queue gating
The conditional import in `src/app.module.ts:31-38`:
```typescript
const queueModules: Array<Type | DynamicModule> = [];
if (process.env.QUEUE_ENABLED === 'true') {
const queueModule = require('./modules/queue/queue.module');
queueModules.push(queueModule.QueueModule);
}
```
When `QUEUE_ENABLED=false` (default), `WebhookService` checks `this.queueEnabled` and dispatches webhooks synchronously with no retry. For any production deployment that depends on webhook delivery, set `REDIS_ENABLED=true` + `QUEUE_ENABLED=true`.
## `.env.minimal`
The minimum that boots OpenWA standalone (SQLite, no Redis, no S3, no queue):
```env
NODE_ENV=development
PORT=2785
DATABASE_TYPE=sqlite
DATABASE_NAME=./data/openwa.sqlite
DATABASE_SYNCHRONIZE=true
ENGINE_TYPE=whatsapp-web.js
SESSION_DATA_PATH=./data/sessions
PUPPETEER_HEADLESS=true
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./data/media
REDIS_ENABLED=false
QUEUE_ENABLED=false
```
Use as `.env` for first-run experimentation. Promote to Postgres + queue + Redis before going to production.
references/deployment.md
# Deployment
How OpenWA gets onto a host, runs in Docker, fronts itself with Traefik, and exposes Kubernetes-shaped probes.
## Contents
- [Docker image](#docker-image)
- [Compose stacks](#compose-stacks)
- [Profiles](#profiles)
- [Volumes & state](#volumes--state)
- [Traefik](#traefik)
- [Health probes](#health-probes)
- [Kubernetes notes](#kubernetes-notes)
- [Local non-Docker development](#local-non-docker-development)
## Docker image
`Dockerfile` is a two-stage build on `node:22-slim`:
**Stage 1 — builder** (`Dockerfile:5-26`)
- Installs `python3`, `make`, `g++` for native modules.
- Runs `npm ci && npm run build` → emits `/app/dist`.
**Stage 2 — runtime** (`Dockerfile:29-90`)
- Installs Chromium and its libs (`chromium`, `libnss3`, `libatk-bridge2.0-0`, fonts, …).
- Sets:
```
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
```
- Entrypoint: `dumb-init` (proper PID-1 signal handling).
- Exposes port `2785`.
- Built-in healthcheck: `node -e "require('http').get('http://localhost:2785/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"` — interval 30 s, timeout 10 s.
- Non-root user is **commented out** because `modules/docker` needs to read `/var/run/docker.sock`.
## Compose stacks
Two compose files. Both default the API to `127.0.0.1:2785` (loopback) so Traefik or a host firewall fronts it.
### `docker-compose.dev.yml` — quick start
Single `openwa` + `dashboard` service. SQLite for both DBs, local storage, no Redis, no profiles.
```bash
docker compose -f docker-compose.dev.yml up -d
open http://localhost:2886 # dashboard
open http://localhost:2785/api/docs # Swagger
```
### `docker-compose.yml` — production
Service map (`docker-compose.yml`):
| Service | Always on? | Notes |
|---|---|---|
| `openwa-api` | yes | Builds from `./Dockerfile`. Mounts `openwa-data:/app/data` and `/var/run/docker.sock:ro`. |
| `dashboard` | profile `with-dashboard` | Builds from `./dashboard/Dockerfile.traefik`. Exposes `2886`. |
| `traefik` | profile `with-proxy` | `traefik:v3.0`. Dashboard on `:8080`, entrypoint `web` on `:80`. |
| `postgres` | profile `postgres` | `postgres:16-alpine`, healthcheck `pg_isready`. |
| `redis` | profile `redis` | `redis:7-alpine`, healthcheck `redis-cli ping`. |
| `minio` | profile `minio` | `minio/minio`, ports `9000` (S3) + `9001` (console). |
Most production envs run with at least `postgres,redis`. The `full` profile turns everything on.
## Profiles
Compose profiles are how OpenWA opts services in. Combine with `--profile`:
```bash
# Built-in Postgres + Redis only
docker compose --profile postgres --profile redis up -d
# Everything (Postgres, Redis, MinIO, dashboard, Traefik)
docker compose --profile full up -d
```
When using **external** Postgres/Redis/S3, leave their profiles off and point `DATABASE_HOST` / `REDIS_HOST` / `STORAGE_S3_ENDPOINT` at the external endpoints.
## Volumes & state
| Volume | Mount | Holds |
|---|---|---|
| `openwa-data` | `/app/data` inside `openwa-api` | SQLite DBs, `.env.generated`, session data (`/app/data/sessions/<id>/` — Puppeteer profile + WhatsApp web auth) |
| `postgres-data` | `/var/lib/postgresql/data` | Postgres files (profile `postgres`) |
| `redis-data` | `/data` | Redis AOF (profile `redis`) |
| `minio-data` | `/data` | S3 bucket files (profile `minio`) |
| `traefik-acme` | `/letsencrypt` | ACME storage (profile `with-proxy`) |
**Back up `openwa-data` always.** Losing `sessions/` means every WhatsApp account has to re-scan its QR. Losing the SQLite `main` DB means losing all API keys.
The `docker.sock:ro` mount is intentional — `modules/docker` introspects the running stack from inside the API container. If you run on a host without Docker (bare metal Node), set `DOCKER_ENABLED=false`.
## Traefik
`traefik/traefik.yml` (static):
```yaml
api:
dashboard: true
insecure: true # only safe behind a private network
log:
level: INFO
entryPoints:
web:
address: ':80'
providers:
file:
filename: /etc/traefik/dynamic.yml
watch: true
```
`traefik/dynamic.yml` defines routers/services pointing at `openwa-api:2785` and `dashboard:2886`. For TLS in production, switch `api.insecure: false`, add a `websecure` entrypoint, and configure ACME with `TRAEFIK_ACME_EMAIL` + `TRAEFIK_ACME_STORAGE`.
## Health probes
`src/modules/health/health.controller.ts` exposes three public endpoints:
| Path | Purpose | Shape |
|---|---|---|
| `GET /api/health` | Basic liveness | `{status, uptime, timestamp, version}` |
| `GET /api/health/live` | K8s `livenessProbe` — process is running | `{status:'ok'}` |
| `GET /api/health/ready` | K8s `readinessProbe` — DB reachable, ready to serve | `{status, checks}` |
Kubernetes manifest skeleton:
```yaml
livenessProbe:
httpGet: { path: /api/health/live, port: 2785 }
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet: { path: /api/health/ready, port: 2785 }
initialDelaySeconds: 15
periodSeconds: 5
```
## Kubernetes notes
OpenWA is not pure stateless. Two constraints:
1. **Session affinity.** The in-memory `engines: Map<sessionId, IWhatsAppEngine>` lives in a single process. A given session must be served by the same pod that started it. Solutions: (a) one pod handles all sessions (vertical scale) or (b) shard sessions across pods with an external router and pin each session to a pod (custom). There is no built-in cluster coordination.
2. **`openwa-data` persistence.** The Puppeteer profile dir under `/app/data/sessions/<id>/` must persist across pod restarts, or each restart will require a fresh QR scan. Use a PersistentVolumeClaim with `ReadWriteOnce`.
Horizontal scaling depth: `docs/13-horizontal-scaling.md` in the OpenWA repo.
## Local non-Docker development
```bash
git clone <openwa-repo>
cd OpenWA
npm install
cp .env.example .env # or skip; first boot writes data/.env.generated
npm run dev # API + dashboard concurrently
```
`npm run dev` boots the NestJS API on `2785` and Vite dev server on `2886`. Chromium needs to be installed locally — either:
```bash
npx puppeteer browsers install chromium
# or set PUPPETEER_EXECUTABLE_PATH to your existing Chrome/Chromium
```
Hot-reload is enabled (`ts-node-dev`); session data lands in `./data/sessions/`.
references/gotchas.md
# Gotchas
Verified non-obvious behaviours. Each cites file:line so you can confirm before relying on it.
## 1. `.env.generated` is the **lowest** precedence layer
`src/main.ts:11-65` loads dotenv with `override: false`. Reading order:
```
process.env > ./.env > ./data/.env.generated
```
The dashboard's "Settings" UI writes `data/.env.generated`. People expect that to be the source of truth and are surprised when a Docker `environment:` block silently keeps winning. To make a dashboard-saved value take effect, remove the matching key from process env and `.env`.
## 2. Every API restart force-disconnects active sessions
`src/modules/session/session.service.ts:52-70` on boot:
```typescript
const activeStatuses = [
SessionStatus.READY, SessionStatus.INITIALIZING,
SessionStatus.QR_READY, SessionStatus.AUTHENTICATING,
];
await this.sessionRepository.update(
{ status: In(activeStatuses) },
{ status: SessionStatus.DISCONNECTED },
);
```
Reason: engines live in `engines: Map<sessionId, IWhatsAppEngine>` in memory. On boot the map is empty, so any row marked `READY` would be a lie. There is **no auto-restart loop** — you must `POST /api/sessions/:id/start` for every session you want back online. The WhatsApp Web auth on disk persists, so this usually reaches `READY` without a fresh QR scan (assuming `SESSION_DATA_PATH/<id>/` survived).
## 3. `QUEUE_ENABLED=false` means webhooks have no retry
`src/app.module.ts:31-38`:
```typescript
if (process.env.QUEUE_ENABLED === 'true') {
const queueModule = require('./modules/queue/queue.module');
queueModules.push(queueModule.QueueModule);
}
```
`WebhookService` then checks `this.queueEnabled` and falls back to sync `await this.send(...)`. Single shot. Failure or timeout = dropped delivery. `WEBHOOK_MAX_RETRIES=3` only applies when the queue is wired up. Default is `QUEUE_ENABLED=false` — production deployments need to flip this **and** enable Redis.
## 4. `synchronize: true` on Postgres is a footgun
`src/app.module.ts:88-108`:
```typescript
synchronize: configService.get<boolean>('dataDatabase.synchronize', true),
migrationsRun: !configService.get<boolean>('dataDatabase.synchronize', true),
```
Default-`true` is fine for SQLite (auto-creates dev schema). With Postgres in production, `synchronize: true` lets TypeORM rewrite the schema on every boot — schema drift, data loss in the worst case. Explicitly set `DATABASE_SYNCHRONIZE=false` for Postgres and rely on migrations (`npm run migration:run:prod`).
## 5. API keys live in a different DB than your data
`src/app.module.ts` configures two TypeORM connections:
- `main` — always SQLite — holds `ApiKey`, `AuditLog`
- `data` — SQLite or Postgres — holds `Session`, `Message`, `Webhook`, …
Moving the `data` DB to Postgres does **not** move API keys. Two consequences:
- Multi-node deploys have per-node key sets (unless you share `main.sqlite` on a network FS, which is risky).
- Backing up "the database" must include `main.sqlite` or you lose all API keys on restore.
## 6. `phone` and `pushName` are `null` until `READY`
`src/modules/session/entities/session.entity.ts:30-31`:
```typescript
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string | null;
```
Both fields are populated by the engine after WhatsApp authentication succeeds. Filtering sessions by phone (`?phone=628…`) won't match any session in `CREATED`/`QR_READY`/`AUTHENTICATING`.
## 7. Chat-id format is unforgiving
WhatsApp Web expects:
- `<phone>@c.us` for direct chats — digits only, country code included, no `+`, no leading zero
- `<id>@g.us` for groups — id is opaque, you get it from `GET /sessions/:id/groups`
- `<id>@newsletter` for channels
Wrong format = **silent failure**. `whatsapp-web.js` returns no error; the message just never sends. Consequence: ack the `messageId` returned by send, but **do not** trust the send unless you see a subsequent `message.ack` event with `status >= 2`.
## 8. Chromium is non-negotiable
The Dockerfile hard-codes `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium` and `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true`. Local non-Docker runs need:
```bash
npx puppeteer browsers install chromium
# or
export PUPPETEER_EXECUTABLE_PATH=$(which chromium || which chrome)
```
Firefox / WebKit are not supported by the current `whatsapp-web.js` integration.
## 9. The CORS `X-API-Key` allow-list is permissive
`src/main.ts:122`:
```typescript
allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization', 'X-Request-ID'],
```
`X-API-Key` is exposed cross-origin. If you put OpenWA on the open internet and set `CORS_ORIGINS=*`, any page in any browser can attempt key-stuffing. Lock `CORS_ORIGINS` to your admin origins, and require API keys to be scoped (`allowedIps`).
## 10. The dev bootstrap key is a fixed string
`src/modules/auth/auth.service.ts` first-run logic uses `dev-admin-key` literally when `NODE_ENV=development`. Anyone who knows the OpenWA codebase will guess that key on a dev-mode public deployment. **Never** expose `NODE_ENV=development` to the internet.
## 11. `WebhookService` shares the API process
Webhook dispatch (sync mode) runs on the request thread. A slow receiver delays the engine event loop. Even in queue mode, the worker runs in the same process by default. For high-volume use, scale the gateway vertically (more vCPU) or run a dedicated worker process (custom — not shipped).
## 12. `engines` map is unsynchronized across processes
There is no Redis-based lock or election to prevent two nodes from starting the same session id. If you horizontally scale without session-affinity routing, two Chromium processes will fight over the same WhatsApp Web auth dir and at least one will fail. Always pin a session id to a node at the load balancer.
## 13. Docker socket mount is required by default
`docker-compose.yml` mounts `/var/run/docker.sock:ro` into `openwa-api`. `src/modules/docker` uses it to introspect the stack from the dashboard. If you don't want that surface (or you're not running in Docker), set `DOCKER_ENABLED=false` and remove the mount — otherwise the module logs errors on every refresh attempt.
references/operation.md
# Operation
Day-2: running OpenWA, managing API keys, bringing sessions up, and using the dashboard.
## Contents
- [Authentication](#authentication)
- [API-key lifecycle](#api-key-lifecycle)
- [Session lifecycle](#session-lifecycle)
- [QR authentication flow](#qr-authentication-flow)
- [Dashboard](#dashboard)
- [Audit log](#audit-log)
- [Multi-session at scale](#multi-session-at-scale)
## Authentication
OpenWA authenticates every API call with a key in the `X-API-Key` header (the `Authorization: Bearer <key>` form is also accepted via CORS allow-list, `src/main.ts:122`).
```bash
curl -H "X-API-Key: owa_k1_..." http://localhost:2785/api/sessions
```
Validation lives in `src/modules/auth/guards/api-key.guard.ts`. The guard:
1. Reads `x-api-key` (case-insensitive).
2. Hashes and looks up the key in the `main` (SQLite) DB.
3. Checks `isActive`, `expiresAt`, IP allow-list, session allow-list.
4. Enforces the role requirement set by `@RequireRole(...)` on the handler.
5. Updates `lastUsedAt`, increments `usageCount`.
Public endpoints (no key required):
- `GET /api/health`, `/api/health/live`, `/api/health/ready`
- `GET /api/docs` (Swagger UI)
Probe a key without consuming a real endpoint:
```bash
curl -i -H "X-API-Key: $KEY" http://localhost:2785/api/auth/validate
# 200 { "valid": true, "role": "OPERATOR" }
```
## API-key lifecycle
`src/modules/auth/entities/api-key.entity.ts` columns: `id`, `name`, `keyHash`, `keyPrefix`, `role`, `allowedIps?`, `allowedSessions?`, `isActive`, `expiresAt?`, `lastUsedAt?`, `usageCount`, timestamps.
**Roles** (`api-key.entity.ts`):
| Role | Allows |
|---|---|
| `VIEWER` | Read-only endpoints |
| `OPERATOR` | All read endpoints + session CRUD + send messages + webhook CRUD |
| `ADMIN` | All of OPERATOR + API-key CRUD |
**Endpoints** (require `ADMIN`, mounted at `/api/auth/api-keys`):
| Method | Path | Body |
|---|---|---|
| `POST` | `/` | `{name, role, allowedIps?, allowedSessions?, expiresAt?}` → returns `{id, key, ...}` once (only chance to copy) |
| `GET` | `/` | list (no plaintext keys, only prefixes) |
| `GET` | `/:id` | one key |
| `PUT` | `/:id` | update mutable fields |
| `DELETE` | `/:id` | hard delete |
| `POST` | `/:id/revoke` | soft revoke (sets `isActive=false`) |
The plaintext key is returned **only** on creation. Lose it and the only recovery is to revoke and recreate.
## Session lifecycle
`src/modules/session/entities/session.entity.ts` columns: `id` (uuid), `name` (unique), `status`, `phone?`, `pushName?`, `config` (JSON), `proxyUrl?`, `proxyType?`, `connectedAt?`, `lastActiveAt?`, timestamps.
```
status enum:
CREATED — row exists, no engine started
INITIALIZING — engine.start() called, Chromium launching
QR_READY — Puppeteer reached WA Web login screen, QR available
AUTHENTICATING — QR scanned, WA negotiating
READY — connected; phone & pushName populated
DISCONNECTED — manual stop, or restart reset, or WA logout
FAILED — engine.start() threw
```
Transitions are driven by `SessionService` and the engine adapter's event stream. **All `READY`/`INITIALIZING`/`QR_READY`/`AUTHENTICATING` rows are flipped to `DISCONNECTED` on every API restart** (`src/modules/session/session.service.ts:52-70`):
```typescript
const activeStatuses = [
SessionStatus.READY, SessionStatus.INITIALIZING,
SessionStatus.QR_READY, SessionStatus.AUTHENTICATING,
];
await this.sessionRepository.update(
{ status: In(activeStatuses) },
{ status: SessionStatus.DISCONNECTED },
);
```
Reason: engines live in `engines: Map<sessionId, IWhatsAppEngine>` in process memory. On boot there are no engines, so any "active" row in the DB would be a lie.
Restart behaviour you need to handle:
- After process restart, **call `POST /api/sessions/:id/start` for each session you want back up.** Authenticated sessions don't need a fresh QR scan because the WhatsApp Web auth lives on disk under `SESSION_DATA_PATH/<id>/`. If that directory is intact, `start` will reach `READY` without QR. If you wiped the volume, QR is required again.
## QR authentication flow
```bash
# 1. Create the session row
RESP=$(curl -s -X POST http://localhost:2785/api/sessions \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"bot-1"}')
ID=$(echo "$RESP" | jq -r .id)
# 2. Start: spawns Chromium, opens web.whatsapp.com
curl -s -X POST http://localhost:2785/api/sessions/$ID/start -H "X-API-Key: $KEY"
# 3. Poll for QR_READY (or subscribe to session.qr / session.status on /events)
curl -s -H "X-API-Key: $KEY" http://localhost:2785/api/sessions/$ID
# { "id":"...", "status":"QR_READY", ... }
# 4. Pull the QR (SVG)
curl -s -H "X-API-Key: $KEY" http://localhost:2785/api/sessions/$ID/qr > qr.svg
open qr.svg # scan with WhatsApp mobile → Linked devices → Link a device
# 5. Status transitions to AUTHENTICATING then READY; phone/pushName populated
```
QR codes rotate every ~20 s server-side; re-fetch if the user is slow.
## Dashboard
`/Users/user/work/playground/OpenWA/dashboard/` — React 19 + Vite + TanStack Query, served on port `2886` (compose service `dashboard`).
What it exposes:
- **Sessions**: list, create, start/stop, view QR, see live status (driven by Socket.IO `/events`).
- **API keys**: create/revoke, view usage counts and last-used timestamps.
- **Webhooks**: per-session create/edit/test/delete.
- **Infrastructure** (`src/modules/infra/`): database type, storage backend, Redis status, queue toggle. Saving writes to `data/.env.generated`.
- **Audit log** + **Stats**.
- **i18n**: language switcher (`i18next`).
The dashboard talks to the API over the same `X-API-Key` header — the first key generated on boot is shown in the API startup banner and stored in `data/.api-key`. Paste it into the dashboard's "Connect" prompt.
## Audit log
`AuditLog` entity in the `main` (SQLite) DB captures mutating actions (session create/start/stop/delete, API-key CRUD, webhook CRUD). Surface via `src/modules/audit/audit.controller.ts`. Useful for forensics; not a substitute for `LOG_LEVEL=debug`.
## Multi-session at scale
Each session = its own Chromium process via Puppeteer. Footprint per session ≈ 80-150 MB RSS, depending on chat volume. Practical limits per node (no scientific guarantee):
| Hardware | Concurrent sessions |
|---|---|
| 2 vCPU / 4 GB | 3-6 |
| 4 vCPU / 8 GB | 8-15 |
| 8 vCPU / 16 GB | 20-30 |
If you cross those limits the symptoms are Chromium crashes, `QR_READY` taking minutes, and engine timeouts. Sharding: run multiple OpenWA pods, route session ids deterministically to pods (e.g. consistent hash on session id at an upstream proxy). The codebase does **not** ship a built-in cluster coordinator.
references/rest-api.md
# REST API
Full controller-to-route catalog, verified against the NestJS decorators in `src/modules/*/`. All paths are mounted under the global prefix `/api` (`src/main.ts:128`). Every endpoint except `/api/health*` and `/api/docs` requires `X-API-Key`.
## Contents
- [Conventions](#conventions)
- [Sessions](#sessions)
- [Messages](#messages)
- [Webhooks](#webhooks)
- [API keys](#api-keys)
- [Health](#health)
- [Contacts, Groups, Labels, Channels, Status, Catalog](#auxiliary-domains)
- [Swagger UI](#swagger-ui)
## Conventions
- **Base URL**: `http://<host>:2785/api`
- **Auth**: `X-API-Key: owa_k1_…`
- **Content-Type**: `application/json` (file upload endpoints accept `multipart/form-data`)
- **Role gating**: `@RequireRole(ApiKeyRole.OPERATOR | ADMIN)` decorators apply to writes; reads default to any active key (`VIEWER`+)
- **IDs**: session `id` is a UUID; chat ids follow WhatsApp Web (`<phone>@c.us`, `<id>@g.us`, `<id>@newsletter`); WA message ids are opaque strings
## Sessions
`@Controller('sessions')` → `src/modules/session/session.controller.ts`.
| Method | Path | Role | Purpose |
|---|---|---|---|
| `POST` | `/sessions` | OPERATOR | Create session row. Body: `{name, config?, proxyUrl?, proxyType?}` |
| `GET` | `/sessions` | any | List all sessions (incl. status) |
| `GET` | `/sessions/:id` | any | One session |
| `DELETE` | `/sessions/:id` | OPERATOR | Stop engine and delete row |
| `POST` | `/sessions/:id/start` | OPERATOR | Start engine (launches Chromium) |
| `POST` | `/sessions/:id/stop` | OPERATOR | Graceful disconnect, status → `DISCONNECTED` |
| `GET` | `/sessions/:id/qr` | any | SVG QR while status = `QR_READY` |
| `GET` | `/sessions/:id/groups` | any | List WA groups for session |
| `GET` | `/sessions/stats/overview` | any | Aggregate stats across sessions |
Create + start in two calls — start is async, the session reaches `QR_READY` then `READY` over the next 5-30 s.
## Messages
`@Controller('sessions/:sessionId/messages')` → `src/modules/message/message.controller.ts`.
| Method | Path | Role | Purpose |
|---|---|---|---|
| `GET` | `/` | any | History with filters: `?chatId=&limit=&offset=&before=` |
| `POST` | `/send-text` | OPERATOR | Body: `{chatId, text}` → `{messageId, timestamp}` |
| `POST` | `/send-image` | OPERATOR | Body: `{chatId, url\|base64, caption?}` |
| `POST` | `/send-video` | OPERATOR | Body: `{chatId, url\|base64, caption?}` |
| `POST` | `/send-audio` | OPERATOR | Body: `{chatId, url\|base64, ptt?}` (`ptt` = voice note) |
| `POST` | `/send-document` | OPERATOR | Body: `{chatId, url\|base64, filename?}` |
| `POST` | `/send-location` | OPERATOR | Body: `{chatId, latitude, longitude, name?, address?}` |
| `POST` | `/send-contact` | OPERATOR | Body: `{chatId, contactId\|vcard}` |
| `POST` | `/send-sticker` | OPERATOR | Body: `{chatId, url\|base64}` |
| `POST` | `/reply` | OPERATOR | Body: `{chatId, quotedMessageId, text}` |
| `POST` | `/forward` | OPERATOR | Body: `{chatId, messageId}` |
| `POST` | `/react` | OPERATOR | Body: `{messageId, emoji}` |
| `GET` | `/:chatId/:messageId/reactions` | any | List reactions |
| `POST` | `/delete` | OPERATOR | Body: `{messageId, forEveryone?}` |
| `POST` | `/send-bulk` | OPERATOR | Body: `{messages: [{chatId,text,…}, …]}` → `{batchId}` |
| `GET` | `/batch/:batchId` | any | Bulk progress |
| `POST` | `/batch/:batchId/cancel` | OPERATOR | Cancel a pending bulk batch |
Send returns once the message is queued to the engine; final delivery state is reported via `message.ack` events (status 1 = sent, 2 = delivered, 3 = read).
## Webhooks
`@Controller('sessions/:sessionId/webhooks')` → `src/modules/webhook/webhook.controller.ts`.
| Method | Path | Role | Purpose |
|---|---|---|---|
| `POST` | `/` | OPERATOR | Create. Body: `{url, events: string[], secret?, headers?, active?}` |
| `GET` | `/` | any | List webhooks for the session |
| `GET` | `/:id` | any | One webhook |
| `PUT` | `/:id` | OPERATOR | Update mutable fields |
| `POST` | `/:id/test` | OPERATOR | Send a synthetic delivery to the configured URL |
| `DELETE` | `/:id` | OPERATOR | Delete |
`events` valid values are in [webhooks-and-events.md](webhooks-and-events.md).
## API keys
`@Controller('auth/api-keys')` → `src/modules/auth/auth.controller.ts`. **All require role `ADMIN`.**
| Method | Path | Body / notes |
|---|---|---|
| `POST` | `/` | `{name, role, allowedIps?, allowedSessions?, expiresAt?}` → returns `{id, key, …}` once |
| `GET` | `/` | list (no plaintext) |
| `GET` | `/:id` | one |
| `PUT` | `/:id` | update mutable fields (not the key itself) |
| `DELETE` | `/:id` | delete |
| `POST` | `/:id/revoke` | soft revoke (sets `isActive=false`) |
Plus the public probe `GET /api/auth/validate` (header `X-API-Key`) → `{valid, role?}`.
## Health
`@Controller('health')` → `src/modules/health/health.controller.ts`. Public.
| Method | Path | Use |
|---|---|---|
| `GET` | `/health` | basic — process up |
| `GET` | `/health/live` | K8s `livenessProbe` |
| `GET` | `/health/ready` | K8s `readinessProbe` — verifies DB reachability |
## Auxiliary domains
Modules are wired via NestJS `imports` in `src/app.module.ts`. Patterns mirror `messages`:
| Module | Mount | Examples |
|---|---|---|
| Contacts | `/sessions/:sessionId/contacts` | `GET /`, `GET /:id`, `POST /block`, `POST /unblock` |
| Groups | `/sessions/:sessionId/groups` | `POST /` (create), `POST /:id/participants/add`, `POST /:id/participants/remove`, `POST /:id/invite-link/revoke`, `PUT /:id/subject`, `PUT /:id/description` |
| Labels | `/sessions/:sessionId/labels` | WA Business label CRUD + assign/unassign to chats |
| Channels | `/sessions/:sessionId/channels` | WA channels/newsletters (read-only on most accounts) |
| Status | `/sessions/:sessionId/status` | Stories/status posts |
| Catalog | `/sessions/:sessionId/catalog` | WA Business product catalog |
| Infra | `/infra` | Settings, stats, export/import for DB migration |
| Settings | `/settings` | Per-installation key/value |
| Stats | `/stats` | Aggregate counters |
| Audit | `/audit` | Audit log query |
| Docker | `/docker` | Stack introspection via mounted `docker.sock` |
| Plugins | `/plugins` | List/enable/disable installed plugins |
Final source of truth for parameters is the per-controller file under `src/modules/<name>/<name>.controller.ts` plus its DTOs in `dto/`.
## Swagger UI
`src/main.ts:4` imports `SwaggerModule`. Live docs at:
```
http://localhost:2785/api/docs # interactive Swagger UI
http://localhost:2785/api/docs-json # raw OpenAPI 3 JSON
```
`/api/docs-json` is what to feed into `openapi-generator-cli` when regenerating SDKs.
references/sdk.md
# SDKs
OpenWA ships first-party clients in `sdk/javascript/` (`@openwa/sdk`) and `sdk/python/` (`openwa`). Both wrap the REST API with typed surfaces; for endpoints not yet wrapped, drop down to raw `fetch`/`httpx` with the `X-API-Key` header.
## Contents
- [JavaScript / TypeScript](#javascripttypescript)
- [Python](#python)
- [Generating clients from OpenAPI](#generating-clients-from-openapi)
- [Real-time via socket.io-client](#real-time-via-socketio-client)
- [n8n](#n8n)
## JavaScript / TypeScript
`sdk/javascript/src/index.ts` exports:
```typescript
export interface OpenWAClientConfig {
baseUrl: string; // 'http://localhost:2785'
apiKey: string;
timeout?: number; // default 30000 ms
}
export interface MessageResponse { messageId: string; timestamp: number }
export interface Session { id: string; name: string; status: string; phone: string|null; pushName: string|null }
export class OpenWAClient {
constructor(config: OpenWAClientConfig)
get sessions(): {
list(): Promise<Session[]>;
get(id: string): Promise<Session>;
create(data: { name: string }): Promise<Session>;
start(id: string): Promise<Session>;
stop(id: string): Promise<Session>;
delete(id: string): Promise<void>;
}
get messages(): {
sendText(sessionId: string, data: { chatId: string; text: string }): Promise<MessageResponse>;
}
}
export default OpenWAClient;
```
Auth is `X-API-Key: <key>`; transport is `fetch` with `AbortSignal.timeout`.
### Usage
```typescript
import { OpenWAClient } from '@openwa/sdk';
const wa = new OpenWAClient({
baseUrl: process.env.OPENWA_URL ?? 'http://localhost:2785',
apiKey: process.env.OPENWA_KEY!,
});
const session = await wa.sessions.create({ name: 'bot-1' });
await wa.sessions.start(session.id);
// poll wa.sessions.get(session.id) until status === 'READY'
await wa.messages.sendText(session.id, {
chatId: '628123456789@c.us',
text: 'Hello from OpenWA',
});
```
### Endpoints the typed surface doesn't cover
The hand-written client wraps the most-used endpoints. For everything else (webhooks, media sends, groups, labels, contacts, channels, status, catalog, api-keys, infra, audit), call via `fetch` directly — the API key header is the only auth requirement:
```typescript
async function call(path: string, init: RequestInit = {}) {
const res = await fetch(`http://localhost:2785${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.OPENWA_KEY!,
...(init.headers ?? {}),
},
});
if (!res.ok) throw new Error(`OpenWA ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
await call(`/api/sessions/${id}/webhooks`, {
method: 'POST',
body: JSON.stringify({
url: 'https://example.com/wa',
events: ['message.received'],
secret: process.env.WEBHOOK_SECRET,
}),
});
```
## Python
`sdk/python/openwa/` mirrors the JS shape. Install path (from source):
```bash
pip install -e sdk/python/openwa
```
Sketch:
```python
from openwa import OpenWAClient
wa = OpenWAClient(base_url="http://localhost:2785", api_key=os.environ["OPENWA_KEY"])
session = wa.sessions.create(name="bot-1")
wa.sessions.start(session.id)
wa.messages.send_text(session.id, chat_id="628123456789@c.us", text="hi")
```
For unwrapped endpoints use the same header from `httpx`:
```python
import httpx
client = httpx.Client(base_url="http://localhost:2785",
headers={"X-API-Key": os.environ["OPENWA_KEY"]})
client.post(f"/api/sessions/{sid}/webhooks", json={
"url": "https://example.com/wa",
"events": ["message.received"],
"secret": secret,
})
```
## Generating clients from OpenAPI
OpenWA serves OpenAPI 3 at `http://localhost:2785/api/docs-json`. To regenerate a fully-typed client:
```bash
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:2785/api/docs-json \
-g typescript-fetch \
-o ./generated/openwa-client \
--additional-properties=supportsES6=true,typescriptThreePlus=true
```
For Python:
```bash
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:2785/api/docs-json \
-g python \
-o ./generated/openwa_client \
--additional-properties=packageName=openwa_client
```
The `@openwa/sdk` package itself is intended to be regenerated from this spec — the hand-written surface is a stopgap.
## Real-time via socket.io-client
The first-party SDKs don't bundle a WebSocket helper yet. Use `socket.io-client` directly:
```typescript
import { io } from 'socket.io-client';
const sock = io('http://localhost:2785/events', {
auth: { apiKey: process.env.OPENWA_KEY },
});
sock.on('connect', () => {
sock.emit('message', {
type: 'subscribe',
sessionId: '*',
events: ['message.received'],
});
});
sock.on('message', (msg) => {
if (msg.type === 'event' && msg.event === 'message.received') {
handleIncoming(msg.data);
}
});
```
Full protocol: [webhooks-and-events.md](webhooks-and-events.md#websocket-events).
## n8n
`docs/22-n8n-integration.md` in the OpenWA repo covers the canonical n8n setup. Two patterns:
1. **Webhook trigger** → n8n receives `message.received` over HTTP. Configure an HTTP node downstream that calls `POST /api/sessions/:id/messages/send-text` (header `X-API-Key`) to reply.
2. **Polling** via `GET /api/sessions/:sessionId/messages?chatId=…` when the network can't accept inbound webhooks.
The Webhook Test endpoint (`POST /api/sessions/:sessionId/webhooks/:id/test`) is handy to fire a synthetic payload at the n8n test URL during workflow development.
references/webhooks-and-events.md
# Webhooks & real-time events
OpenWA emits the same event taxonomy through two transports: HTTP webhooks (one POST per event, signed) and Socket.IO on the `/events` namespace (a subscription protocol over a persistent connection).
## Contents
- [Event taxonomy](#event-taxonomy)
- [Webhook entity](#webhook-entity)
- [HTTP delivery headers](#http-delivery-headers)
- [HMAC signing](#hmac-signing)
- [Retries & idempotency](#retries--idempotency)
- [Queue vs sync dispatch](#queue-vs-sync-dispatch)
- [Verifying a signature](#verifying-a-signature)
- [WebSocket events](#websocket-events)
## Event taxonomy
Authoritative list at `src/modules/events/dto/ws-messages.dto.ts` (`SUBSCRIBABLE_EVENTS`):
```
message.received — incoming WA message
message.sent — outbound message accepted by WA
message.ack — delivery/read ack (status 1/2/3)
message.revoked — message deleted by sender
session.status — any status transition (CREATED → … → READY etc.)
session.qr — new QR rendered (rotates ~20 s)
session.authenticated — QR scanned, WA negotiating
session.disconnected — engine disconnected
group.join — participant added
group.leave — participant removed
group.update — subject/description/picture change
```
Plus the literal `'*'` for "all events". Webhook `events: ['*']` is supported; WebSocket subscribe with `events: ['*']` likewise.
## Webhook entity
`src/modules/webhook/entities/webhook.entity.ts`:
```typescript
{
id: UUID,
sessionId: UUID,
url: string,
events: string[], // SubscribableEvent[]
secret: string | null, // HMAC secret (optional)
headers: Record<string,string> | null, // additional headers
active: boolean,
retryCount: number, // last attempt count (informational)
lastTriggeredAt: Date | null,
createdAt, updatedAt
}
```
Webhook scope is per-session. To deliver one event to N URLs, create N webhook rows for that session.
## HTTP delivery headers
Verified from `src/modules/webhook/webhook.service.ts:124-132` (test send), `:201-213` (real send), `:310-314` (retry), and `src/modules/queue/processors/webhook.processor.ts:47`:
| Header | Source | Notes |
|---|---|---|
| `Content-Type` | always `application/json` | |
| `X-OpenWA-Event` | event name | e.g. `message.received` |
| `X-OpenWA-Delivery-Id` | per-dispatch UUID | |
| `X-OpenWA-Idempotency-Key` | stable per (event, subject) | dedupe key for consumers |
| `X-OpenWA-Retry-Count` | `0` first, then `attemptsMade` | |
| `X-OpenWA-Signature` | `sha256=<hex>` | only when `webhook.secret` set |
| Any keys from `webhook.headers` | per-webhook custom headers | |
Body is the raw JSON event payload — shape depends on the event type. For `message.received`:
```json
{
"event": "message.received",
"sessionId": "…",
"timestamp": 1737054321000,
"data": {
"messageId": "false_628…@c.us_3EB0…",
"chatId": "628123456789@c.us",
"from": "628123456789@c.us",
"fromMe": false,
"type": "chat",
"body": "hello",
"hasMedia": false,
"timestamp": 1737054321
}
}
```
(Verify exact shape per event in `src/modules/webhook/dto/` and the engine adapter mapping.)
## HMAC signing
`src/modules/webhook/webhook.service.ts:generateSignature()`:
```typescript
crypto.createHmac('sha256', secret).update(body).digest('hex')
// header value:
'X-OpenWA-Signature': `sha256=${hex}`
```
The HMAC input is the **raw JSON string body**, not the parsed object. Consumers must verify against the unparsed request body.
## Retries & idempotency
With `QUEUE_ENABLED=true` (Redis + BullMQ):
- `WEBHOOK_MAX_RETRIES=3` total attempts.
- BullMQ backoff base: `WEBHOOK_RETRY_DELAY=5000` ms.
- Timeout per attempt: `WEBHOOK_TIMEOUT=10000` ms.
- `X-OpenWA-Retry-Count` increments per attempt (starting `0`).
- Job lives in `webhook-delivery` queue; failures land in BullMQ's failed jobs and can be re-driven.
With `QUEUE_ENABLED=false`:
- One attempt, no retry. Failures are logged and dropped.
`X-OpenWA-Idempotency-Key` is **stable across retries of the same event** (so consumers can dedupe), but **shared keys across the lifecycle** (e.g. a `message.received` event has a key derived from `(event, messageId)`). Treat the key as the dedup key in the consumer's storage.
## Queue vs sync dispatch
```
[Engine event]
↓
WebhookService.dispatch(event, sessionId, payload)
↓
For each active webhook matching event in events[] (or '*'):
↓
if queueEnabled:
queue.add('webhook-delivery', WebhookJobData)
else:
await this.send(...) ← single-shot, no retry
```
Production deployments without `QUEUE_ENABLED=true` will silently drop webhooks when the receiver is briefly unavailable.
## Verifying a signature
Node.js (express raw body):
```javascript
import crypto from 'node:crypto';
app.post('/openwa-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const got = req.headers['x-openwa-signature'];
if (!got || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
// dedupe on req.headers['x-openwa-idempotency-key']
res.status(200).end();
});
```
Python (FastAPI):
```python
import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/openwa-webhook")
async def handle(req: Request):
body = await req.body()
expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, req.headers.get("x-openwa-signature", "")):
raise HTTPException(401)
event = json.loads(body)
# dedupe on req.headers["x-openwa-idempotency-key"]
```
## WebSocket events
Same taxonomy, delivered live over Socket.IO on the `/events` namespace.
### Wire protocol
The protocol is **not** stock Socket.IO emit-style. Clients send a JSON envelope under the generic `'message'` event and receive a JSON envelope back. From `src/modules/events/dto/ws-messages.dto.ts`:
**Client → server**:
```typescript
type WSClientMessage =
| { type: 'subscribe'; sessionId: string; events: string[]; requestId?: string }
| { type: 'unsubscribe'; sessionId: string; requestId?: string }
| { type: 'ping'; requestId?: string }
```
`sessionId` may be `'*'` for "all sessions". `events` may be `['*']`.
**Server → client**:
```typescript
type WSServerMessage =
| { type: 'subscribed'; sessionId: string; events: string[]; requestId?: string; timestamp: string }
| { type: 'unsubscribed'; sessionId: string; requestId?: string; timestamp: string }
| { type: 'event'; sessionId: string; event: string; data: unknown; timestamp: string }
| { type: 'error'; message: string; requestId?: string; timestamp: string }
| { type: 'pong'; timestamp: string; requestId?: string }
```
### Authentication
The gateway expects the API key either as a `Socket.IO auth` field or as a query/header. Easiest:
```javascript
import { io } from 'socket.io-client';
const sock = io('http://localhost:2785/events', {
auth: { apiKey: process.env.OPENWA_KEY },
// or transports:['websocket'], extraHeaders:{ 'X-API-Key': ... }
});
```
Bad/missing keys are rejected at handshake time.
### Subscribing
```javascript
sock.on('connect', () => {
sock.emit('message', {
type: 'subscribe',
sessionId: '*',
events: ['message.received', 'session.status'],
requestId: 'req-1',
});
});
sock.on('message', (msg) => {
if (msg.type === 'event') {
console.log(msg.event, msg.sessionId, msg.data);
}
});
// keep-alive
setInterval(() => sock.emit('message', { type: 'ping' }), 30_000);
```
### Room layout
Internally the gateway joins each subscribed socket to rooms `session:<sessionId>:<event>` (or `session:*:<event>` / `session:<id>:*`). Broadcasts target the union of matching rooms. This is implementation detail — clients shouldn't depend on the room names; just use `subscribe`.
SKILL.md
---
name: openwa
description: OpenWA self-hosted WhatsApp API Gateway expertise — deployment, sessions, REST API, webhooks, real-time events, SDKs, plugins. Use when working in the OpenWA repo or a gateway based on it; when code hits port `2785`, builds the `openwa-api` Docker service, or fronts the dashboard on `2886`; when scripts send `X-API-Key` headers with `owa_k1_` keys; when configuring `whatsapp-web.js` sessions via `POST /api/sessions/:id/start`, fetching QR via `/api/sessions/:id/qr`, or sending via `POST /api/sessions/:sessionId/messages/send-text`; when subscribing to `message.received`/`message.sent`/`message.ack`/`session.status` webhooks; when handling `X-OpenWA-Event`/`X-OpenWA-Idempotency-Key`/`X-OpenWA-Signature` headers; when using the `/events` Socket.IO namespace, `@openwa/sdk` (JS) or `openwa` (Python); when toggling `DATABASE_TYPE`, `QUEUE_ENABLED`, `REDIS_ENABLED`, `STORAGE_TYPE`, `PUPPETEER_HEADLESS`; when extending via plugin hooks (`message:sending`, `message:sent`) or integrating into n8n.
---
# 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`).
```bash
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 string `dev-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](references/operation.md#authentication) 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:
```bash
# 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](references/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](references/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](references/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.
```javascript
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](references/webhooks-and-events.md#websocket-events).
## 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](references/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`:
- **`main` connection** — always SQLite. Stores API keys, audit log. Stays node-local (don't try to migrate it).
- **`data` connection** — SQLite or PostgreSQL. Stores sessions, messages, webhooks. Synchronize defaults `true` for SQLite, `false` for Postgres (then `npm run migration:run:prod`).
Full env-var reference, `.env.minimal`, dual-DB diagram, and `QUEUE_ENABLED` gating: [configuration.md](references/configuration.md).
## SDKs
```typescript
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](references/sdk.md).
## Architecture & extensibility
OpenWA's pluggability is enforced through interface boundaries, not feature flags alone:
- **`IWhatsAppEngine`** (`src/engine/interfaces/`) — abstracts `whatsapp-web.js`; alternative engines (Baileys, etc.) can be dropped behind it via `engine.factory.ts`. Selected by `ENGINE_TYPE`.
- **`StorageService`** — local-fs or S3/MinIO, swapped via `STORAGE_TYPE`.
- **`CacheService`** — in-memory or Redis, gated by `REDIS_ENABLED`.
- **Plugin hooks** — `core/hooks/` runs named hook chains. The message-send pipeline fires `message:sending` before dispatch (plugins can modify/block) and `message:sent` on success. Plugins are auto-loaded when `PLUGINS_ENABLED=true`.
- **Queue gating** — `QueueModule` is `require`d only when `QUEUE_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](references/architecture.md).
## Gotchas
A handful of behaviours that bite people who read the docs but skip the code:
1. `.env.generated` is the **lowest** precedence, not the highest.
2. Every API restart force-resets all `READY/INITIALIZING/QR_READY/AUTHENTICATING` sessions to `DISCONNECTED` (engines are in-memory; restart loses them).
3. `QUEUE_ENABLED=false` (the default) means **webhooks have no retry** — synchronous best-effort dispatch.
4. `synchronize: true` is the SQLite default and is **dangerous in production**; flip to migrations for Postgres.
5. API keys live in the SQLite `main` DB. Switching the `data` DB to Postgres doesn't move them.
6. `phone` and `pushName` are `null` until the session reaches `READY`.
7. Chat-id format must be `<phone>@c.us` / `<id>@g.us` — wrong format = silent failure.
8. Chromium is mandatory; the Docker image hard-codes `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium`.
Full annotated list with file:line references: [gotchas.md](references/gotchas.md).
## Reference index
| Reference | What's inside |
|---|---|
| [deployment.md](references/deployment.md) | Dockerfile stages, compose profiles, Traefik, K8s probes, volumes |
| [configuration.md](references/configuration.md) | Env-var reference, precedence, dual-DB layout, queue gating |
| [operation.md](references/operation.md) | Session lifecycle, QR auth, API-key bootstrap & lifecycle, dashboard |
| [rest-api.md](references/rest-api.md) | Full controller→route catalog, role decorators, request/response shapes |
| [webhooks-and-events.md](references/webhooks-and-events.md) | Webhook headers, HMAC, retries, idempotency, Socket.IO `/events` wire format |
| [sdk.md](references/sdk.md) | JS/TS and Python client usage, raw `fetch` fallback, regeneration notes |
| [architecture.md](references/architecture.md) | Module map, engine/storage/cache pluggability, plugin hooks, scaling |
| [gotchas.md](references/gotchas.md) | Non-obvious behaviours verified against the source |