A self-hosted, single-container REST API for sending WhatsApp OTPs (and bulk text messages) to multiple phone numbers. Built on whatsapp-web.js and NestJS.
Scope — This is a stripped-down build focused on OTP delivery. It keeps only what you need: multi-session management, QR-code authentication, single-text send, and bulk-text send. No Redis, no S3, no dashboard, no sidecar services — just SQLite and a single Docker container.
Not an official WhatsApp product. It drives WhatsApp Web through a headless browser, which is not a supported integration path. Automated sending can get a number rate-limited or banned — see Typing Simulation for the anti-ban signals this project implements, and use a number you can afford to lose while testing.
| Capability | Detail |
|---|---|
| Multi-session | Run multiple WhatsApp numbers on one instance |
| QR authentication | Scan from a browser — /qr/scan auto-refreshes every 15 s |
| Text send | Single message to one recipient |
| Bulk send | Up to 100 recipients per batch, variable substitution, configurable delay |
| Typing simulation | Optional "typing…" presence for a content-proportional duration before delivery — off by default (details) |
| Delivery tracking | Message status reflects WhatsApp's own acks: sent → delivered → read |
| Message history | Queryable log of sent/received messages (SQLite) |
| API key auth | OPERATOR / ADMIN roles — set your key in .env or let it auto-generate |
| Rate limiting | Three-tier (per-second, per-minute, per-hour) |
| Proxy support | Per-session HTTP/SOCKS proxy |
| Plugin hooks | Internal event lifecycle hooks (message:sending, session:ready, …) — for engine plugins, not HTTP |
| Health checks | /api/health, /api/health/live, /api/health/ready |
| Swagger UI | Interactive docs at /api/docs |
# 1. Clone
git clone https://github.com/3bsalam-1/OpenWA-mini.git
cd OpenWA-mini
# 2. Set your API key (optional — auto-generated if omitted)
echo "API_KEY=your-secret-key" > .env
# 3. Start
docker compose up -dAPI: http://localhost:2785/api
Docs: http://localhost:2785/api/docs
If you skipped step 2, retrieve the auto-generated key from the logs:
docker compose logs openwa-mini-api | grep "API Key" -A1Requires Node.js 20+ (22 LTS recommended). NestJS 11 relies on a global
crypto, so older runtimes fail at boot withReferenceError: crypto is not defined. The Docker image already uses Node 22.
cp .env.minimal .env # edit API_KEY and any other vars
npm install
npm run start:devThe API key controls access to all protected endpoints. There are two ways to manage it.
Add API_KEY to your .env file (or Docker Compose environment):
API_KEY=your-strong-secret-keyOn every restart the server reads this value and ensures it is the active default admin key. Changing the value and restarting replaces the old key automatically — no database cleanup needed.
On first boot the server generates a random key, prints it in the startup banner, and saves it to data/.api-key. Subsequent restarts re-display the saved key. Use this mode for quick local testing; pin it via API_KEY for any persistent deployment.
curl -s -X POST http://localhost:2785/api/auth/api-keys \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"name": "backend-service", "role": "operator"}'The raw key is returned once — save it immediately.
| Role | Permissions |
|---|---|
admin |
Full access including key management |
operator |
Create sessions, send messages |
viewer |
Read-only (list sessions, message history) |
Copy .env.minimal to .env and edit what you need. Every value has a sensible default — an empty .env works for local testing.
# Server
PORT=2785
NODE_ENV=production
# SQLite databases
DATABASE_TYPE=sqlite
DATABASE_NAME=./data/openwa-mini.sqlite
# Chromium / Puppeteer
PUPPETEER_HEADLESS=true
PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
# API key — fixed key for all restarts.
# Remove this line to auto-generate a random key on first boot.
API_KEY=your-strong-secret-key| Variable | Default | Description |
|---|---|---|
PORT |
2785 |
HTTP port |
NODE_ENV |
— | Set to production for production behaviour: random owa_k1_… keys, suppressed validation error detail, HTTPS-upgrade CSP |
API_KEY |
auto | Fixed default admin key. Unset → a key is generated on first boot and saved to data/.api-key |
QR_TOKEN_SECRET |
API_KEY / random |
HMAC secret for signing browser QR-scan tokens. Set a fixed value for stable tokens across restarts/instances |
QR_TOKEN_TTL_MS |
600000 |
Lifetime (ms) of a QR scan link before it must be re-minted via /qr/link (default 10 min) |
CORS_ORIGINS |
* |
Comma-separated list of allowed origins |
DATABASE_TYPE |
sqlite |
Engine for the data DB (sessions/messages): sqlite or postgres |
DATABASE_NAME |
./data/openwa-mini.sqlite |
Data DB path (SQLite only) |
DATABASE_HOST |
localhost |
Postgres host (when DATABASE_TYPE=postgres) |
DATABASE_PORT |
5432 |
Postgres port |
DATABASE_USERNAME |
— | Postgres username |
DATABASE_PASSWORD |
— | Postgres password |
DATABASE_POOL_SIZE |
10 |
Postgres connection pool size |
DATABASE_SYNCHRONIZE |
false |
Auto-sync schema for the data DB (leave off in production — migrations run automatically) |
MAIN_DATABASE_SYNCHRONIZE |
true |
Auto-sync schema for the auth DB. Keep true unless you manage the api_keys schema yourself (no migrations ship for it) |
DATABASE_LOGGING |
false |
Log SQL queries |
ENGINE_TYPE |
whatsapp-web.js |
WhatsApp engine (only whatsapp-web.js is bundled) |
SESSION_DATA_PATH |
./data/sessions |
Directory for whatsapp-web.js LocalAuth/browser state |
SESSION_SETTLE_TIMEOUT_MS |
90000 |
How long a session may hang in initializing/authenticating before the watchdog intervenes (ms). The stalled Chromium is destroyed and initialization retried once; a second stall marks the session failed. Effective budget is therefore ~2× this value |
PUPPETEER_HEADLESS |
true |
Run Chromium headless (false to show a window) |
PUPPETEER_ARGS |
--no-sandbox,--disable-setuid-sandbox |
Comma-separated Chromium launch flags |
RATE_LIMIT_SHORT_TTL / RATE_LIMIT_SHORT_LIMIT |
1000 / 10 |
Per-second window (ms) and request cap — see Rate Limiting |
RATE_LIMIT_MEDIUM_TTL / RATE_LIMIT_MEDIUM_LIMIT |
60000 / 100 |
Per-minute window and cap |
RATE_LIMIT_LONG_TTL / RATE_LIMIT_LONG_LIMIT |
3600000 / 1000 |
Per-hour window and cap |
TYPING_SIMULATION_ENABLED |
false |
Master switch for human-like typing — see Typing Simulation |
TYPING_WPM |
45 |
Typing speed in words per minute (10–200) |
TYPING_MIN_MS |
700 |
Floor for one chunk's typing time (ms) |
TYPING_MAX_MS |
12000 |
Ceiling for the whole send: pre-delay + every chunk (ms) |
TYPING_PRE_DELAY_MS |
800 |
"Read and think" pause before the typing indicator turns on (ms) |
TYPING_JITTER |
0.15 |
Randomness fraction; 0.15 scales each wait by a random 0.85–1.15 |
TYPING_MARK_SEEN |
false |
Mark the chat read before replying (emits blue ticks on your account) |
TYPING_CHUNK_ENABLED |
false |
Split long text into several bubbles |
TYPING_CHUNK_MAX_CHARS |
280 |
Soft ceiling per bubble; an unbreakable token may exceed it |
BASE_URL |
http://localhost:{PORT} |
Base URL for generating QR scan URLs |
Data is persisted in the openwa-mini-data Docker volume (or ./data/ locally).
Note: Two SQLite files are created at runtime —
data/main.sqlitestores API keys and auth data (path is fixed, not configurable), anddata/openwa-mini.sqlitestores sessions, messages, and batches (path set viaDATABASE_NAME). SettingDATABASE_TYPE=postgresmoves only the data DB to Postgres; the auth DB always stays on SQLite.
All endpoints share a global three-tier rate limit (per IP). Defaults:
| Tier | Window | Limit | Tune with |
|---|---|---|---|
| Short | 1 second | 10 requests | RATE_LIMIT_SHORT_TTL / RATE_LIMIT_SHORT_LIMIT |
| Medium | 1 minute | 100 requests | RATE_LIMIT_MEDIUM_TTL / RATE_LIMIT_MEDIUM_LIMIT |
| Long | 1 hour | 1000 requests | RATE_LIMIT_LONG_TTL / RATE_LIMIT_LONG_LIMIT |
Exceeding any tier returns 429 Too Many Requests. The X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset response headers are exposed via CORS for browser clients.
Base URL: http://localhost:2785/api (every route below is relative to /api). Interactive docs live at /api/docs.
Authentication — every endpoint except the health probes requires an API key. Pass it as a header:
X-API-Key: <key>Authorization: Bearer <key> is also accepted. A missing or invalid key returns 401 Unauthorized.
Roles — keys are admin, operator, or viewer, and the hierarchy is cumulative: admin ⊃ operator ⊃ viewer. A higher role satisfies any endpoint that asks for a lower one (an admin key can call operator routes). Routes marked any in the tables below accept any valid key; calling a route with too low a role returns 401 with Insufficient permissions. Required: <role>.
Responses are returned as raw JSON — there is no wrapper envelope. Errors use NestJS's default shape:
{ "statusCode": 404, "message": "Session not found", "error": "Not Found" }Validation failures return 400 with message as an array of per-field errors (the detail is suppressed when NODE_ENV=production). Rate-limited requests return 429 Too Many Requests — see Rate Limiting.
| Method | Route | Role | Description |
|---|---|---|---|
POST |
/api/sessions |
operator | Create a session |
GET |
/api/sessions |
any | List all sessions |
GET |
/api/sessions/:id |
any | Get session details |
DELETE |
/api/sessions/:id |
operator | Delete a session |
POST |
/api/sessions/:id/start |
operator | Start WhatsApp connection |
POST |
/api/sessions/:id/stop |
operator | Disconnect |
GET |
/api/sessions/:id/qr |
any | QR code as JSON { qrCode: "data:image/png;base64,…", status: "qr_ready" } |
GET |
/api/sessions/:id/qr/link |
operator | Mint a short-lived, browser-openable scan URL (signed token) — returns { scanUrl, imageUrl, expiresAt } |
GET |
/api/sessions/:id/qr/image?token=… |
token | QR code as raw PNG. No API key, but requires the signed token from /qr/link |
GET |
/api/sessions/:id/qr/scan?token=… |
token | HTML page — shows QR, auto-refreshes, polls status, and has a "Check status" button. Requires the token from /qr/link |
GET |
/api/sessions/:id/status |
public | Lightweight { status } for the scan page (no key) — qr_ready / authenticating / ready / failed / disconnected |
GET |
/api/sessions/stats/overview |
any | Session counts + memory usage |
| Method | Route | Role | Description |
|---|---|---|---|
POST |
/api/sessions/:sessionId/messages/send-text |
operator | Send one text message. Accepts an optional typing block (details) |
POST |
/api/sessions/:sessionId/messages/send-bulk |
operator | Send to multiple recipients (async) |
GET |
/api/sessions/:sessionId/messages |
any | Message history — refreshes delivery status from WhatsApp on each read (details) |
GET |
/api/sessions/:sessionId/messages/batch/:batchId |
any | Bulk send status |
POST |
/api/sessions/:sessionId/messages/batch/:batchId/cancel |
operator | Cancel running batch |
GET /api/sessions/:sessionId/messages accepts optional query parameters: chatId (filter to one conversation), limit, and offset (pagination).
All /api/auth/api-keys endpoints require the admin role.
| Method | Route | Description |
|---|---|---|
POST |
/api/auth/validate |
Validate the current API key — returns role info (any role) |
POST |
/api/auth/api-keys |
Create a new key (returns raw key once) |
GET |
/api/auth/api-keys |
List all keys |
GET |
/api/auth/api-keys/:id |
Get a single key by ID |
PUT |
/api/auth/api-keys/:id |
Update name, role, or IP restrictions |
DELETE |
/api/auth/api-keys/:id |
Delete a key |
POST |
/api/auth/api-keys/:id/revoke |
Revoke without deleting |
These endpoints are public — no API key required — and are intended for load balancers and Kubernetes probes.
| Method | Route | Description |
|---|---|---|
GET |
/api/health |
Basic health check — { status: "ok", timestamp } |
GET |
/api/health/live |
Liveness probe — { status: "ok" } |
GET |
/api/health/ready |
Readiness probe — pings both databases; 200 { status: "ok", details: { main, data } } when up, 503 if either DB is unreachable |
KEY="your-secret-key" # whatever you set in API_KEY
BASE="http://localhost:2785/api"# Create
curl -s -X POST $BASE/sessions \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"name": "otp-sender"}'
# Start — Puppeteer launches Chromium in the background
SESSION_ID="<id from above response>"
curl -s -X POST $BASE/sessions/$SESSION_ID/start \
-H "X-API-Key: $KEY"
# Mint a short-lived, browser-openable scan URL (the browser can't send the API key)
curl -s $BASE/sessions/$SESSION_ID/qr/link -H "X-API-Key: $KEY"
# → { "scanUrl": "/api/sessions/.../qr/scan?token=…", "imageUrl": "...", "expiresAt": "..." }
# Open the returned scanUrl in a browser and scan with WhatsApp → Linked Devices → Link a Device.
# The page auto-refreshes a fresh QR every 15s and shows ✅ once authenticated.
open "http://localhost:2785${scanUrl}"curl -s -X POST $BASE/sessions/$SESSION_ID/messages/send-text \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "966512345678@c.us",
"text": "Your OTP is: 482910. Valid for 5 minutes."
}'{ "messageId": "true_966512345678@c.us_3EB0123456789", "timestamp": 1706868000 }curl -s -X POST $BASE/sessions/$SESSION_ID/messages/send-bulk \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{ "chatId": "966512345678@c.us", "text": "Hi {name}, your OTP is {otp}.", "variables": { "name": "Ahmed", "otp": "482910" } },
{ "chatId": "966598765432@c.us", "text": "Hi {name}, your OTP is {otp}.", "variables": { "name": "Sara", "otp": "731204" } }
],
"options": { "delayBetweenMessages": 3000, "randomizeDelay": true }
}'Returns 202 Accepted immediately — processing is async:
{
"batchId": "batch_c1b723c0",
"status": "pending",
"totalMessages": 2,
"estimatedCompletionTime": "2026-05-25T13:15:18Z",
"statusUrl": "/api/sessions/.../messages/batch/batch_c1b723c0"
}curl -s $BASE/sessions/$SESSION_ID/messages/batch/batch_c1b723c0 \
-H "X-API-Key: $KEY"{
"batchId": "batch_c1b723c0",
"status": "completed",
"progress": { "total": 2, "sent": 2, "failed": 0, "pending": 0, "cancelled": 0 },
"results": [
{ "chatId": "966512345678@c.us", "status": "sent", "messageId": "…", "sentAt": "…" },
{ "chatId": "966598765432@c.us", "status": "sent", "messageId": "…", "sentAt": "…" }
]
}| Option | Type | Default | Description |
|---|---|---|---|
delayBetweenMessages |
ms | 3000 |
Base delay between sends (min 1000, max 60000) |
randomizeDelay |
bool | true |
Add 0–2 s of random jitter on top of the base delay |
stopOnError |
bool | false |
Abort the entire batch on the first failure |
batchId |
string | auto | Custom batch ID for idempotency checks |
typing |
object | – | Typing-simulation overrides applied to every message (see below) |
Maximum 100 recipients per batch. Use {variable} placeholders in text and pass matching variables per recipient.
delayBetweenMessages remains the inter-recipient anti-ban pause. When typing simulation is on, typing time is
additional and per-message; estimatedCompletionTime accounts for both.
By default this gateway sends messages instantly, which is indistinguishable from a paste. With typing simulation
enabled, an outbound message instead emits WhatsApp's composing ("typing…") presence, waits a duration derived
from the message content, and only then delivers — optionally as several shorter bubbles rather than one wall of
text.
It is off by default. Enabling it adds latency to every send.
charsPerSecond = wpm × 5 ÷ 60 (45 wpm ≈ 3.75 chars/sec). On top of the raw character count:
- URLs are charged a flat cost instead of their length — nobody types an 80-character link character by character.
- Emoji are charged less than their code-unit length (one picker tap).
- A short pause is added at each sentence boundary (
.,?,!, newline). - Every wait is multiplied by a random jitter factor in
[1 − TYPING_JITTER, 1 + TYPING_JITTER].
| Variable | Default | Description |
|---|---|---|
TYPING_SIMULATION_ENABLED |
false |
Master switch |
TYPING_WPM |
45 |
Typing speed in words per minute (10–200) |
TYPING_MIN_MS |
700 |
Floor for one chunk's typing time |
TYPING_MAX_MS |
12000 |
Ceiling for the whole send: pre-delay + every chunk |
TYPING_PRE_DELAY_MS |
800 |
"Read and think" pause before the indicator turns on |
TYPING_JITTER |
0.15 |
Randomness fraction applied to every wait |
TYPING_MARK_SEEN |
false |
Mark the chat read before replying |
TYPING_CHUNK_ENABLED |
false |
Split long text into several bubbles |
TYPING_CHUNK_MAX_CHARS |
280 |
Soft ceiling per bubble |
TYPING_MAX_MS is a total budget, not a per-chunk clamp. If the computed plan exceeds it, every wait is scaled
down proportionally, so one message's simulation never exceeds this no matter how many chunks the text splits
into. The total ceiling outranks the TYPING_MIN_MS per-chunk floor.
Note this bounds the simulation, not necessarily the HTTP request. Concurrent sends to the same chat are serialized so their typing indicators cannot interleave, so a request may additionally wait for those queued ahead of it. That queue is capped: past 8 waiting sends for one chat, further sends skip serialization rather than queue indefinitely — a briefly flickering indicator is preferable to a hung request. Sends to different chats never wait on each other.
All values are clamped on the way in — maxMs to 30 s, preDelayMs to 10 s, wpm to 10–200 — no matter whether
they arrive from a request, a session's config, or an environment variable.
TYPING_MARK_SEEN defaults to off because it is an observable side effect on the operator's real account —
it clears unread state and emits blue ticks. For unprompted OTP pushes there is usually no unread message to mark
seen anyway, so it earns its keep only in reply-to-inbound flows.
Typing simulation applies to group chats (@g.us) exactly as it does to direct chats.
Precedence is request > session > env. A session can carry a typing block in its config column; a single
request can override both via the optional typing object on POST /send-text (and on options for /send-bulk):
| Field | Type | Bounds |
|---|---|---|
typing.enabled |
bool | – |
typing.wpm |
int | 10–200 |
typing.minMs |
int | 0–30000 |
typing.maxMs |
int | 0–30000 |
typing.chunk |
bool | – |
Out-of-bounds values are rejected with 400; an unbounded maxMs from a caller would be a self-inflicted DoS.
messageId is always a real WhatsApp message ID (true_<chat>_<hex>) — the send is confirmed against
WhatsApp's own message store before the API responds. If it cannot be confirmed after several lookups the
request fails with 400 rather than returning a placeholder, so any ID you receive is safe to store and to
correlate with delivery receipts.
messageId and timestamp are unchanged and always present — messageId is the first (or only) chunk's ID.
Three optional fields appear only when simulation ran: messageIds (one per chunk), typingMs, and chunks.
Existing consumers are unaffected.
Two caveats when chunking is on. Bulk batch results record only the first bubble's ID per recipient. And if a
send fails partway through a chunked message, the earlier bubbles have already been delivered — the stored message
is marked failed with metadata.partialDelivery = true and metadata.messageIds listing what did land, so a
retry can be recognised as a partial re-send rather than a fresh one.
curl -X POST http://localhost:2785/api/sessions/$SESSION_ID/messages/send-text \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "966512345678@c.us",
"text": "Hi! Thanks for reaching out. I will check on that and get back to you shortly.",
"typing": { "enabled": true, "wpm": 50 }
}'At 50 wpm the 78-character body works out to roughly 18.7 s of raw typing, which the 12 s total budget scales down; including the ~0.8 s pre-delay, the recipient sees "typing…" for about 12 s before the message lands:
{
"messageId": "true_966512345678@c.us_3EB0123456789",
"timestamp": 1706868000,
"messageIds": ["true_966512345678@c.us_3EB0123456789"],
"typingMs": 12000,
"chunks": 1
}GET /api/sessions/{id}/messages refreshes delivery status from WhatsApp's own acks on every read, so the
status field is a real answer rather than an optimistic guess:
| status | Meaning |
|---|---|
pending |
Stored, not yet dispatched |
sent |
Dispatched to WhatsApp (✓) |
delivered |
Reached the recipient's device (✓✓) |
read |
Opened by the recipient (blue ticks) |
failed |
Send threw |
curl -s "http://localhost:2785/api/sessions/$SESSION_ID/messages?chatId=966512345678@c.us&limit=5" \
-H "X-API-Key: $API_KEY"Status only ever moves forward — a stale ack can never demote a message already reported as read.
Acks are polled from WhatsApp's message store on read, not driven by the message_ack event, because that
event does not fire on current WhatsApp Web builds. The refresh is best-effort: if it fails, you still get the
history, just with the previously known status.
Messages sent before this behaviour existed may carry a legacy unconfirmed: ID; those can never progress past
sent, because there is no real ID to match an ack against.
Typing simulation is worth enabling for OTP only as an anti-ban signal; it always costs latency. A short code at the default 45 wpm takes ~6.5 s. Tuning brings that down:
{ "typing": { "enabled": true, "wpm": 90, "maxMs": 3000 } }| Setting | Typing time for a ~19-char OTP |
|---|---|
enabled: false (default) |
0 s — instant, no indicator |
| default 45 wpm | ~6.5 s |
wpm: 90, maxMs: 3000 |
~3 s |
Set them per request, or globally via TYPING_WPM / TYPING_MAX_MS.
POST /send-text is synchronous — it blocks for the full simulation before responding. TYPING_MAX_MS exists
precisely because this gateway's primary use case is OTP delivery, and an OTP that takes 20 s to arrive is a broken
product. Keep the budget tight for OTP traffic, or leave simulation off for it entirely.
If you need a non-blocking send, use POST /send-bulk with a single recipient: it returns 202 Accepted with a
statusUrl you can poll.
Two hook events fire around the simulation, between message:sending and message:sent:
message:typing:start and message:typing:stop. Both receive { sessionId, chatId, plan }, where plan
contains chunks, preDelayMs, chunkMs, and totalMs.
POST /sessions → CREATED
↓ start
INITIALIZING
↓ (Puppeteer + Chromium ready)
QR_READY ──→ GET /qr/link → open scanUrl in browser and scan
↓
AUTHENTICATING
↓
READY ←── auto-reconnect on drop
↓ stop / delete
DISCONNECTED
The status field on a session reports one of: created, initializing, qr_ready, authenticating, ready, disconnected, or failed (set when startup or authentication errors out).
Auto-start on boot: sessions that were active when the server stopped are automatically re-started on the next boot (restored from LocalAuth — no re-scan if the session is still valid).
If a restored session hangs in initializing/authenticating, the settle watchdog (SESSION_SETTLE_TIMEOUT_MS, default 90 s) destroys the stalled Chromium and retries initialization once. A stalled restore commonly succeeds on the second attempt, and failing outright would cost a physical QR re-scan. Only if the retry also stalls is the session marked failed — which then frees the browser and stops the auto-start loop, since failed sessions are not auto-started.
On unexpected disconnect the service retries with exponential back-off (5 attempts, base 5 s by default). Override per session via config:
curl -s -X POST $BASE/sessions \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"name": "otp-sender", "config": {"maxReconnectAttempts": 10, "reconnectBaseDelay": 3000}}'When the container restarts, sessions with active status (ready, initializing, qr_ready, authenticating) are automatically restarted. This ensures WhatsApp connections persist across container restarts without manual intervention.
# First run (builds image, starts container)
docker compose up -d --build
# View startup logs including API key
docker compose logs -f openwa-mini-api
# Restart with a new API_KEY
echo "API_KEY=new-key" > .env
docker compose up -d --force-recreate
# Stop
docker compose downThe container runs as a non-root openwa-mini user. All persistent state (SQLite files, session browser profiles) lives in the openwa-mini-data volume mounted at /app/data.
docker-compose.yml enumerates environment variables explicitly. A variable in .env
only reaches the container if compose references it, which produces three different
behaviours that are easy to mistake for bugs:
| Behaviour | Variables | What happens |
|---|---|---|
| Passed through | API_KEY, BASE_URL, TYPING_*, SESSION_SETTLE_TIMEOUT_MS, QR_TOKEN_*, CORS_ORIGINS, RATE_LIMIT_*, PUPPETEER_HEADLESS, PUPPETEER_ARGS, NODE_ENV, LOG_LEVEL, ENGINE_TYPE, PLUGINS_ENABLED |
Your .env value wins |
| Hardcoded | PORT, DATABASE_TYPE, DATABASE_NAME, SESSION_DATA_PATH, PLUGINS_DIR |
Your .env value is ignored — these are pinned to /app/data paths so state lands in the volume |
| Never referenced | PUPPETEER_EXECUTABLE_PATH |
Never reaches the container. The image sets its own (/usr/bin/chromium) |
Three consequences worth internalising:
PORTdoes nothing under Docker. The container always listens on 2785 internally; the host port isAPI_PORT. SetAPI_PORT=3000, notPORT=3000.- Don't set
PUPPETEER_ARGSfor Docker unless you mean to. It overrides compose's container-tuned defaults, dropping--disable-crashpad,--no-zygote,--disable-gpuand--disable-software-rasterizer. The image ships a crashpad workaround that depends on those. BASE_URLmust be reachable by whatever scans the QR — it builds the scan URL.http://localhost:2785is fine locally, but on a server it needs the external URL.
A minimal .env for a server deployment:
API_PORT=2785 # host port (NOT "PORT")
API_KEY=your-strong-secret-key
NODE_ENV=production
BASE_URL=https://wa.example.com # must be reachable by the scanning browser
QR_TOKEN_SECRET=some-fixed-secret # pin it, or scan links break across restarts
# optional — typing simulation
TYPING_SIMULATION_ENABLED=true
TYPING_WPM=90
TYPING_MAX_MS=3000No PUPPETEER_* lines: Docker supplies Chromium and the correct flags.
openwa-mini/
├── src/
│ ├── main.ts # Bootstrap, Swagger, CORS, validation
│ ├── app.module.ts # Root module (TypeORM ×2, throttler)
│ ├── config/configuration.ts # Typed config from environment variables
│ ├── common/ # Logger, security, transformers, utils
│ ├── core/
│ │ ├── hooks/ # HookManager — event lifecycle hooks
│ │ └── plugins/ # Plugin loader & storage service
│ ├── database/
│ │ ├── data-source.ts # TypeORM CLI data source (for migrations)
│ │ └── migrations/ # SQLite & Postgres migration files
│ ├── engine/
│ │ ├── interfaces/ # IWhatsAppEngine, EngineStatus
│ │ ├── adapters/ # whatsapp-web-js.adapter.ts
│ │ └── types/ # whatsapp-web.js type shims
│ ├── plugins/engines/whatsapp-web-js/ # Built-in engine plugin
│ └── modules/
│ ├── auth/ # API key management (ADMIN / OPERATOR / VIEWER)
│ ├── session/ # Session CRUD, QR flow, auto-reconnect
│ ├── message/ # send-text, send-bulk, batch tracking
│ │ ├── typing-simulator.service.ts # Timing model, serialization, abort
│ │ ├── typing-options.ts # 3-layer config resolution + clamping
│ │ └── typing-chunker.ts # Sentence/word splitter (URL-safe)
│ └── health/ # /health, /health/live, /health/ready
├── data/ # Runtime data — gitignored
│ ├── main.sqlite # Auth database (API keys)
│ ├── openwa-mini.sqlite # Sessions, messages, batches
│ └── sessions/ # whatsapp-web.js LocalAuth state per session
├── .env.minimal # Configuration reference — copy to .env
├── docker-compose.yml # Single-container deployment
└── Dockerfile # Multi-stage build (builder + production)
npm audit reports zero vulnerabilities at every severity. Three overrides entries in package.json keep it
there — each targets a transitive dependency whose parent has not yet released a fix:
"overrides": {
"tar": "^7.5.22",
"@tootallnate/once": "^2.0.1",
"@nestjs/swagger": { "js-yaml": "^5.2.3" }
}| Override | Chain | Why an override rather than an upgrade |
|---|---|---|
tar |
sqlite3 → node-gyp → cacache → tar |
<=7.5.20 is a critical advisory (hardlink path traversal). npm audit fix --force upgrades sqlite3 to 6.x, but TypeORM 0.3.x declares sqlite3: ^5.0.3 as its optional peer — that would leave TypeORM's supported range to patch a build-time dependency |
@tootallnate/once |
sqlite3 → node-gyp → make-fetch-happen → http-proxy-agent → @tootallnate/once |
http-proxy-agent@4 pins "1", and <2.0.1 is vulnerable. Same sqlite3-major problem as above; the v2 API is unchanged |
js-yaml (scoped) |
@nestjs/swagger → js-yaml |
@nestjs/swagger@11.4.6 is already the latest and ships js-yaml@5.2.1, which carries a high DoS advisory (>=5.0.0 <=5.2.1). Deliberately scoped to the swagger subtree — ESLint's config loader uses js-yaml@4.x, and a blanket override would force a major bump on an unrelated subtree |
Everything here is a transitive pin; no direct dependency was upgraded to satisfy the audit.
Verified after the overrides: a clean npm ci builds sqlite3 from source, both SQLite databases open, Swagger
renders at /api/docs, the full test suite passes, and a live WhatsApp send still succeeds. Revisit each entry
when its parent publishes a fix — particularly tar/@tootallnate/once once TypeORM widens its sqlite3 peer
range, which would let the whole node-gyp chain move forward on its own.
whatsapp-web.js is pinned to an exact version (1.34.7) in package.json — deliberately, not by oversight.
It drives WhatsApp Web by injecting code into a real page, so a WhatsApp-side change can break it without any
release on either side. Several of its APIs already fail on current builds, and this project routes around them:
| Library API | Behaviour on current builds | What this project does instead |
|---|---|---|
Client.getChatById() |
Throws DataError: IDBObjectStore (resolves the chat as a model) |
Never resolves chat models; passes the raw …@c.us id |
Chat.sendStateTyping() |
Returns true unconditionally — its page callback never awaits the async helper, so failures vanish |
Calls WWebJS.sendChatstate via pupPage.evaluate with await, and surfaces the error |
Client.sendMessage() |
Delivers, but resolves undefined on LID-addressed chats (…@lid) |
Recovers the real ID from WhatsApp's message store via String(msg.id) |
message_ack / message_create events |
Never fire | Delivery status is polled from the message store instead |
LocalWebCache.persist() |
Throws on null — its manifest-<version>.json regex no longer matches, killing the process at session start |
webVersionCache: { type: 'none' } |
Because the workarounds are calibrated to this exact release, widening the version range can silently reintroduce every one of these failures — mostly as silent no-ops rather than errors. Upgrade deliberately, and re-verify presence and send confirmation against a live session afterwards.
Tested on
2.0.0-alpha.0: it links the device on the phone but never firesauthenticated, so sessions hang atqr_readywhile consuming WhatsApp's 4-linked-device limit. Not usable at time of writing.
| Layer | Technology |
|---|---|
| Runtime | Node.js 22 LTS |
| Framework | NestJS 11.x |
| Language | TypeScript 5.x |
| WhatsApp engine | whatsapp-web.js 1.34.7, pinned exactly (Puppeteer / LocalAuth) |
| Database | SQLite via TypeORM (Postgres also supported for the data DB) |
| Container | Docker — single image, no sidecars |
npm test # unit tests
npm run test:cov # with coverage (thresholds enforced in package.json)
npm run lint # eslint --fix
npm run build # tsc via nest build222 tests across 13 suites. Two conventions worth knowing before adding more:
- Tests never sleep in real time.
TypingSimulatorServicetakes its clock and RNG by injection, so timing behaviour is asserted against a fake clock. The whole suite runs in ~2 s. A test that actually waits will be rejected in review. npm run lintmutates source (eslint --fix), and has been observed stripping type assertions thattscneeds. Always runnpm run buildafter linting, not only before.
Issues and pull requests are welcome at github.com/3bsalam-1/OpenWA-mini.
Before opening a PR:
npm run lint && npm run build && npm test— all three green.- Add tests for behaviour changes; match the injected-clock pattern above.
- If you touch the whatsapp-web.js adapter, read WhatsApp Web compatibility first — several library APIs fail silently on current builds, and a change that looks correct can be a no-op in production. Verify against a live session, not only the test suite.
This project is a derivative of OpenWA by Yudhi Armyndharis, reworked into a minimal OTP-focused gateway. The original MIT copyright is retained in LICENSE alongside the current one.
| Original author | Yudhi Armyndharis |
| Contributors | Yosef Chai |
| Current maintainer | Ahmed Mohamed |
Built on whatsapp-web.js by Pedro S. Lopez.
MIT — free for personal and commercial use.
Copyright (c) 2026 Yudhi Armyndharis and OpenWA Contributors Copyright (c) 2026 Ahmed Mohamed and ExGate Contributors