Skip to main content
Version: v0.23.1

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.

Prerequisites
  • OpenWA installed and able to start — see Installation.
  • Shell access to the machine (or container) running OpenWA, to edit .env or 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:

BoundaryVariableOptionsDefault
DatabaseDATABASE_TYPEsqlite, postgressqlite
StorageSTORAGE_TYPElocal, s3local
CacheREDIS_ENABLEDtrue, falsefalse
WhatsApp engineENGINE_TYPEwhatsapp-web.js, baileyswhatsapp-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
warning

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.

note

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

VariableDefaultDescription
NODE_ENVproductionRuntime mode; production enforces the security checks noted above
API_PORT2785Port the API and dashboard listen on
SERVE_DASHBOARDtrueServe 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_LEVELinfoOne of error, warn, info, debug
AUTO_START_SESSIONSfalseAuto-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_READYfalseBackfill 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_MS300000HTTP server request timeout in milliseconds. Raise it when a reverse proxy in front holds connections longer
HEADERS_TIMEOUT_MS65000HTTP headers timeout in milliseconds
KEEPALIVE_TIMEOUT_MS5000HTTP keep-alive timeout in milliseconds

Adapters

VariableDefaultDescription
ENGINE_TYPEunsetwhatsapp-web.js or baileys; dashboard chooses when unset
SESSION_DATA_PATH./data/sessionsWhere whatsapp-web.js session auth data is stored
BAILEYS_AUTH_DIR./data/baileysWhere Baileys session auth data is stored
BAILEYS_BROWSER_NAMEOpenWADevice 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_MAX5000LRU 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_ENTRIES5000Per-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_LIMIT5000Per-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_CONNECTtrueWhether 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_VERSIONunsetPins 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_PATHhttps://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/{version}.htmlURL 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_TYPEsqlitesqlite or postgres
DATABASE_HOSTlocalhostPostgreSQL host (ignored for SQLite)
DATABASE_PORT5432PostgreSQL port
DATABASE_NAMEopenwaPostgreSQL database name
DATABASE_USERNAMEopenwaPostgreSQL user
DATABASE_PASSWORD(empty)Required for PostgreSQL; no default shipped
DATABASE_SSLfalseEnable TLS for managed PostgreSQL
DATABASE_POOL_SIZE10PostgreSQL connection pool size (ignored for SQLite)
MAIN_DATABASE_NAME./data/main.sqliteSQLite file backing the main (auth and audit) connection, which is always SQLite. Must not resolve to the same file as DATABASE_NAME
STORAGE_TYPElocallocal or s3
STORAGE_LOCAL_PATH./data/mediaLocal media directory
STORAGE_LIST_MAX_FILES100000Cap 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_ENDPOINThttp://localhost:9000S3 / MinIO endpoint
S3_BUCKETopenwaS3 / MinIO bucket
S3_REGIONus-east-1S3 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_MS60000Re-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_MS86400000Boot sweep deletes orphaned storage-export-* archives older than this (24 hours)
REDIS_ENABLEDfalseEnable 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_HOSTlocalhostRedis host
REDIS_PORT6379Redis port
NODE_IDhostnameIdentifies 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_MS60000How 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_MS20000Lease 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_MS30000How 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

