Skip to content

Repository files navigation

OpenWA Mini — Minimal WhatsApp OTP Gateway

Version License: MIT Node NestJS Tests npm audit

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.


Features

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: sentdeliveredread
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

Quick Start

Docker (recommended)

# 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 -d
API:   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" -A1

Local development

Requires Node.js 20+ (22 LTS recommended). NestJS 11 relies on a global crypto, so older runtimes fail at boot with ReferenceError: 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:dev

API Key

The API key controls access to all protected endpoints. There are two ways to manage it.

Set a fixed key in .env

Add API_KEY to your .env file (or Docker Compose environment):

API_KEY=your-strong-secret-key

On 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.

Auto-generated key (no API_KEY set)

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.

Creating additional keys via API

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)

Configuration

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

All environment variables

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.sqlite stores API keys and auth data (path is fixed, not configurable), and data/openwa-mini.sqlite stores sessions, messages, and batches (path set via DATABASE_NAME). Setting DATABASE_TYPE=postgres moves only the data DB to Postgres; the auth DB always stays on SQLite.


Rate Limiting

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.


API Reference

Base URL: http://localhost:2785/api (every route below is relative to /api). Interactive docs live at /api/docs.

Conventions

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: adminoperatorviewer. 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.

Sessions

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

Messages

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).

Auth / API Keys

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

Health

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

Usage Examples

KEY="your-secret-key"   # whatever you set in API_KEY
BASE="http://localhost:2785/api"

1. Create and authenticate a session

# 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}"

2. Send a single OTP

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 }

3. Bulk send with variable substitution

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"
}

4. Poll batch status

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": "" }
  ]
}

Bulk Send Options

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.


Typing Simulation

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.

How the duration is computed

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].

Configuration

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.

Per-request overrides

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.

Response shape

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.

Worked example

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
}

Checking whether a message was actually delivered

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.

Recommended settings for OTP

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.

⚠️ Latency and OTP delivery

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.

Plugin hooks

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.


Session Lifecycle

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}}'

Auto-start on Container Restart

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.


Docker

# 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 down

The 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.

Environment variables under Docker — read this first

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:

  • PORT does nothing under Docker. The container always listens on 2785 internally; the host port is API_PORT. Set API_PORT=3000, not PORT=3000.
  • Don't set PUPPETEER_ARGS for Docker unless you mean to. It overrides compose's container-tuned defaults, dropping --disable-crashpad, --no-zygote, --disable-gpu and --disable-software-rasterizer. The image ships a crashpad workaround that depends on those.
  • BASE_URL must be reachable by whatever scans the QR — it builds the scan URL. http://localhost:2785 is 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=3000

No PUPPETEER_* lines: Docker supplies Chromium and the correct flags.


Project Structure

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)

Dependency overrides

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 compatibility

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 fires authenticated, so sessions hang at qr_ready while consuming WhatsApp's 4-linked-device limit. Not usable at time of writing.


Tech Stack

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

Testing

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 build

222 tests across 13 suites. Two conventions worth knowing before adding more:

  • Tests never sleep in real time. TypingSimulatorService takes 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 lint mutates source (eslint --fix), and has been observed stripping type assertions that tsc needs. Always run npm run build after linting, not only before.

Contributing

Issues and pull requests are welcome at github.com/3bsalam-1/OpenWA-mini.

Before opening a PR:

  1. npm run lint && npm run build && npm test — all three green.
  2. Add tests for behaviour changes; match the injected-clock pattern above.
  3. 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.

Credits

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.


License

MIT — free for personal and commercial use.

Copyright (c) 2026 Yudhi Armyndharis and OpenWA Contributors Copyright (c) 2026 Ahmed Mohamed and ExGate Contributors

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages