Configure OpenWA with environment variables
Set up OpenWA's database, storage, cache, and WhatsApp engine, then point it at the backends you actually run. Every setting is an environment variable: the defaults run with zero configuration (SQLite + local filesystem + no cache), so you change only what you need.
- OpenWA installed and able to start — see Installation.
- Shell access to the machine (or container) running OpenWA, to edit
.envor pass env vars.
How configuration is loaded
OpenWA reads configuration from environment variables, with .env.example as the single source of truth for the full list. Copy it and edit your copy:
cp .env.example .env
The server validates the file at boot and refuses to start on an invalid value (for example, an unknown ENGINE_TYPE, or empty credentials a selected backend requires). Changing a value takes effect on the next restart — there is no hot reload, and adapters cannot be swapped without a restart.
In Docker, the same variables are passed through docker-compose.yml. Names and meanings are identical; only the delivery mechanism differs.
Numeric variables accept plain decimal digits only: an exponent or hex spelling (1e6, 0x100) fails boot instead of configuring a different value than you intended. For knobs whose 0 is a documented opt-out (for example MESSAGE_REAPER_INTERVAL_MS), a blank or whitespace-only value — such as a ${KEY:-} forward from a compose file — means "use the default"; an explicit 0 selects the opt-out.
REDIS_ENABLED is strictly validated: only true and false are accepted. A non-canonical value (a typo such as ture) fails boot instead of silently downgrading to in-memory.
Select your pluggable adapters
OpenWA's infrastructure backends are swappable by configuration alone — no code changes. Four boundaries are independently selectable:
| Boundary | Variable | Options | Default |
|---|---|---|---|
| Database | DATABASE_TYPE | sqlite, postgres | sqlite |
| Storage | STORAGE_TYPE | local, s3 | local |
| Cache | REDIS_ENABLED | true, false | false |
| WhatsApp engine | ENGINE_TYPE | whatsapp-web.js, baileys | whatsapp-web.js |
The main database (API keys and audit logs) is always SQLite and is not configurable — only the data connection above is pluggable. See Database design terminology for the dual-database split.
Database — SQLite or PostgreSQL
SQLite is the zero-config default, stored as a file under ./data. It suits a personal bot or a handful of sessions but has a single writer. For higher write concurrency or many sessions, switch to PostgreSQL:
DATABASE_TYPE=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=openwa
DATABASE_USERNAME=openwa
DATABASE_PASSWORD=your-strong-password
For managed PostgreSQL (Supabase, Heroku, Render, Railway), enable TLS:
DATABASE_SSL=true
# Only if the provider uses a self-signed certificate:
# DATABASE_SSL_REJECT_UNAUTHORIZED=false
With DATABASE_TYPE=postgres, a production build (NODE_ENV=production) refuses to start if DATABASE_PASSWORD is empty or a known placeholder. Set a strong, unique value.
Storage — local filesystem or S3/MinIO
Media files default to the local filesystem under ./data/media. To use S3 — or any S3-compatible service such as MinIO — set:
STORAGE_TYPE=s3
S3_ENDPOINT=http://localhost:9000 # point at MinIO, or your S3 endpoint
S3_BUCKET=openwa
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are the canonical names — what the app reads first and what the dashboard writes. The older S3_ACCESS_KEY / S3_SECRET_KEY spellings are still accepted as a fallback for existing setups, and are read only when the canonical names are unset. The production placeholder-credential check reports the legacy spelling in its error message whichever pair you set.
MinIO is not a separate type — it is the s3 backend. The client always uses path-style addressing, which MinIO requires and AWS S3 accepts. As with PostgreSQL, a production build refuses to start with empty or placeholder S3 credentials.
Cache — Redis (optional)
Caching is off by default, and there is no in-process cache: "no cache configured" means no cache. The cache layer fails open — when Redis is disabled or unreachable, reads return nothing and writes are skipped, so the app keeps serving from its source of truth (the database and engine). Caching is a pure optimization, never the source of truth.
Enable Redis with:
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASSWORD=your-redis-password
Engine — whatsapp-web.js or Baileys
OpenWA ships two WhatsApp engines. whatsapp-web.js (the default) drives a headless Chromium and is heavier (hundreds of MB per session). baileys is browser-free, far lighter, and supports phone-number pairing in addition to QR. Pin one explicitly:
ENGINE_TYPE=baileys
If ENGINE_TYPE is left unset, the active engine is chosen in the dashboard (Infrastructure → Engine), defaulting to whatsapp-web.js. A value set in the environment always wins over the dashboard selection.
Switching the engine does not migrate an existing authenticated session — each engine stores its own credentials, so a session connected under one engine must scan a fresh QR after you switch. Connect a session in Connect a session.
Key environment variables
These names are taken verbatim from .env.example. For the full list, including media size limits, observability, and MCP, read the comments in .env.example itself.
Core
| Variable | Default | Description |
|---|---|---|
NODE_ENV | production | Runtime mode; production enforces the security checks noted above |
API_PORT | 2785 | Port the API and dashboard listen on |
SERVE_DASHBOARD | true | Serve the bundled dashboard from the API process. Only the exact string false turns it off — startup then logs Dashboard: serving disabled (SERVE_DASHBOARD=false); API only and the process serves the API alone. Validated as a strict boolean at boot: any other value fails startup with SERVE_DASHBOARD must be "true" or "false". The bundled docker-compose.yml already forwards this key, so setting it in .env is enough on that path. Listed in .env.example since v0.17.0 |
LOG_LEVEL | info | One of error, warn, info, debug |
AUTO_START_SESSIONS | false | Auto-start previously authenticated sessions on boot. The app-level default stays false, but the quick-start docker-compose.dev.yml sets it to true (v0.10.0+), so on that path authenticated sessions come back by themselves after a container restart |
STATUS_SEED_ON_READY | false | Backfill active statuses that predate a connection as soon as a session reaches READY. Off by default since v0.14.0: the eager status@broadcast read could make WhatsApp revoke some freshly paired whatsapp-web.js companions at their first scheduled Web reload. Live status events are unaffected either way; flip to true to restore the pre-0.14.0 backfill (v0.14.0+, breaking) |
CORS_ORIGINS | * | Comma-separated allowed origins; the * wildcard is refused in production |
REQUEST_TIMEOUT_MS | 300000 | HTTP server request timeout in milliseconds. Raise it when a reverse proxy in front holds connections longer |
HEADERS_TIMEOUT_MS | 65000 | HTTP headers timeout in milliseconds |
KEEPALIVE_TIMEOUT_MS | 5000 | HTTP keep-alive timeout in milliseconds |
Adapters
| Variable | Default | Description |
|---|---|---|
ENGINE_TYPE | unset | whatsapp-web.js or baileys; dashboard chooses when unset |
SESSION_DATA_PATH | ./data/sessions | Where whatsapp-web.js session auth data is stored |
BAILEYS_AUTH_DIR | ./data/baileys | Where Baileys session auth data is stored |
BAILEYS_BROWSER_NAME | OpenWA | Device name shown in WhatsApp → Settings → Linked Devices. Baileys engine only; applies to new pairings — existing sessions keep the name they paired with until re-linked |
LID_MAPPING_CACHE_MAX | 5000 | LRU cap on the in-memory @lid→phone mirror used for synchronous resolution on the dispatch hot path; a miss falls back to engine re-resolution. 0 restores the legacy unbounded behavior |
BAILEYS_SESSION_STORE_MAX_ENTRIES | 5000 | Per-map LRU cap on the Baileys session store's in-memory maps (contacts, chats, lid mappings — all fed by peer traffic); a miss falls back to the persisted table or a re-resolution, so eviction never loses data. 0 = unbounded |
BAILEYS_MESSAGE_STORE_LIMIT | 5000 | Per-session cap on Baileys message rows kept in the database: the newest N survive and older rows are pruned after each write. A non-positive or non-numeric value falls back to the default |
BAILEYS_MARK_ONLINE_ON_CONNECT | true | Whether a Baileys session marks the account online when it connects. Set false to keep phone push notifications alive while a gateway is connected (v0.13.0+). The default preserves prior behavior |
BAILEYS_WA_VERSION | unset | Pins the WhatsApp Web protocol version the Baileys engine links with, skipping every remote tier of the version resolver (v0.22.0+). Unset, the resolver tries the two library endpoints (web.whatsapp.com's service worker, then the upstream repository), then the last known-good version cached on disk in the auth dir, then a built-in default; each remote tier is timeout-bounded, rides the session proxy, and a stale answer is neither cached nor used |
WWEBJS_ONBOARDING_CONTINUE_LABELS | (empty) | Extra comma-separated labels for the whatsapp-web.js onboarding-modal auto-dismiss: a deployment whose modal is localized (not English) adds its Continue-button label(s) here, and the watcher matches them without the English heading check (v0.12.1+) |
WWEBJS_WEB_VERSION_REMOTE_PATH | https://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/{version}.html | URL template the pinned WhatsApp Web build's HTML is fetched from, with {version} as the placeholder. Point it at an operator-controlled mirror instead of the public third-party registry. whatsapp-web.js engine only |
DATABASE_TYPE | sqlite | sqlite or postgres |
DATABASE_HOST | localhost | PostgreSQL host (ignored for SQLite) |
DATABASE_PORT | 5432 | PostgreSQL port |
DATABASE_NAME | openwa | PostgreSQL database name |
DATABASE_USERNAME | openwa | PostgreSQL user |
DATABASE_PASSWORD | (empty) | Required for PostgreSQL; no default shipped |
DATABASE_SSL | false | Enable TLS for managed PostgreSQL |
DATABASE_POOL_SIZE | 10 | PostgreSQL connection pool size (ignored for SQLite) |
MAIN_DATABASE_NAME | ./data/main.sqlite | SQLite file backing the main (auth and audit) connection, which is always SQLite. Must not resolve to the same file as DATABASE_NAME |
STORAGE_TYPE | local | local or s3 |
STORAGE_LOCAL_PATH | ./data/media | Local media directory |
STORAGE_LIST_MAX_FILES | 100000 | Cap on how many files one local-storage listing call enumerates, so a large media directory cannot be walked into memory in a single request. A non-positive or non-numeric value falls back to the default; streaming reads are unaffected — only the listing call is truncated |
S3_ENDPOINT | http://localhost:9000 | S3 / MinIO endpoint |
S3_BUCKET | openwa | S3 / MinIO bucket |
S3_REGION | us-east-1 | S3 region |
S3_ACCESS_KEY_ID | (empty) | S3 / MinIO access key; required for s3. S3_ACCESS_KEY is a legacy alias, read only when this is unset |
S3_SECRET_ACCESS_KEY | (empty) | S3 / MinIO secret key; required for s3. S3_SECRET_KEY is a legacy alias, read only when this is unset |
S3_REPROBE_INTERVAL_MS | 60000 | Re-probe cadence for an S3 bucket that was unreachable at boot (media storage falls back to the local dir until it recovers). Recovery is one-way — local→S3 only, so a transient flake cannot drop a healthy deployment to local; while S3 is active, listings and totals also cover the local fallback dir, and deletes go to both backends |
STORAGE_EXPORT_SWEEP_MAX_AGE_MS | 86400000 | Boot sweep deletes orphaned storage-export-* archives older than this (24 hours) |
REDIS_ENABLED | false | Enable Redis for the queue, caching, AND cross-replica WebSocket event fan-out (Socket.IO Redis adapter). Multi-replica deployments need this so a WS client on one replica receives events raised on another; single-node can leave it off (WS fan-out added in v0.14.0+) |
REDIS_HOST | localhost | Redis host |
REDIS_PORT | 6379 | Redis port |
NODE_ID | hostname | Identifies the process owning a session's engine in a multi-process deployment. Defaults to the hostname and must be stable across restarts so a returning process reclaims its sessions. A single-process deployment needs none of the multi-node vars (v0.14.0+) |
NODE_URL | (empty) | Where THIS node answers HTTP for its peers, e.g. http://10.0.0.5:2785. Set on EVERY node to enable forwarding of session-scoped requests to the owning node. Empty disables forwarding, which is the single-node default (v0.14.0+) |
SESSION_LEASE_TTL_MS | 60000 | How long a node's claim on a session survives unrenewed (60 seconds); also the worst-case failover delay after an unclean exit (v0.14.0+) |
SESSION_LEASE_HEARTBEAT_MS | 20000 | Lease renewal cadence (20 seconds). Must be strictly less than half SESSION_LEASE_TTL_MS, so one late or failed renewal still lands inside the lease — boot fails otherwise (v0.14.0+) |
SESSION_TAKEOVER_SWEEP_MS | 30000 | How often a node adopts sessions whose holder's lease lapsed (crashed peer or this node's previous container identity). The takeover follows AUTO_START_SESSIONS (v0.14.0+) |
Security, webhooks, and rate limiting
| Variable | Default | Description |
|---|---|---|
API_MASTER_KEY | (empty) | Your own master API key; see API key authentication. In production a set key shorter than 32 characters refuses boot — unset stays allowed (v0.19.0+, breaking) |
BOOTSTRAP_KEY_FILE | ./data/.api-key | Where the admin key generated on first boot is written, and read back from on later boots. Override it when the data directory is read-only, or when the key must land on a mounted secret path |
API_KEY_PEPPER | unset | Server-side pepper for HMAC key hashing; recommended in production |
AUDIT_RETENTION_DAYS | 90 | Days of audit log kept; older rows are pruned once at startup and every 24 hours after. 0 or any negative value disables pruning entirely and keeps every row. Validated at boot as a plain integer of ANY sign — signed rather than positive precisely because 0 and negatives are documented off switches. 30d, 90.5, +90, and a non-numeric word such as ninety now refuse to boot with AUDIT_RETENTION_DAYS must be an integer (got "…"), where before they started and silently became 30, 90, 90, and the 90-day default. The failure is a boot refusal, not a fallback to the default (v0.17.0+, breaking) |
ALLOW_UNSIGNED_INGRESS | false | Required opt-in to load plugin ingress routes declaring signature.scheme: 'none' — such a route is an unauthenticated public endpoint, so the loader rejects it without this flag |
DOCKER_HOST | unset (/var/run/docker.sock) | Docker daemon endpoint used to manage the built-in datastore containers (POSTGRES_BUILTIN, REDIS_BUILTIN, MINIO_BUILTIN). OpenWA itself recognises only the exact tcp://host:port form; every other value is handed to the Docker client, which parses DOCKER_HOST again on its own — unix:// and npipe:// land back on the local socket, but any host-shaped value (https://host:2376, ssh://user@host, tcp://host:2375/path, even an unparseable one) still connects to THAT host. A typo here is a different daemon, not a fallback. The bundled docker-compose.yml hard-codes its own socket proxy and forwards no value from .env, so setting this affects a non-compose run only |
WEBHOOK_TIMEOUT | 10000 | Webhook delivery timeout in milliseconds |
WEBHOOK_RETRY_DELAY | 5000 | Delay between webhook retries in milliseconds |
WEBHOOK_MAX_PER_SESSION | 16 | Max webhooks registered per session; registrations at or over the cap get 400 (existing ones are grandfathered). 0 = unlimited |
AUTOMATION_MAX_PER_SESSION | 32 | Max autoreply rules per session; new rules at or over the cap get 400 (existing ones are grandfathered). 0 = unlimited. Every inbound message is evaluated against every rule of its session (v0.14.0+) |
WEBHOOK_MAX_PAYLOAD_BYTES | 1048576 | Max serialized webhook body (1 MiB); over-cap payloads shed inline media first, then are recorded undelivered. Positive only — 0 fails boot |
WEBHOOK_MEDIA_INLINE_MAX_BYTES | 1048576 | Decoded-byte cap for inline base64 media in webhook payloads and (since v0.21.0) in the message.received / message.sent Socket.IO broadcasts; larger media arrives as the { omitted: true, sizeBytes } marker. 0 = never inline media |
WEBHOOK_SHUTDOWN_DRAIN_MS | 5000 | How long shutdown waits for in-flight webhook deliveries to finish; a startup WARN fires when this is shorter than WEBHOOK_TIMEOUT |
WEBHOOK_WORKER_CONCURRENCY | 10 | Queued webhook-delivery workers (only when QUEUE_ENABLED=true) |
WEBHOOK_FAILURE_RETENTION_DAYS | 90 | Days to keep the delivery-failure rows behind GET /api/webhooks/delivery-failures; pruned once at startup and daily thereafter. 0 or less disables the prune and keeps every row |
WEBHOOK_OUTBOX_RETENTION_DAYS | 7 | Days to keep the settled outbound-delivery records that make a webhook delivery survive a hard crash; pruned once at startup and daily thereafter. A record that can still be replayed is never pruned on age, and a non-positive value falls back to 7 rather than letting the table grow without bound (v0.23.0+) |
WEBHOOK_SSRF_PROTECT | true | Block webhook deliveries to internal/reserved addresses. Since v0.20.0 the opt-out (false) no longer follows redirects: see WEBHOOK_SSRF_REDIRECTS (breaking) |
WEBHOOK_SSRF_REDIRECTS | false | Redirect handling when WEBHOOK_SSRF_PROTECT=false: a receiver answering 3xx fails the delivery loudly; set true only for a receiver that legitimately redirects. Before v0.20.0 the opt-out followed redirects (v0.20.0+, breaking) |
SSRF_DNS_TIMEOUT_MS | 10000 | DNS resolution deadline in milliseconds inside the SSRF guard |
INGRESS_MAX_ATTEMPTS | 3 | Queued ingress delivery attempts before an event is dead-lettered (min 1) |
INGRESS_RETRY_DELAY_MS | 5000 | Base for exponential backoff between queued ingress attempts, in milliseconds |
INGRESS_WORKER_CONCURRENCY | 10 | Queued ingress workers (only when QUEUE_ENABLED=true) |
INGRESS_RECONCILE_INTERVAL_MS | 60000 | Sweep cadence for replaying persisted ingress deliveries stuck mid-delivery; a delivery is retired to the DLQ after five attempts. 0 disables |
INGRESS_DEDUP_RETENTION_DAYS | 7 | Days to keep ingress dedup rows; <=0 does not disable this prune — the value falls back to 7 |
INGRESS_RETENTION_DAYS | 90 | Days to keep ingress DLQ rows; <=0 disables that prune |
INGRESS_TIMESTAMP_TOLERANCE_SEC | 300 | Host-wide replay-tolerance fallback for ingress routes that declare timestampHeader without a per-route toleranceSec |
RATE_LIMIT_MEDIUM_LIMIT | 100 | Max requests per window (the enforced tier) |
RATE_LIMIT_MEDIUM_TTL | 60000 | Rate-limit window in milliseconds |
SEND_PACING_ENABLED | false | Opt-in outbound send pacing (anti-ban). When on, a session may send at most its warm-up allowance per UTC day, and a run of consecutive send failures that reached WhatsApp pauses it. Refusals are HTTP 429 with code: 'SEND_PACING_LIMITED' and retryAfterSeconds, which is what tells them apart from the global rate limiter's own 429 (v0.14.0+) |
SEND_PACING_WARMUP_SCHEDULE | 20,40,80,160,320,640,1000 | Per-day send allowance by session age in days: the first entry is the session's first day, the last applies to every day after. Starts small because a brand-new account that sends at volume is the pattern that gets numbers banned. A malformed entry falls the whole schedule back to the default rather than sending more than you asked for. Counts come from the messages table, so they survive a restart |
SEND_PACING_COLD_DAILY_CAP | 5,10,20,40,60,80,100 | Bounds COLD REACHOUTS specifically: the first message to a chat this account has no history with in either direction. Starting conversations with strangers is what WhatsApp actually punishes, while replies to someone who wrote first are never counted. Same by-age shape as the schedule; a single number is a flat cap; empty disables the rule |
SEND_PACING_BREAKER_THRESHOLD | 5 | Consecutive send failures that reached WhatsApp which trip the pacing breaker, pausing sends on the session for SEND_PACING_BREAKER_COOLDOWN_MS. Client-fault and engine-state errors raised inside the send call do not accumulate, so a client sending bad requests cannot 429 a healthy session |
SEND_PACING_BREAKER_COOLDOWN_MS | 900000 | How long the pacing breaker pauses sends after SEND_PACING_BREAKER_THRESHOLD consecutive failures reached WhatsApp. 15 minutes |
ENABLE_SWAGGER | unset | Serve the API docs and Swagger UI at /api/docs. .env.example ships this line commented out since v0.16.0, so the effective default is the unset behaviour: on outside production, off under NODE_ENV=production. Only the exact strings true and false have effect — set ENABLE_SWAGGER=true to serve it in production, or false to force it off everywhere (v0.16.0+) |
VALIDATION_ERROR_DETAIL | unset | Return field-level messages on a 400 instead of a generic one. Same exact-string contract as ENABLE_SWAGGER: true forces it on, false forces it off, and when unset it is on outside production and off under NODE_ENV=production, so a rejected request there does not reflect the DTO shape back to the caller. Set it to true to debug an SDK or integration against a production instance without flipping NODE_ENV |
BODY_SIZE_LIMIT | 25mb | Max request body size (base64 media rides in the JSON body) |
INFLIGHT_BODY_BUDGET_BYTES | 4 × BODY_SIZE_LIMIT | Aggregate cap on request-body bytes buffered across all connections; over-budget requests get a transient 503 with Retry-After, and a headers-then-silent sender is dropped after 15 seconds. Since v0.21.0 each client IP also has its own share of the budget (half the aggregate by default, resolved through TRUSTED_PROXIES): one IP is refused past its share while the gateway still has aggregate room, and behind a reverse proxy with no TRUSTED_PROXIES every caller shares the proxy address's single share |
TEMPLATE_RENDER_MAX_CHARS | 65536 | Cap on the rendered text of a send-template request after variable substitution; over-cap renders are rejected with 400, never silently truncated |
CHAT_HISTORY_MEDIA_BUDGET_BYTES | 26214400 | Aggregate inline-media budget (25 MiB) for one getChatHistory(includeMedia=true) call; media past the budget returns the omitted marker |
INBOUND_MEDIA_CONCURRENCY | 4 | How many inbound media items download at once; each concurrent download can hold up to MEDIA_DOWNLOAD_MAX_BYTES of memory |
STATUS_MEDIA_MAX_BYTES | 10485760 | Per-file cap (10 MiB) on status (Story) media; oversized media is stored omitted (mediaOmitted=true), not rejected |
MEDIA_CONVERSION_ENABLED | false | Opt-in server-side media conversion via ffmpeg through POST /api/sessions/:sessionId/media/convert/voice and .../video. Transcodes caller-supplied media into what WhatsApp clients actually play (audio to Ogg/Opus, video to MP4); nothing is converted implicitly. The official Docker image ships ffmpeg, a source install needs the binary on PATH or the endpoints answer 503 (v0.14.0+) |
FFMPEG_PATH | ffmpeg | Absolute path to the ffmpeg binary, for a setup where it lives outside PATH. Ignored unless MEDIA_CONVERSION_ENABLED=true |
MEDIA_CONVERSION_TIMEOUT_MS | 60000 | Kill one conversion after this long. 60 seconds. A non-positive value fails boot |
MEDIA_CONVERSION_MAX_OUTPUT_BYTES | 52428800 | Cap on the CONVERTED bytes (50 MiB); an oversized output is rejected. A non-positive value fails boot |
MEDIA_CONVERSION_CONCURRENCY | 2 | Max concurrent ffmpeg processes; a short queue absorbs bursts, beyond it the endpoint answers 503 instead of stacking processes |
CHAT_MEDIA_ARCHIVE_ENABLED | false | Opt-in chat-media archiving. When on, each message's media is also written to the file store (local or S3) and served by GET /sessions/:id/messages/:chatId/:messageId/media, so it stays retrievable after delivery. The inline base64 copy on the message row is kept, so archiving roughly doubles storage for media under the cap (v0.14.0+). Since v0.16.0 the retention purge and the orphan sweep run even while this is false — previously both stopped with the flag, so a deployment that archived and later switched off kept its unreferenced files forever; now they are swept after the grace window below (v0.16.0+, behaviour change) |
CHAT_MEDIA_ARCHIVE_OUTBOUND | false | Sub-flag of CHAT_MEDIA_ARCHIVE_ENABLED. When on, media sent by this account gets the same durable file copy, S3 portability, and TTL retention that inbound media already has. Off by default because outbound media was never archived before. Requires CHAT_MEDIA_ARCHIVE_ENABLED=true to take effect (v0.15.0+) |
CHAT_MEDIA_ARCHIVE_MAX_BYTES | 26214400 | Per-file cap on archived chat media (25 MiB). Media above it is simply not archived; the message row and its inline copy are unaffected |
CHAT_MEDIA_ARCHIVE_TTL_DAYS | 0 | How long archived files are kept, in days. 0 = forever. Expiry clears the FILE and the row's media columns; the message row itself is never deleted by this. The retention purge is scheduled regardless of CHAT_MEDIA_ARCHIVE_ENABLED, but stays a no-op while this is 0 |
CHAT_MEDIA_ORPHAN_SWEEP_INTERVAL_MS | 3600000 | How often the orphan sweep re-lists files under the chat-media/ prefix to find ones no message row references — crash leftovers, or files left behind by archiving that was later switched off (1 hour) |
CHAT_MEDIA_ORPHAN_GRACE_MS | 3600000 | How long such a file must have been seen unreferenced before the sweep deletes it (1 hour). First-seen timestamps live in memory only, so a restart restarts the clock and the first pass after boot deletes nothing; the sweep is scoped strictly to the chat-media/ prefix and never touches status media |
BULK_MAX_CONCURRENT_BATCHES | 50 | Cap on concurrently running bulk-send batches. 0 = unlimited |
MESSAGE_REAPER_INTERVAL_MS | 600000 | Sweep cadence (10 minutes) of the reaper that marks outbound rows stuck PENDING after a crash as FAILED with a reapedAt marker. 0 disables |
MESSAGE_REAPER_GRACE_MS | 3600000 | Only PENDING rows older than this (1 hour) are reaped |
MESSAGE_REAPER_BATCH_SIZE | 50 | Max rows reaped per sweep |
STATS_CACHE_TTL_MS | 30000 | In-process memoization TTL for dashboard stats aggregates — stats can lag by up to this. 0 disables the memo |
PLUGIN_STATE_DIR | ./data | Root for the plugin registry and per-plugin storage, the one piece of state with no path knob before v0.23.0, so a test run rewrote the developer's own registry. It moves plugin state only: the databases, sessions, media and auth dirs each carry their own knob and none of them follow this one (distinct from PLUGINS_DIR, where plugin packages are installed). Moving it does not carry existing state across: copy registry.json and every <plugin id>/key-*.json from the old root's plugins/ into the new one's, or plugins come back enabled with their persisted ctx.storage gone (v0.23.0+) |
PLUGIN_DOWNLOAD_MAX_BYTES | 5242880 | Cap (5 MiB) on a plugin .zip fetched by install-from-URL |
PLUGIN_INSTALL_REQUIRE_PIN | unset | Require a #sha256=<64 hex> integrity pin on every plugin install from a URL. Unset means production-only (NODE_ENV=production); true forces it everywhere, false lifts it. A plain-http: URL needs the pin in every environment regardless (v0.20.0+, breaking for production installs without a pin) |
PLUGIN_CAP_TIMEOUT_MS | 30000 | Timeout for one sandboxed plugin capability call; a wedged call is failed and its in-flight slot freed |
PLUGIN_STORAGE_MAX_BYTES | 52428800 | Per-plugin storage quota (50 MiB); ctx.storage writes beyond it are rejected |
PLUGIN_CATALOG_URL | https://raw.githubusercontent.com/rmyndharis/OpenWA-plugins/main/plugins.json | Catalog the plugin browser reads its list of installable packages from. Point it at your own published catalog to install from a private index. The fetch goes through the SSRF guard, so an internal host must also be named in SSRF_ALLOWED_HOSTS or the request is refused — a private index on a non-public address does not work on this key alone |
.env for ENABLE_SWAGGERUp to v0.15.0, .env.example shipped ENABLE_SWAGGER=true uncommented alongside NODE_ENV=production, so cp .env.example .env pinned the opt-in that the production default exists to withhold. The /api/docs mount sits outside the API-key guard, so that served the OpenAPI schema and the exact running version to anyone who could reach the port. v0.16.0 commented the line out in the template, but that fix is not retroactive — a .env already on disk still carries the line. Bare-metal operators who copied the template at v0.15.0 or earlier should comment the line out (production then defaults to off) or set ENABLE_SWAGGER=false to force it off everywhere. Docker Compose and Helm deployments are unaffected: neither forwards the variable, so both resolve to the production default.
Search
| Variable | Default | Description |
|---|---|---|
SEARCH_ENABLED | true | Mount the global message-search module and its /search route. false omits the module entirely, so the route answers 404 |
SEARCH_PROVIDER | auto | Which provider backs /search: auto selects the built-in database full-text provider (PostgreSQL tsvector/GIN, SQLite FTS5) at runtime, builtin-fts pins that provider explicitly, and none keeps the module and route mounted but registers no provider, so /search answers 501. Enum-validated at boot against exactly those three values on the raw string — a stray space (auto ) is rejected with SEARCH_PROVIDER must be one of: auto, builtin-fts, none. Shipped uncommented in .env.example, so copying the file pins it |
SEARCH_LIMIT_MAX | 100 | Hard cap on the limit query parameter of /search. Shipped uncommented in .env.example, so copying the file pins it |
Verify your configuration
After editing .env, restart OpenWA and confirm the API is up. The base URL in local development is http://localhost:2785/api (the /api global prefix; in production it sits behind your domain and TLS).
Start with GET /api/health. This endpoint is public — it needs no API key, so you can run it before you have located your key:
curl http://localhost:2785/api/health
A healthy instance returns 200 OK with three fields — though the version field is disclosed only to requests carrying a valid API key (v0.19.0+):
{
"status": "ok",
"timestamp": "2026-06-26T12:00:00.000Z",
"version": "0.23.1"
}
Once that succeeds, confirm your API key works against an authenticated endpoint. GET /api/sessions lists your sessions and requires the X-API-Key header:
curl http://localhost:2785/api/sessions \
-H "X-API-Key: YOUR_API_KEY"
Replace YOUR_API_KEY with the key OpenWA printed in its startup banner (or one you set via API_MASTER_KEY). A 200 OK confirms the key is accepted; a 401 Unauthorized means it is missing or wrong. See API key authentication below.
API key authentication
Every API route requires a key in the X-API-Key header. Keys carry a role — admin, operator, or viewer — and can be scoped to specific sessions or IP ranges.
On first boot, OpenWA generates a random admin key and prints it in the startup banner (it is also written to data/.api-key). To supply your own instead, set:
API_MASTER_KEY=your-long-random-secret
For production, also set API_KEY_PEPPER to switch key hashing from SHA-256 to HMAC-SHA256. Changing the pepper invalidates all existing key hashes, so set it before issuing keys and re-issue any keys created earlier.
See Authentication for roles, scoping, and key lifecycle.
Webhooks
OpenWA delivers session and message events to your endpoints as HTTP POSTs, optionally signed with an HMAC secret so you can verify authenticity. Tune delivery with WEBHOOK_TIMEOUT and WEBHOOK_RETRY_DELAY. Retry attempts are not an environment variable: retryCount is a field on each webhook record, defaulting to 3 and settable when you create or update the webhook, so two webhooks on the same instance can retry a different number of times. Outbound SSRF protection is on by default (WEBHOOK_SSRF_PROTECT): webhook URLs that resolve to loopback, private, or link-local ranges are refused at registration and at delivery. Since v0.20.0 a name in SSRF_ALLOWED_HOSTS also resolves at registration, so a host that cannot resolve yet fails the create/update with a 400, and each delivery pins its connection to the addresses the name resolves to for that delivery, closing the window where a name re-resolves to a different address between the check and the connect. With the guard off, deliveries no longer follow redirects (WEBHOOK_SSRF_REDIRECTS).
See Webhooks for the event catalog, payload shapes, and signature verification.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Server exits at boot with a credentials error | DATABASE_TYPE=postgres or STORAGE_TYPE=s3 with an empty or placeholder secret under NODE_ENV=production, or (v0.19.0+) an API_MASTER_KEY shorter than 32 characters | Set a strong, unique DATABASE_PASSWORD or S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY, and a master key of at least 32 characters; the error text names the legacy S3_ACCESS_KEY/S3_SECRET_KEY spelling whichever pair you set |
Server rejects ENGINE_TYPE at boot | Value is not whatsapp-web.js or baileys | Use one of the two valid values, or leave it unset to choose from the dashboard |
Server exits at boot naming AUDIT_RETENTION_DAYS (v0.17.0+) | The value is not a plain integer — 30d, 90.5, +90, or a word such as ninety. Earlier versions accepted these and silently used 30, 90, 90, or the default | Set a plain integer such as 90, or 0 to disable pruning; or leave the knob unset |
| Browser blocks dashboard requests in production | CORS_ORIGINS=* is refused in production | Set explicit origins, e.g. CORS_ORIGINS=https://dashboard.yourdomain.com |
| Cache appears to do nothing | REDIS_ENABLED=false, or Redis is unreachable | Caching is optional and fails open; enable Redis or accept source-of-truth reads |
401 Unauthorized on every request | Missing or wrong X-API-Key | Send the key from the startup banner, or the one you set via API_MASTER_KEY |
| Config change had no effect | Values load only at boot | Restart OpenWA; adapters cannot be hot-swapped |
Next steps
- Authentication — API key roles, scoping, and management.
- Deployment — production hardening and reverse-proxy setup.
- Connect a session — authenticate a WhatsApp account and confirm it is online.