thatjuan/agent-skills

openwa

OpenWA self-hosted WhatsApp API Gateway expertise — deployment, sessions, REST API, webhooks, real-time events, SDKs, plugins.

Zobacz źródło
Oryginalny dokument Skill

Treść z repozytorium z zachowaniem nagłówków, przykładów, kodu, tabel, linków i obrazów.

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

ComponentPortRole
openwa-api (NestJS)2785REST + WebSocket + webhook dispatcher
dashboard (React 19 + Vite)2886Session/API-key/webhook UI
Swagger UI2785/api/docs
Traefik (optional)80, 8080Reverse proxy + dashboard (profile with-proxy)
PostgreSQL (optional)5432Profile postgres
Redis (optional)6379Profile redis, gates BullMQ queue
MinIO (optional)9000, 9001Profile 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

LayerTechEvidence
FrameworkNestJS 11.xpackage.json
WA enginewhatsapp-web.js 1.26.1 (Puppeteer + Chromium)package.json, src/engine/engine.factory.ts
Main DBSQLite (always — stores API keys, audit log)src/app.module.ts
Data DBSQLite or PostgreSQL (sessions, messages, webhooks)src/app.module.ts
ORMTypeORM 0.3.xpackage.json
QueueBullMQ 5.x (gated by QUEUE_ENABLED)src/app.module.ts:31
CacheRedis (optional, gated by REDIS_ENABLED)src/common/cache/
StorageLocal or S3/MinIOsrc/common/storage/
Real-timeSocket.IO 4.8 on namespace /eventssrc/modules/events/events.gateway.ts
Validationclass-validator, class-transformerpackage.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 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.

REST API — controller catalog

All routes are mounted under /api. Roles in parentheses are required when stricter than the default VIEWER-or-higher.

DomainRoutesFile
SessionsPOST /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/overviewsession.controller.ts
MessagesGET /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/cancelmessage.controller.ts
WebhooksPOST/GET/GET :id/PUT :id/DELETE :id under /sessions/:sessionId/webhooks, plus POST :id/testwebhook.controller.ts
API keysPOST /auth/api-keys (ADMIN), GET, GET :id, PUT :id, DELETE :id, POST :id/revokeauth.controller.ts
Auth probeGET /auth/validate (header X-API-Key)auth-validate.controller.ts
HealthGET /health, GET /health/live, GET /health/ready (public; K8s-shaped)health.controller.ts
Contact/Group/Label/Channel/Status/CatalogPer-module controllers under /sessions/:sessionId/...modules/<name>/
InfraSettings, stats, export/import for migrationmodules/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:

HeaderMeaning
X-OpenWA-EventEvent name (e.g. message.received)
X-OpenWA-Delivery-IdUnique per delivery attempt's parent dispatch
X-OpenWA-Idempotency-KeyStable per (event × subject) — dedupe key for consumers
X-OpenWA-Retry-Count0 on first attempt, then attemptsMade
X-OpenWA-Signaturesha256=<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.

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.

Deployment

Three documented paths:

PathCommand
Local dev (SQLite, no Redis/queue, all in one)docker compose -f docker-compose.dev.yml up -d
Production basicdocker 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:

  • `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.

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.

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 hookscore/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 gatingQueueModule is required 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.

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.

Reference index

ReferenceWhat's inside
deployment.mdDockerfile stages, compose profiles, Traefik, K8s probes, volumes
configuration.mdEnv-var reference, precedence, dual-DB layout, queue gating
operation.mdSession lifecycle, QR auth, API-key bootstrap & lifecycle, dashboard
rest-api.mdFull controller→route catalog, role decorators, request/response shapes
webhooks-and-events.mdWebhook headers, HMAC, retries, idempotency, Socket.IO /events wire format
sdk.mdJS/TS and Python client usage, raw fetch fallback, regeneration notes
architecture.mdModule map, engine/storage/cache pluggability, plugin hooks, scaling
gotchas.mdNon-obvious behaviours verified against the source