VariableDefaultDescription
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-keyWhere 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_PEPPERunsetServer-side pepper for HMAC key hashing; recommended in production
AUDIT_RETENTION_DAYS90Days 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_INGRESSfalseRequired 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_HOSTunset (/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_TIMEOUT10000Webhook delivery timeout in milliseconds
WEBHOOK_RETRY_DELAY5000Delay between webhook retries in milliseconds
WEBHOOK_MAX_PER_SESSION16Max webhooks registered per session; registrations at or over the cap get 400 (existing ones are grandfathered). 0 = unlimited
AUTOMATION_MAX_PER_SESSION32Max 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_BYTES1048576Max 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_BYTES1048576Decoded-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_MS5000How long shutdown waits for in-flight webhook deliveries to finish; a startup WARN fires when this is shorter than WEBHOOK_TIMEOUT
WEBHOOK_WORKER_CONCURRENCY10Queued webhook-delivery workers (only when QUEUE_ENABLED=true)
WEBHOOK_FAILURE_RETENTION_DAYS90Days 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_DAYS7Days 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_PROTECTtrueBlock 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_REDIRECTSfalseRedirect 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_MS10000DNS resolution deadline in milliseconds inside the SSRF guard
INGRESS_MAX_ATTEMPTS3Queued ingress delivery attempts before an event is dead-lettered (min 1)
INGRESS_RETRY_DELAY_MS5000Base for exponential backoff between queued ingress attempts, in milliseconds
INGRESS_WORKER_CONCURRENCY10Queued ingress workers (only when QUEUE_ENABLED=true)
INGRESS_RECONCILE_INTERVAL_MS60000Sweep cadence for replaying persisted ingress deliveries stuck mid-delivery; a delivery is retired to the DLQ after five attempts. 0 disables
INGRESS_DEDUP_RETENTION_DAYS7Days to keep ingress dedup rows; <=0 does not disable this prune — the value falls back to 7
INGRESS_RETENTION_DAYS90Days to keep ingress DLQ rows; <=0 disables that prune
INGRESS_TIMESTAMP_TOLERANCE_SEC300Host-wide replay-tolerance fallback for ingress routes that declare timestampHeader without a per-route toleranceSec
RATE_LIMIT_MEDIUM_LIMIT100Max requests per window (the enforced tier)
RATE_LIMIT_MEDIUM_TTL60000Rate-limit window in milliseconds
SEND_PACING_ENABLEDfalseOpt-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_SCHEDULE20,40,80,160,320,640,1000Per-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_CAP5,10,20,40,60,80,100Bounds 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_THRESHOLD5Consecutive 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_MS900000How long the pacing breaker pauses sends after SEND_PACING_BREAKER_THRESHOLD consecutive failures reached WhatsApp. 15 minutes
ENABLE_SWAGGERunsetServe 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_DETAILunsetReturn 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_LIMIT25mbMax request body size (base64 media rides in the JSON body)
INFLIGHT_BODY_BUDGET_BYTES4 × BODY_SIZE_LIMITAggregate 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_CHARS65536Cap 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_BYTES26214400Aggregate inline-media budget (25 MiB) for one getChatHistory(includeMedia=true) call; media past the budget returns the omitted marker
INBOUND_MEDIA_CONCURRENCY4How many inbound media items download at once; each concurrent download can hold up to MEDIA_DOWNLOAD_MAX_BYTES of memory
STATUS_MEDIA_MAX_BYTES10485760Per-file cap (10 MiB) on status (Story) media; oversized media is stored omitted (mediaOmitted=true), not rejected
MEDIA_CONVERSION_ENABLEDfalseOpt-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_PATHffmpegAbsolute path to the ffmpeg binary, for a setup where it lives outside PATH. Ignored unless MEDIA_CONVERSION_ENABLED=true
MEDIA_CONVERSION_TIMEOUT_MS60000Kill one conversion after this long. 60 seconds. A non-positive value fails boot
MEDIA_CONVERSION_MAX_OUTPUT_BYTES52428800Cap on the CONVERTED bytes (50 MiB); an oversized output is rejected. A non-positive value fails boot
MEDIA_CONVERSION_CONCURRENCY2Max concurrent ffmpeg processes; a short queue absorbs bursts, beyond it the endpoint answers 503 instead of stacking processes
CHAT_MEDIA_ARCHIVE_ENABLEDfalseOpt-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_OUTBOUNDfalseSub-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_BYTES26214400Per-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_DAYS0How 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_MS3600000How 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_MS3600000How 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_BATCHES50Cap on concurrently running bulk-send batches. 0 = unlimited
MESSAGE_REAPER_INTERVAL_MS600000Sweep 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_MS3600000Only PENDING rows older than this (1 hour) are reaped
MESSAGE_REAPER_BATCH_SIZE50Max rows reaped per sweep
STATS_CACHE_TTL_MS30000In-process memoization TTL for dashboard stats aggregates — stats can lag by up to this. 0 disables the memo
PLUGIN_STATE_DIR./dataRoot 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_BYTES5242880Cap (5 MiB) on a plugin .zip fetched by install-from-URL
PLUGIN_INSTALL_REQUIRE_PINunsetRequire 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_MS30000Timeout for one sandboxed plugin capability call; a wedged call is failed and its in-flight slot freed
PLUGIN_STORAGE_MAX_BYTES52428800Per-plugin storage quota (50 MiB); ctx.storage writes beyond it are rejected
PLUGIN_CATALOG_URLhttps://raw.githubusercontent.com/rmyndharis/OpenWA-plugins/main/plugins.jsonCatalog 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
Check your own .env for ENABLE_SWAGGER

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

VariableDefaultDescription
SEARCH_ENABLEDtrueMount the global message-search module and its /search route. false omits the module entirely, so the route answers 404
SEARCH_PROVIDERautoWhich 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_MAX100Hard 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

ProblemCauseFix
Server exits at boot with a credentials errorDATABASE_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 charactersSet 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 bootValue is not whatsapp-web.js or baileysUse 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 defaultSet a plain integer such as 90, or 0 to disable pruning; or leave the knob unset
Browser blocks dashboard requests in productionCORS_ORIGINS=* is refused in productionSet explicit origins, e.g. CORS_ORIGINS=https://dashboard.yourdomain.com
Cache appears to do nothingREDIS_ENABLED=false, or Redis is unreachableCaching is optional and fails open; enable Redis or accept source-of-truth reads
401 Unauthorized on every requestMissing or wrong X-API-KeySend the key from the startup banner, or the one you set via API_MASTER_KEY
Config change had no effectValues load only at bootRestart OpenWA; adapters cannot be hot-swapped

Next steps