Skip to main content
Version: v0.23.5

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. Both bundled Compose files forward an explicit list of keys and carry no env_file, so a variable that is not on that list never reaches the container, however your .env is written.

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

Neither bundled Compose file forwards DATABASE_SSL or DATABASE_SSL_REJECT_UNAUTHORIZED, so on those paths setting them in .env leaves the connection without TLS. Add them to the openwa-api service's environment: list in a docker-compose.override.yml, or set the TLS options in the dashboard under Infrastructure, which writes them to data/.env.generated.

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 # set only for MinIO or another S3-compatible store
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. Path-style addressing is enabled only when S3_ENDPOINT is set, which is what MinIO and other S3-compatible stores need. Leave S3_ENDPOINT unset for AWS S3 and the client derives the regional endpoint and uses virtual-hosted addressing. As with PostgreSQL, a production build refuses to start with empty or placeholder S3 credentials.

Everywhere that refusal does not apply, STORAGE_TYPE=s3 without credentials builds no S3 client at all and quietly writes every file to the local directory instead. Since v0.23.4 that degradation is announced at startup, naming the half of the pair that is unset rather than declaring both absent, since arriving here with one of them set is a typo in the other:

STORAGE_TYPE=s3 but S3_SECRET_ACCESS_KEY is not set; media is being written to the local dir
'./data/media' instead of the bucket. The built-in MinIO uses minioadmin/minioadmin.

MINIO_BUILTIN=true does not supply those credentials. Saving storage settings from the dashboard writes them for you when you pick the built-in MinIO; setting the flag by hand only starts the bundled container, so a hand-edited .env still needs both keys.

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 and far lighter. Both link by QR or by phone-number pairing code; the engines differ in per-route capability rather than in how you connect them. 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
PORT2785Port the app binds to. The only port variable a bare-metal run or a plain docker run reads
API_PORT2785Docker Compose only: the host-side port mapped to the container's fixed 2785. The app itself never reads it, and docker-compose.dev.yml ignores it
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, verbose, matched after trimming and lowercasing, so DEBUG and a space-padded warn are accepted; unset or empty means info. Any other value fails startup with LOG_LEVEL must be one of "error", "warn", "info", "debug", "verbose" (got "…"), so a spelling borrowed from a neighbouring vocabulary (log, trace, warning) now refuses to boot where it used to be ignored and leave the level at info (v0.23.5+, breaking)
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_TYPEunset (falls back to whatsapp-web.js)whatsapp-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. The value is also carried into the phone-number pairing request, and some accounts reject a non-standard device name with a generic "check the phone number" error: if pairing keeps failing on a number you know is correct, set this to a standard OS name such as Ubuntu, restart the session and request a fresh code
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_CHAT_STATE_CACHE_MAX5000Global LRU cap on the in-memory Baileys chat mute/archive/pin mirror, shared by every session; raise it for many sessions or large chat lists. An evicted row stays persisted: a write reads it back before merging, so a single-field update never wipes the fields it omits, and a read that misses returns the defaults once while the entry warms for the next lookup. 0 = unbounded. Listed in .env.example and forwarded by both bundled Compose files since v0.23.5
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
PUPPETEER_PROTOCOL_TIMEOUT_MSunset (Puppeteer's own budget, 180000 today)How long a single browser command may run before it is abandoned, in milliseconds. whatsapp-web.js engine only, and leaving it unset changes nothing. Raise it when a large account (thousands of chats) fails a read with Runtime.callFunctionOn timed out: the renderer is still working, the command budget just ran out. Must be a positive integer no greater than 2147483647, and the gateway refuses to boot on anything else. 0 arms no timer at all, so a wedged browser would hold the request forever; a larger value overflows Node's timer and fires after 1 ms, so the browser never finishes launching (v0.23.4+)
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_ENDPOINTunsetS3-compatible endpoint (MinIO, R2). Leave it unset for AWS S3, which derives its endpoint from the region; setting it also switches the client to path-style addressing
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 sweeps for sessions whose holder's lease lapsed, whether a crashed peer or this node's previous container identity (v0.14.0+). Adopting them follows AUTO_START_SESSIONS, but the sweep itself runs on every node whatever that flag says, because it also corrects the status a vanished node left behind: a ready, initializing, authenticating or action_required row (and a qr_ready one only while it has no phone) is marked disconnected once its lease has been expired for more than twice SESSION_LEASE_TTL_MS (v0.23.5+)

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. WEBHOOK_MAX_PAYLOAD_BYTES is then checked on the whole JSON body, where base64 adds a third, so at the defaults webhook media over roughly 768 KiB is shed to the marker anyway; raise the payload cap to at least 4/3 of this one plus room for the envelope. The Socket.IO broadcasts apply only this cap. 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). Not forwarded by either bundled Compose file, so the opt-out needs a docker-compose.override.yml on the Docker path; the guard stays on there whatever .env says
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. One of three tiers the global throttler enforces together, with RATE_LIMIT_SHORT_LIMIT (10 per RATE_LIMIT_SHORT_TTL, 1000 ms) and RATE_LIMIT_LONG_LIMIT (1000 per RATE_LIMIT_LONG_TTL, 3600000 ms); exceeding any tier answers 429, so raising this one alone does not lift the ceiling
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
MEDIA_DOWNLOAD_MAX_BYTES52428800Cap (50 MiB) on remote-URL sends, inbound media, and outbound base64 sends. Oversized inbound media is dropped and the message kept with its omitted marker; an oversized base64 send is rejected with 413
MEDIA_DOWNLOAD_TIMEOUT_MS30000Abort a slow media download after this long, in milliseconds
MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES8388608Aggregate inline-media budget (8 MiB of encoded base64) for one GET /sessions/{sessionId}/messages response. Spent newest-first; past it a payload becomes the { omitted, sizeBytes } marker and stays fetchable from the per-message media route. The newest payload is always sent whatever its size, so the real bound is max(this, one payload). 0 omits every payload, and inlineMedia=false on the request has the same effect for that call
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
The media knobs take raw numbers, BODY_SIZE_LIMIT takes a unit string

BODY_SIZE_LIMIT is written 25mb, and the neighbouring size knobs are not. MEDIA_DOWNLOAD_MAX_BYTES, MEDIA_DOWNLOAD_TIMEOUT_MS, INBOUND_MEDIA_CONCURRENCY and CHAT_HISTORY_MEDIA_BUDGET_BYTES each take a bare integer, and since v0.23.4 a unit-suffixed value refuses the boot by name. Before that it parsed as its leading digits with nothing said: MEDIA_DOWNLOAD_MAX_BYTES=50mb became a 50-byte cap and MEDIA_DOWNLOAD_TIMEOUT_MS=30s a 30-millisecond timeout, so every media download failed and no log line named the cause. A zero or negative value is refused the same way. If you are on an older release, check these four before you upgrade.

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. Helm deployments are unaffected: the chart's default env map carries only NODE_ENV and LOG_LEVEL, neither of which touches Swagger, so they resolve to the production default. Both bundled Compose files do forward the key (- ENABLE_SWAGGER=${ENABLE_SWAGGER:-}), so a .env still carrying the v0.15.0 line switches Swagger on there too.

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. Under auto an enabled plugin that declares search:provide takes the route over, and only then can /search itself fail with 502 (the provider returned an invalid result shape) or 503 (it did not answer); the built-in provider returns neither. 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
SEARCH_LIMIT_MAX100Hard cap on the limit query parameter of /search

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

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 authenticated API route requires a key, sent either in the X-API-Key header or as Authorization: Bearer <key>. 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​