Repository navigation
Expand file tree
/
Copy path.env.example
More file actions
1009 lines (946 loc) · 74.2 KB
/
Copy path.env.example
File metadata and controls
1009 lines (946 loc) · 74.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# =============================================================================
# OpenWA - Environment Configuration
# =============================================================================
# Copy this file to .env and customize as needed.
# This is the Single Source of Truth for all configuration.
#
# WHY SO MANY LINES ARE COMMENTED OUT
# -----------------------------------
# A value present in .env wins over everything the dashboard saves. Configuration is filled from
# three layers, each supplying only what the previous one left unset:
#
# process environment > .env > data/.env.generated (Dashboard > Infrastructure)
#
# So any setting shipped uncommented here becomes a PIN the moment you copy this file: the matching
# dashboard control still moves, saves, and reports success, while the running value never changes.
# Every setting the dashboard owns is therefore commented out, with its default shown. Uncomment
# only what you intend to manage by hand — and remember that an EMPTY assignment pins just as hard
# as a filled one (`DATABASE_PASSWORD=` is a set-to-empty value, not an absent one, so it shadows a
# password the dashboard provisioned).
#
# Keep it that way: `src/config/env-precedence.spec.ts` fails if a dashboard-owned key is shipped
# uncommented again.
# =============================================================================
# CORE SETTINGS
# =============================================================================
NODE_ENV=production
# Port the app binds to when run directly (bare metal / `npm run start:prod`). In the bundled Docker
# Compose the container always listens on 2785; the API_PORT below is only the HOST-published port.
# To run the image itself on another port, pass `-e PORT=...`: the image healthcheck never reads .env
# or data/.env.generated.
PORT=2785
# Docker Compose only: the host-side port mapped to the container's 2785 (no effect on a bare-metal run).
API_PORT=2785
# Serve the bundled dashboard from the API process. Set false for an API-only deployment.
# SERVE_DASHBOARD=true
LOG_LEVEL=info # error | warn | info | debug | verbose
# Console output format. Default: json in production (containers, log aggregators),
# human-readable pretty otherwise. Force one with: json | pretty
# LOG_FORMAT=pretty
# Process time zone. Left commented because the app reads nothing from it: stored timestamps are UTC
# on both databases (the PostgreSQL data connection pins its own session), so this only sets the zone
# log stamps and the "today" stats window are read in. The Docker image runs UTC.
# TZ=UTC
# Auto-start previously authenticated sessions on server boot. Recommended `true` for a SINGLE-instance
# production deployment so a crash-restart self-heals authenticated sessions; keep `false` if you run
# multiple replicas behind a scheduler (two replicas resurrecting one session risks a forced logout/ban —
# see docs/13-horizontal-scaling.md). Auto-start selects sessions with a non-null `phone`. Both a
# completed logout (200, credentials wiped) and an incomplete logout (502, `code: 'SESSION_LOGOUT_INCOMPLETE'`)
# clear `phone`, so neither is auto-started on boot — an incomplete-logout session must be started
# explicitly and the logout retried. A session stopped through POST /stop or /force-kill is not
# auto-started either (nor adopted by the takeover sweep) until an explicit POST /start.
# Docker Compose: this value only reaches the container from v0.12.0 onward — the bundled
# docker-compose.yml did not forward it before then (#985), so setting it on an earlier version had
# no effect and sessions stayed DISCONNECTED on boot regardless.
# Left commented deliberately. The production compose treats an unset value as off, so the default
# below is unchanged there. The DEV compose defaults an unset value to `true`
# (docker-compose.dev.yml: `${AUTO_START_SESSIONS:-true}`), which is its intended convenience —
# uncommenting the line here turns that off again.
# AUTO_START_SESSIONS=false
# Fetch active statuses that predate a connection as soon as a session reaches READY. Disabled by
# default because the eager status@broadcast read can make WhatsApp revoke some freshly paired
# companions at their first scheduled Web reload. Live status events are unaffected.
STATUS_SEED_ON_READY=false
# 0 = unlimited. Set a positive integer to cap concurrently running/initializing sessions, plus
# sessions waiting to relaunch after a failed reconnect (no engine yet, but their timer starts one).
# A FAILED session is evicted by design (no live engine, no pending relaunch), so it does not hold a
# concurrency slot.
# MAX_CONCURRENT_SESSIONS=0
# Graceful-shutdown drain: on SIGTERM/SIGINT the app flips readiness to 503 (so a load balancer stops
# routing) and keeps serving in-flight requests for this many ms before teardown. Default 3000 unless
# NODE_ENV is development or test (0 there, so a dev hot-reload/Ctrl+C is not slowed); an unset
# NODE_ENV also gets 3000. Capped at 30000. A second signal forces an immediate exit. At the default 3 s
# delay, the shipped 75 s `stop_grace_period` is this delay plus a 67 s teardown limit plus a 5 s
# margin; raising this moves the in-process force-exit later by the same amount, so raise
# `stop_grace_period` by the same amount.
# SHUTDOWN_DELAY_MS=3000
# HTTP server timeouts. Raise these when a reverse proxy in front holds connections longer.
# REQUEST_TIMEOUT_MS=300000
# HEADERS_TIMEOUT_MS=65000
# KEEPALIVE_TIMEOUT_MS=5000
# Host interface the dev compose binds the API/dashboard ports to. Defaults to 127.0.0.1
# (localhost only). Set to 0.0.0.0 to reach the dev stack from another machine (e.g. a remote
# VPS) — put a TLS reverse proxy in front for anything internet-facing (the API key is sent in
# cleartext over plain HTTP).
# BIND_HOST=0.0.0.0
# Public URLs (for startup banner and external access)
# BASE_URL=https://api.yourdomain.com
# DASHBOARD_URL=https://dashboard.yourdomain.com
# CORS Configuration: comma-separated allowed origins. The wildcard "*" is allowed
# only in development; in production it is REFUSED (cross-origin browser requests are
# blocked) — set explicit origin(s) there, e.g. https://dashboard.yourdomain.com
# (an entry's path or trailing slash is dropped, since a browser never sends one in Origin).
# CORS_ORIGINS=*
# SSL/TLS: terminate TLS at your own reverse proxy (nginx, Caddy, a cloud load
# balancer, or a k8s Ingress) in front of the API — see docs/12-troubleshooting-faq.md.
# Trusted reverse proxies (comma-separated IPs/CIDRs) whose X-Forwarded-For
# header is trusted to determine the real client IP for API-key IP whitelisting.
# Leave empty (default) to ignore X-Forwarded-For and use the direct socket
# address, which prevents IP spoofing. If you run behind a reverse proxy AND use
# per-key allowedIps restrictions, set this to the proxy's address/subnet, e.g.:
# TRUSTED_PROXIES=172.18.0.0/16
# IPv4 and IPv6 addresses and CIDRs are accepted (e.g. fd00::/8); an entry that is neither is
# ignored with a boot warning.
# Docker daemon endpoint used to manage the built-in datastore containers (POSTGRES_BUILTIN,
# REDIS_BUILTIN, MINIO_BUILTIN — each in its own section below). Unset means /var/run/docker.sock.
# 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. So a typo here is a different daemon, not a
# fallback. The bundled compose hard-codes its own socket-proxy and forwards no value from .env, so
# setting this affects a non-compose run only.
# DOCKER_HOST=tcp://127.0.0.1:2375 # example only — a compose deployment cannot override this
# =============================================================================
# ENGINE CONFIGURATION
# =============================================================================
# Which WhatsApp engine to use (plugin-based)
# Options: whatsapp-web.js, baileys
# Left unset by default so the dashboard (Infrastructure > Engine) governs the active engine via
# data/.env.generated (defaults to whatsapp-web.js). Uncomment to pin an engine from the environment;
# a value set here always wins over the dashboard selection.
# ENGINE_TYPE=whatsapp-web.js
# Engine-specific settings
# SESSION_DATA_PATH=./data/sessions
# PUPPETEER_HEADLESS=true
# Browser flags, comma- or space-separated. NOTE: this REPLACES the default list rather than adding to
# it, so repeat the flags you still want — dropping --no-sandbox stops Chromium launching in a
# container. `--lang=en-US` is appended automatically unless you pass your own `--lang`. It sets the
# browser's language, but WhatsApp Web may still follow the account's; for a non-English onboarding
# modal see WWEBJS_ONBOARDING_CONTINUE_LABELS below.
# PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
# Path to a system Chromium/Chrome binary. Leave unset to use the bundled Puppeteer browser.
# The Docker image sets its own (/usr/local/bin/puppeteer-chrome); set it only on hosts without a
# usable bundled browser (e.g. Alpine/ARM).
# PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# How long a single browser command may take before it is abandoned, in milliseconds. Unset means
# Puppeteer's own budget (180000 today), so leaving it unset changes nothing. Raise it if a large account
# (thousands of chats) fails a read with a 503 "did not answer ... in time" (the chat, group and contact
# lists) or 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:
# 0 does not mean "no limit later", it means no timer is armed at all, so a wedged browser holds the
# request forever instead of failing it; and above 2147483647 Node's timer overflows and
# fires after 1 ms, so the browser never finishes launching. The gateway refuses to boot on either.
# PUPPETEER_PROTOCOL_TIMEOUT_MS=300000
# A freshly linked account is shown a "What's new on WhatsApp Web" modal that must be acknowledged or
# WhatsApp unlinks the companion a few minutes later. The whatsapp-web.js engine clicks it
# automatically, matching the English button label `Continue`. If your WhatsApp Web renders the modal
# in another language, add that label here (comma-separated). An operator-supplied label is matched
# without the English heading check, which would otherwise reject the very modal it targets, but only
# on a button inside a visible dialog.
# WWEBJS_ONBOARDING_CONTINUE_LABELS=Continuar,Weiter,Lanjutkan
# ── Container resource limits (docker-compose only) ───────────────────────────
# These map to docker-compose `mem_limit` / `pids_limit` and only apply when running via the
# bundled compose files. They are read by docker-compose.yml, not by the app process.
# Override in your .env to tune for your fleet.
# Memory ceiling for the openwa-api container. whatsapp-web.js runs a full Chromium per session, so
# raise this for multi-session deployments (e.g. 4g). Baileys (no Chromium) is far lighter.
OPENWA_MEM_LIMIT=2g
# Per-container process (PID) ceiling — a fork-bomb guard, NOT an allocation (the kernel only
# rejects forks once the count is reached, so a higher limit is free for light containers).
# Default 2048 fits ~8-10 whatsapp-web.js sessions with startup-spike headroom; each Chromium is
# itself multi-process (browser + renderer + GPU + zygote + utilities) and WhatsApp Web is
# process-heavy, so the old default (512) could get a session's Chromium killed mid-spawn during
# startup — surfacing in the API as a `Code: null` launch failure (#636). Baileys is single-process
# and uses a handful of PIDs regardless. Raise for larger fleets; do NOT set -1 (drops the guard).
OPENWA_PIDS_LIMIT=2048
# Optional WhatsApp Web client version pin. DEFAULT (unset, "auto", or "latest"): OpenWA fetches
# the current known-good build from the third-party wppconnect-team/wa-version registry and pins
# it — the pinned HTML is downloaded and executed inside the web.whatsapp.com origin with NO
# integrity check, which is the reliable default (avoids the "stuck at authenticating" hang, #488)
# but means trusting that registry's published HTML. Set "off" to disable pinning and use the
# first-party build served by WhatsApp (whatsapp-web.js native behavior, minus its local HTML
# cache, which cannot be written in the image). Set an exact version string to pin a specific
# build from the same registry (browse the html/ folder at
# https://github.com/wppconnect-team/wa-version). WWEBJS_WEB_VERSION_REMOTE_PATH overrides the
# HTML URL template (use {version} as the placeholder) to fetch from an operator-controlled copy
# instead. The requested build + its source (pinned/auto/native) is shown on the dashboard's
# Infrastructure page. A pin is not guaranteed to hold: a pin whose HTML cannot be fetched within
# 10 s is dropped for the live build (warning web_version_html_unavailable), and WhatsApp Web's
# service worker can serve its own cached build instead of the pinned HTML. A fetched build is kept
# under SESSION_DATA_PATH/.wa-web-cache/. Each session logs the build it actually runs when it
# reaches ready (action web_version_running), and warns with both builds when that is not the
# pinned one (action web_version_pin_not_applied).
# WWEBJS_WEB_VERSION=<a build from that registry's html/ folder>
# WWEBJS_WEB_VERSION=off
# The HTML URL template the pinned build is fetched from (see the note above). `{version}` is the
# placeholder. Point it at an operator-controlled mirror to avoid trusting the public registry.
# WWEBJS_WEB_VERSION_REMOTE_PATH=https://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/{version}.html
# Extend the initial boot/inject wait (milliseconds) before QR generation. On slow first boots
# (WSL2 or low-resource containers) the default 30000ms can expire before WhatsApp Web finishes
# loading. Raise it (e.g. 120000) if startup times out. Unset keeps the default (30000).
# WWEBJS_AUTH_TIMEOUT_MS=120000
# Baileys engine (used when ENGINE_TYPE=baileys). WebSocket client, no Chromium.
# NOTE: the Baileys engine is loaded lazily (dynamic import, only when ENGINE_TYPE=baileys) and adds no
# Node.js floor of its own; the package floor is Node.js 22.19 (engines.node).
# BAILEYS_AUTH_DIR=./data/baileys
# NOTE: proxy egress is configured PER SESSION, on both engines, via the proxyUrl field on
# POST /api/sessions or PATCH /api/sessions/:sessionId/proxy (ADMIN), NOT via environment variables.
# The URL scheme (http, https, socks4, socks5) selects the protocol.
# Pull the FULL message-history archive when the device is linked (large; set before linking, an
# already-linked session must be re-linked). Off by default: the engine still enables the initial
# sync (contacts, chats, recent messages, lid mappings) without downloading the entire history.
# BAILEYS_SYNC_FULL_HISTORY=false
# Announce the linked device as online when the socket connects. Baileys' default is true, which
# suppresses WhatsApp push notifications on the paired phone while the gateway is connected —
# a 24/7 gateway then silences the phone permanently (#871). Set to false to keep phone
# notifications working. Only gates the on-connect presence: the typing / chat-state send API
# still announces per-chat presence for that call.
# BAILEYS_MARK_ONLINE_ON_CONNECT=true
# Device name shown in WhatsApp Settings → Linked Devices for NEW pairings (existing sessions keep
# the name they were paired with until re-linked). Default: Ubuntu. It is also carried into the
# phone-number pairing request: some accounts reject a non-standard name with a generic "check the
# phone number" error, so keep a standard OS name like Ubuntu if you use phone-number pairing.
# BAILEYS_BROWSER_NAME=
# Pin a specific WhatsApp Web protocol version for the Baileys engine (e.g. 2.3000.1045340097).
# Leave unset (default) for automatic multi-tier resolution (live WA Web endpoint -> upstream Defaults -> local cache -> safe fallback).
# BAILEYS_WA_VERSION=2.3000.1045340097
# Surface the Baileys library's own logs for debugging (trace|debug|info|warn|error). Silent by default.
# `trace` dumps the decoded WhatsApp wire frames to stdout (context "baileys-wire").
# BAILEYS_LOG_LEVEL=debug
# Humanise single sends: show a "typing…" indicator and pause briefly (length-scaled, jittered)
# before each text send so messages don't look instantaneous (anti-ban). ON by default — set to
# false to disable. SIMULATE_TYPING_MAX_MS caps the pause (default 5000). Does not affect bulk
# sends, which have their own delayBetweenMessages throttle.
# SIMULATE_TYPING=false
# SIMULATE_TYPING_MAX_MS=5000
# Outbound send pacing (anti-ban). OFF by default — nothing is refused unless you opt in. When on,
# a session may send at most its warm-up allowance per UTC day, and a run of consecutive send
# failures 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.
#
# SEND_PACING_WARMUP_SCHEDULE is the per-day allowance by session age in days: the first entry is the
# session's first day, and the last entry applies to every day after that. It starts small because a
# brand-new WhatsApp account that immediately sends at volume is the pattern that gets numbers
# banned. Any 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 — but that also
# means the cap only sees sends that WRITE a row there. Status posts and message edits are real
# outbound traffic that persists no new row, so they pass through the check
# without being counted — and so does a bulk item the engine refuses, because bulk writes its row
# only after the send succeeds. A session doing any of them can exceed its stated daily allowance.
# A bulk item that SUCCEEDS is counted, as is a failed single send, whose PENDING row is kept as
# FAILED. A Baileys product send is counted once its own-send echo writes the outgoing row, shortly
# after the send returns.
# Clearing or deleting a chat removes its stored rows, so that chat's sends today stop counting toward
# either cap, and the next send to it counts as a new conversation.
# SEND_PACING_ENABLED=true
# SEND_PACING_WARMUP_SCHEDULE=20,40,80,160,320,640,1000
#
# SEND_PACING_COLD_DAILY_CAP 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 (its own "reachout timelock" restriction targets exactly that), while
# answering someone who wrote to you first is not a risk at all and is never counted here. Same
# by-age shape as the schedule above; a single number is a flat cap; 0 or off disables the rule.
# SEND_PACING_COLD_DAILY_CAP=5,10,20,40,60,80,100
# SEND_PACING_BREAKER_THRESHOLD=5
# SEND_PACING_BREAKER_COOLDOWN_MS=900000
# Inline @lid -> phone resolution (#263). When a sender is identified by a WhatsApp privacy id
# (@lid) instead of a phone number, attach a best-effort `senderPhone` (MSISDN digits, or null when
# the engine can't map it) to the message.received webhook + websocket payload. OFF by default —
# it adds a per-sender lookup (cached). The on-demand endpoint GET /sessions/:id/contacts/:id/phone
# works regardless of this flag.
# RESOLVE_LID_TO_PHONE=true
# Cap on the in-memory lid->phone mirror used for synchronous resolution on the dispatch hot path.
# The table remains the source of truth; a cache miss falls back to engine re-resolution, so this
# trades a re-resolution for bounded memory on a long-running, contact-heavy account. 0 disables the
# cap (legacy unbounded behaviour) and, with it, the record of which lids the table has no row for,
# so an unmapped lid queries the table again on every dispatch. Default 5000.
# LID_MAPPING_CACHE_MAX=5000
# Per-map cap on the Baileys session store's in-memory maps (contacts, chats, lastMessages, lidToPn,
# ephemeralByChat, all fed by peer traffic). A miss falls back to the persisted lid table, a
# re-resolution, or a defined empty result, so eviction never loses data. 0 disables the cap. Default 5000.
# In the contacts map it bounds the PEER half only: a contact carrying a name you saved is exempt, so
# that map is bounded by the account's own address book and lowering this knob will not shrink it.
# BAILEYS_SESSION_STORE_MAX_ENTRIES=5000
# Global cap on the in-memory Baileys chat mute/archive/pin mirror, shared by all sessions. An evicted
# row stays persisted and is read back from the database on a miss, so eviction never loses data. Raise
# it for many sessions or large chat lists. 0 disables the cap. Default 5000.
# BAILEYS_CHAT_STATE_CACHE_MAX=5000
# Per-session cap on the Baileys message rows kept in the database: the newest N survive and older
# rows are pruned after each write. A value that is not a positive integer fails the boot.
# BAILEYS_MESSAGE_STORE_LIMIT=5000
# Observability — Prometheus scrape endpoint at GET /api/metrics.
# Disabled by default; set a token to enable. Scrapers must send `Authorization: Bearer <token>`.
# METRICS_TOKEN=change-me-to-a-long-random-string
# Audit-log retention. Logs older than this many days are pruned daily (and once at startup).
# Default 90; set to 0 to keep audit logs forever (disable pruning).
# AUDIT_RETENTION_DAYS=90
# Message retention. Stored messages older than this many days, and finished bulk batches, are
# deleted daily (and once at startup). Default 0 keeps them forever; the maximum is 36500. Pruning
# also shrinks stats history, and send pacing then treats a contact silent for longer than the
# window as new (cold).
# Search plugins keep their copies of pruned messages; archived media files are reclaimed by the
# chat-media orphan sweep. History older than the window is not written back by a history sync.
# MESSAGE_RETENTION_DAYS=0
# =============================================================================
# DATABASE
# =============================================================================
# Options: sqlite, postgres
# DATABASE_TYPE=sqlite
# POSTGRES_BUILTIN=false # Use built-in PostgreSQL container?
# PostgreSQL settings (ignored for sqlite, auto-configured if POSTGRES_BUILTIN=true)
# DATABASE_HOST=localhost
# DATABASE_PORT=5432
# PostgreSQL database name ONLY. Leave unset for SQLite (DATABASE_TYPE=sqlite) to use the
# default file path ./data/openwa.sqlite — a bare value here would become the SQLite file PATH
# and, under a read-only container rootfs, trigger a SQLITE_CANTOPEN boot-loop (#677).
# DATABASE_NAME=openwa
# PostgreSQL schema (ignored for sqlite). Default 'public' keeps the historical behavior.
# Set to a dedicated schema to isolate OpenWA's tables + migration ledger (e.g. on managed
# Postgres where you get a project schema, or to share one DB across apps). The schema must
# already exist — the built-in container creates it; for external/managed Postgres create it
# once (CREATE SCHEMA openwa;). A missing schema fails fast at migration time.
# Lower case only (letters, digits, underscores): boot rejects upper case.
# POSTGRES_SCHEMA=public
# DATABASE_USERNAME=openwa
# MUST set a strong, unique value before using Postgres — no default is shipped.
# Production refuses to start if this is empty or a known placeholder when DATABASE_TYPE=postgres.
# DATABASE_PASSWORD=
DATABASE_SYNCHRONIZE=false # WARNING: Set false in production! (data DB)
# WARNING (PII): "true" makes TypeORM log every query WITH its bound parameters, and those
# parameters carry message bodies, phone numbers and webhook payloads. Debug-only on a local
# data set — never enable in production or hand the logs to a wider audience than the DB itself.
# DATABASE_LOGGING=false
# SQLite file for the 'main' (auth/audit) connection. Must not resolve to DATABASE_NAME.
# MAIN_DATABASE_NAME=./data/main.sqlite
# Auth/audit (main) DB schema management. By default the bundled main-owned migrations create
# and upgrade api_keys/audit_logs at boot (an existing synchronize-built file is adopted in place).
# Boot stops on a file a newer release migrated, or one an older release rebuilt after its ledger
# recorded the lost column; a file without that ledger row is not detected (see docs/05 section 5.6).
# "true" also runs TypeORM synchronize after them: for development only, and warned about under
# NODE_ENV=production.
# A new api_keys/audit_logs column still needs a migrations-main migration; synchronize does not add it.
# MAIN_DATABASE_SYNCHRONIZE=false
# DATABASE_SSL=false # Set true for managed Postgres (Supabase, Heroku, Render, Railway)
# DATABASE_SSL_REJECT_UNAUTHORIZED=true # Set false to allow self-signed certs (only when DATABASE_SSL=true)
# PostgreSQL connection pool size.
# DATABASE_POOL_SIZE=10
# Postgres pool/query timeouts (ms). Defaults are conservative; set any to 0 to disable; a value
# above 2147483647 fails the boot.
# DATABASE_STATEMENT_TIMEOUT_MS=30000 # Aborts a single runtime query that runs longer (server-side; not applied to migrations)
# DATABASE_IDLE_TIMEOUT_MS=30000 # Closes idle pooled connections after this long
# DATABASE_CONNECTION_TIMEOUT_MS=10000 # Fails fast if a new connection can't be acquired in time
# =============================================================================
# REDIS / QUEUE (Phase 2)
# =============================================================================
# REDIS_ENABLED=false # Use Redis for the rate-limit store, the cache and
# cross-replica WebSocket event fan-out (Socket.IO Redis
# adapter). It does not enable the queue: that needs
# QUEUE_ENABLED=true. Multi-replica deployments need this so a
# WS client on one replica receives events raised on another;
# single-node can leave it off.
# REDIS_BUILTIN=false # Use built-in Redis container?
# Process webhooks/ingress through the BullMQ queue (needs a reachable Redis via REDIS_HOST/REDIS_PORT).
# Off by default (inline dispatch). In the bundled Docker Compose this is dashboard-managed
# (Infrastructure > Redis & Queue) — a host value here is not forwarded to the container.
# QUEUE_ENABLED=false
# Redis cache: on when REDIS_ENABLED=true OR CACHE_ENABLED=true; otherwise there is no cache (reads
# go to the database, no in-memory tier). CACHE_ENABLED=false does not turn it off while Redis is on.
# CACHE_ENABLED=false
# Redis settings (auto-configured if REDIS_BUILTIN=true)
# REDIS_HOST=localhost
# REDIS_PORT=6379
# Fail Redis queue/cache connection attempts after this many ms. Queue calls made before Redis has
# ever connected wait at most this long from the first one, then take the same fallback as an
# unreachable Redis until it connects. 0 disables the socket connection timeout; that queue wait then
# still uses the default 5000.
# REDIS_CONNECT_TIMEOUT_MS=5000
# REDIS_USERNAME=
# REDIS_PASSWORD=
# Connect to Redis over TLS (a managed Redis that requires it). Uses Node's default CA store; add a
# private CA with NODE_EXTRA_CA_CERTS=/path/ca.pem in the environment that launches node (shell, systemd
# unit, or a compose environment: entry plus a read-only mount of the CA file). Node reads it only at
# launch, so a value in this file has no effect. Not for the built-in Redis container, which serves
# plain TCP.
# REDIS_TLS=false
# Redis logical database for the cache (the queue uses the default database).
# REDIS_CACHE_DB=1
# =============================================================================
# STORAGE
# =============================================================================
# Options: local, s3
# STORAGE_TYPE=local
# MINIO_BUILTIN=false # Use built-in MinIO container?
# Local storage path (if STORAGE_TYPE=local)
# STORAGE_LOCAL_PATH=./data/media
# 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 value that is not a positive integer fails the boot.
# Streaming reads are not affected — only the listing call is truncated.
# STORAGE_LIST_MAX_FILES=100000
# S3/MinIO settings (if STORAGE_TYPE=s3). Saving storage from the dashboard writes the credentials
# below for you when you pick the built-in MinIO. Setting MINIO_BUILTIN=true by hand does not: it
# starts the bundled container and nothing else, so a hand-edited file still needs the keys set.
# S3_ENDPOINT is OPTIONAL: leave it unset for standard AWS S3 (derived from region); set it only for
# an S3-compatible store (MinIO, R2, …), which also enables path-style addressing.
# S3_ENDPOINT=http://localhost:9000 # uncomment / set ONLY for an S3-compatible store (MinIO, R2)
# S3_BUCKET=openwa
# S3_REGION=us-east-1
# Key root inside the bucket (default media/). Two deployments with separate databases need their own
# bucket or prefixes that do not overlap: neither may start with the other (use siblings such as
# prod/ and staging/, never media/ and media/staging/), and never the same STORAGE_LOCAL_PATH. An
# overlap puts one deployment's objects in the other's storage stats and export, and its orphan
# sweeps delete the files its own database does not reference. Changing the prefix on a live
# deployment hides existing objects; move them with a storage export, the change, then an import.
# S3_KEY_PREFIX=media/
# MUST set strong, unique values before using S3/MinIO — no defaults shipped.
# Production refuses to start if these are empty/placeholder when STORAGE_TYPE=s3 (external).
# Canonical names (what the app reads first and the dashboard writes); the legacy
# S3_ACCESS_KEY / S3_SECRET_KEY are still accepted as a fallback for older setups.
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# =============================================================================
# WEBHOOK
# =============================================================================
WEBHOOK_TIMEOUT=10000 # Timeout in milliseconds
WEBHOOK_RETRY_DELAY=5000 # Base delay between retries in ms (doubles each retry); at most 268435455
# WEBHOOK_DISPATCH_CONCURRENCY=16 # max inline webhook POSTs in flight per process; a delivery
# waiting out a retry backoff does not hold one
# WEBHOOK_DISPATCH_MAX_QUEUED=1000 # inline deliveries running, parked, or waiting out a retry
# backoff are capped together at WEBHOOK_DISPATCH_CONCURRENCY
# plus this; more are shed to the outbox.
# Deliveries to one session's failing receivers park at most
# a quarter of it, so other sessions keep room to wait.
# Each inline delivery holds its event's inline media (up to
# 4/3 x WEBHOOK_MEDIA_INLINE_MAX_BYTES as base64), up to about
# 1.3 GiB per process at the defaults; QUEUE_ENABLED=true keeps
# it in Redis instead.
# WEBHOOK_SHUTDOWN_DRAIN_MS=5000 # on shutdown, in-flight deliveries, an outbox replay and an
# operator redrive in flight get this long to finish
# before they are logged abandoned (parked ones are
# dead-lettered). Startup warns when this is shorter than
# WEBHOOK_TIMEOUT — that cross silently truncates deliveries.
# Raising it moves the in-process force-exit and the needed
# `stop_grace_period` later by the same amount.
# WEBHOOK_MAX_PER_SESSION=16 # max webhooks registered per session; new registrations at/over
# the cap get 400 (existing ones are grandfathered); 0 = unlimited
# AUTOMATION_MAX_PER_SESSION=32 # max autoreply rules per session; new rules at/over the cap get
# 400 (existing ones are grandfathered); 0 = unlimited
# WEBHOOK_MEDIA_INLINE_MAX_BYTES=1048576 # decoded-byte cap for inline base64 media in webhook
# payloads AND the WebSocket message.received/.sent events;
# larger media is delivered as { omitted: true, sizeBytes }
# instead (0 = never inline media)
# WEBHOOK_MAX_PAYLOAD_BYTES=1048576 # max serialized webhook body; over-budget payloads shed inline
# media first, then are recorded undelivered if still too large.
# Checked on the JSON body after the inline cap, where base64
# adds a third: at the defaults, media over ~768 KiB arrives as
# the omitted marker. Raise both, this one to >= 4/3 of the
# inline cap plus room for the envelope. WebSocket events apply
# only the inline cap. Downloaded media stays retrievable from
# GET /api/sessions/:sessionId/messages/:chatId/:messageId/media
# (404 when nothing was stored).
# Days to keep webhook delivery-failure records before pruning them (default 90; set <= 0 to disable).
# WEBHOOK_FAILURE_RETENTION_DAYS=90
# Hours a terminal delivery-failure row keeps the event data it was built from, so
# POST /api/webhooks/delivery-failures/redrive can replay the lost event (default 0 = keep nothing,
# no redrive). The payload can carry a whole message body; once the window passes it is cleared and
# the row stays. With the queue enabled each job also carries this copy in Redis, up to
# WEBHOOK_MEDIA_INLINE_MAX_BYTES more per job.
# WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS=0
# Block outbound WEBHOOK deliveries to internal/reserved addresses (SSRF
# protection). ON by default; set to "false" only on closed networks. When on,
# webhook URLs resolving to loopback/private/link-local/metadata ranges are
# refused (at registration AND delivery) and redirects are not followed.
# (Server-side media-by-URL fetches are ALWAYS SSRF-guarded, regardless of this flag.)
# The bundled Docker Compose does not forward this key (it fails safe), so a value here does not
# reach the container; for a receiver on the compose network, list it in SSRF_ALLOWED_HOSTS instead.
WEBHOOK_SSRF_PROTECT=true
# Redirect handling when SSRF protection is DISABLED (WEBHOOK_SSRF_PROTECT=false): fail loudly on
# 3xx ('error', the default) or follow them ('true'). Disabling the guard is not opting into
# chasing redirects to arbitrary hosts — set this only if a receiver legitimately redirects.
# It is never read while protection is on.
# WEBHOOK_SSRF_REDIRECTS=false
# Expose the FULL sender `contact` field set (id, number, shortName, business flags, isBlocked,
# labels, …) on message.received (webhook + websocket) and on chat history. OFF by default — the payload keeps
# the minimal { name, pushName }. Opt in only if your consumer needs the richer data; all fields are
# read from the already-cached contact (no extra WhatsApp API calls). whatsapp-web.js only — the
# Baileys path never reads this flag, so setting it there changes nothing.
# WEBHOOK_CONTACT_DETAILS=true
# Comma-separated hosts/IPs allowed to bypass SSRF protection for BOTH webhooks
# and media — escape-hatch for trusted internal targets (e.g. a localhost media
# store or a sidecar webhook receiver).
# SSRF_ALLOWED_HOSTS=localhost,minio
# Note: allowlisted hosts resolve via DNS at webhook REGISTRATION time too (pinned at delivery) —
# a name that cannot resolve fails the create/update with a 400 until DNS answers for it.
# DNS resolution deadline (ms) inside the SSRF guard. The default (10000) is generous for healthy
# resolvers; lowering it shrinks the window a slow/hanging resolver can pin a worker, raising it
# widens that window — tune deliberately. A value that is not a positive integer, or above
# 2147483647, fails the boot.
# SSRF_DNS_TIMEOUT_MS=10000
# Server-side media size/time limits:
# MEDIA_DOWNLOAD_ENABLED=true # Set to false to skip downloading inbound media entirely
# # (no decryption, no memory allocation, no storage).
# # NOTE: the media FIELD is not removed — it arrives as the omitted
# # marker { mimetype, filename?, omitted: true, sizeBytes }, the
# # same shape an oversized payload gets. hasMedia still reports
# # true: it mirrors the engine's own flag, not this setting.
# # Accepts true/false/1/0/yes/no; anything else fails the boot,
# # because an unrecognised value silently means ENABLED.
# EXPORT_INLINE_MEDIA_BUDGET_BYTES=8388608
# # How much inline base64 media one GET /infra/export-data may
# # carry, in encoded bytes (default 8 MiB). Past it a message
# # payload is exported as the omitted marker and a bulk-batch
# # entry simply loses its base64, because an
# # unbounded export produces a backup the import refuses with 413
# # (it rides BODY_SIZE_LIMIT, 25mb by default). Spent newest-first
# # within each table, so a budget that cannot hold everything
# # keeps the most recent media. An http/https payload is never
# # counted or dropped — it is a pointer, not bytes, whatever the
# # case of its scheme. 0 omits every payload.
# Aggregate budget for the inline base64 media ONE message-list response may carry
# (GET /sessions/:id/messages and the MCP MessageList tool). The row count is clamped to
# 1..100, but a row is not a bounded object: past the budget each payload is replaced with
# the { omitted: true, sizeBytes } marker and stays fetchable from the per-message media
# route. The NEWEST payload is always sent whatever its size — otherwise a single photo larger
# than the budget could never be read back — so the real bound is max(this, one payload), and one
# payload is capped upstream by MEDIA_DOWNLOAD_MAX_BYTES. 0 omits every payload, newest included.
# Default: 8 MiB of encoded base64.
# MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES=8388608
# STORE_EPHEMERAL_MESSAGES=true # Set to false to skip persisting and dispatching incoming
# # WhatsApp disappearing messages (ephemeralDuration > 0).
# # Default: true (backward compatible — store everything).
# The four media knobs below take a RAW NUMBER, unlike BODY_SIZE_LIMIT further down which takes a
# unit string. Writing `50mb` here is refused at boot rather than read as its leading digits.
# MEDIA_DOWNLOAD_MAX_BYTES=52428800 # cap remote-URL sends, inbound media, AND outbound base64 sends (default 50 MiB; oversized inbound media is dropped, message kept; oversized base64 is rejected with 413)
# MEDIA_DOWNLOAD_TIMEOUT_MS=30000 # abort a slow media download (default 30s), in milliseconds
# Inbound media download parallelism PER SESSION (how many inbound media items one session downloads
# at once). Each concurrent download holds memory up to MEDIA_DOWNLOAD_MAX_BYTES (held as base64 on
# whatsapp-web.js, about a third larger), so the worst case is about sessions x N x MEDIA_DOWNLOAD_MAX_BYTES
# (x ~1.33 on whatsapp-web.js). Unset takes the default; a value that is not a positive integer fails the boot.
# That covers downloads in progress. On whatsapp-web.js Chromium also holds each file being downloaded,
# encrypted and decrypted, and a download starts whenever the size the sender declares is within
# MEDIA_DOWNLOAD_MAX_BYTES, so a sender that understates it can make one download exceed that cap. On
# PostgreSQL each downloaded payload (received media, and the echo of every media send on
# whatsapp-web.js) then waits in memory as base64 (about 4/3 of its size, on both engines) until its row
# is stored. N bounds those too: a session downloads media only into one of N slots, each held until the
# row is stored, so it holds at most N x 4 x ceil(MEDIA_DOWNLOAD_MAX_BYTES / 3) bytes of payload
# (279620272, about 267 MiB, at the defaults) however slow the inserts. Each payload takes a whole slot
# whatever its size, and a chat's messages are stored in arrival order, so a text waits for the media
# that arrived before it in its chat. Media that finds no free slot while none is freed for
# 2 x MEDIA_DOWNLOAD_TIMEOUT_MS arrives with the omitted marker. On SQLite rows are written at once and
# nothing waits, except behind a slow message:received or message:sent plugin hook, which is not bounded.
# INBOUND_MEDIA_CONCURRENCY=4 # default 4
# Process-wide ceiling on inbound media downloads across all sessions, on top of the per-session one.
# 0 (default) is off. When on, media that waits longer than MEDIA_DOWNLOAD_TIMEOUT_MS for a slot is
# delivered without its payload (the omitted marker), as a timed-out download is. On Baileys a timed-out
# download frees its slot at the deadline. whatsapp-web.js cannot abort one, so its slot stays taken until
# the page answers or Puppeteer fails the call (PUPPETEER_PROTOCOL_TIMEOUT_MS, 180s by default).
# INBOUND_MEDIA_GLOBAL_CONCURRENCY=0 # default 0 (off)
# Fetch a caller-supplied URL (a media URL passed to a send or to POST /media/convert/voice|video, and the link
# preview of a text send) through the egress proxy of the session the request names. ON by default,
# so the destination sees the same address as the rest of that session's traffic. Set to false when
# the proxy is a WhatsApp-only route that cannot reach arbitrary media hosts: those fetches then
# leave from the gateway's own address again. Sessions without a proxy are unaffected either way.
# Through an http or https proxy the proxy resolves the URL's host itself, so the SSRF guard's
# DNS-rebind pinning does not apply there; a socks proxy or false keeps it.
# SESSION_PROXY_URL_FETCH=true
# ---------------------------------------------------------------------------
# Server-side media conversion (opt-in)
# ---------------------------------------------------------------------------
# Transcodes caller-supplied media into what WhatsApp clients actually play, via the ffmpeg binary,
# through POST /api/sessions/:sessionId/media/convert/voice and .../video. Nothing is converted
# implicitly — sends are untouched unless a caller runs the media through these first.
#
# WhatsApp renders a playable voice-note bubble only for Ogg/Opus. Posting MP3 bytes with ptt=true
# sends them as-is, so the recipient sees a mic bubble that will not play; converting first is what
# fixes that.
#
# The official Docker image already ships ffmpeg, so there enabling the flag is all that is needed.
# On a source install the binary must be present, otherwise the endpoints answer 503 and say so.
# Video conversion needs ffmpeg 4.4 or newer; an older binary fails every video conversion with 400.
# ---------------------------------------------------------------------------
# Session ownership (multi-process deployments)
# ---------------------------------------------------------------------------
# A session's WhatsApp engine runs in exactly one process. These record which, so a second process
# booting beside a live one leaves its sessions alone instead of reporting them disconnected, and so
# two processes never race to start the same session — which would open the same account twice.
#
# A single-process deployment needs none of this: unclaimed rows read as free, which is what every
# existing row is.
# NODE_ID= # defaults to the hostname; must be STABLE across restarts
# # and UNIQUE per process: two processes on one host (host
# # networking, pm2 cluster) must each set it; a shared one
# # is logged as duplicate_node_id
# SESSION_LEASE_TTL_MS=60000 # how long a claim survives unrenewed (default 60s) —
# # also the worst-case failover delay after an unclean exit
# SESSION_LEASE_HEARTBEAT_MS=20000 # renewal cadence (default 20s) — MUST be strictly less
# than half SESSION_LEASE_TTL_MS, so one late or failed
# renewal still lands inside the lease; boot FAILS otherwise
# SESSION_TAKEOVER_SWEEP_MS=30000 # how often a node adopts sessions whose holder's lease
# lapsed (crashed peer / this node's previous container
# identity); adopting follows AUTO_START_SESSIONS, the
# sweep itself always runs and also marks a vanished
# node's leftover rows disconnected
# NODE_URL= # 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 requests to the owning node;
# empty disables forwarding (single-node default). A
# path is kept and put in front of every forwarded
# /api/... path, so give one only for a reverse-proxy
# prefix: a value ending in /api forwards to /api/api/...
# SESSION_PROXY_TIMEOUT_MS=60000 # ceiling for one forwarded request
# Media conversion. Each number below must be a positive integer, or the boot fails, and the timeout
# no greater than 2147483647 ms (Node's timer ceiling), even with conversion disabled.
# MEDIA_CONVERSION_ENABLED=false # default false
# FFMPEG_PATH=ffmpeg # absolute path, for a binary outside PATH; also reads a Baileys voice note's waveform
# MEDIA_CONVERSION_TIMEOUT_MS=60000 # kill one conversion after this long (default 60s)
# MEDIA_CONVERSION_MAX_OUTPUT_BYTES=52428800 # cap the CONVERTED bytes (default 50 MiB)
# MEDIA_CONVERSION_CONCURRENCY=2 # max concurrent ffmpeg processes; a short queue
# # absorbs bursts, beyond it the endpoint answers 503
# Aggregate base64 budget for media inlined by ONE GET .../messages/:chatId/history?includeMedia=true
# call. Once the running total crosses it, remaining media messages return the declared-only `omitted`
# marker instead of a download, so a media-heavy history can't stack ~100 × the per-message cap into a
# single response. Unset takes the default; a value that is not a positive integer fails the boot.
# CHAT_HISTORY_MEDIA_BUDGET_BYTES=26214400 # default 25 MiB
# Per-file cap on status (Story) media persisted to disk/S3. A status whose media exceeds this is
# stored omitted (mediaOmitted=true) rather than rejected. Unset takes the default; a value that is
# not a positive integer fails the boot.
# STATUS_MEDIA_MAX_BYTES=10485760 # default 10 MiB
# Orphaned status media reconciliation: how often the sweep re-lists status media files to find
# ones no row references (crash leftovers), and how long a file must be seen unreferenced before
# it is deleted. Unset takes the default; a value that is not a positive integer fails the boot, and
# so does a sweep interval above 2147483647 ms (Node's timer ceiling).
# STATUS_ORPHAN_SWEEP_INTERVAL_MS=3600000 # default 1h
# STATUS_ORPHAN_GRACE_MS=3600000 # default 1h
# Chat-media archiving (opt-in). When enabled, each inbound message's media is also written to the
# file store (local or S3) as a durable copy with its own lifecycle (S3 portability, TTL retention).
# The media download route GET /sessions/:id/messages/:chatId/:messageId/media serves the archived
# file first and falls back to the inline base64 copy on the message row, so retrievability does not
# require archiving. The inline copy is KEPT, so archiving roughly doubles storage for media under
# the cap — hence off by default.
# It also costs memory during a media burst, on either database: each message is archived after its
# row is written, and until its upload finishes it holds its payload as base64 and decoded. Nothing
# limits how many archive at once, and slow storage (S3) makes more overlap. Media over
# CHAT_MEDIA_ARCHIVE_MAX_BYTES are not archived and cost nothing here.
# CHAT_MEDIA_ARCHIVE_ENABLED=false # default false
# Extend archiving to media this account SENT (needs CHAT_MEDIA_ARCHIVE_ENABLED=true). Doubles
# storage again for the outbound half. A URL-based send stores no bytes on Baileys, so there is
# nothing to archive; on whatsapp-web.js the echo row may hold them, and is archived then.
# CHAT_MEDIA_ARCHIVE_OUTBOUND=false # default false
# Per-file cap on archived chat media. Media above it is simply not archived; the message row and
# its inline copy are unaffected. Unset takes the default; a value that is not a positive integer
# fails the boot.
# CHAT_MEDIA_ARCHIVE_MAX_BYTES=26214400 # default 25 MiB
# 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. A negative or malformed value
# fails the boot.
# CHAT_MEDIA_ARCHIVE_TTL_DAYS=0 # default 0 (keep forever)
# What happens to a message row's inline base64 once its media is archived. "inline" (default)
# keeps it, so archived media is stored twice. "archive" replaces it with the omitted marker
# ({ mimetype, filename?, omitted: true, sizeBytes, archived: true }) after the file is written and
# read back intact, so each file is stored once and served by the media route above. Anything that
# goes wrong keeps the inline copy. Needs CHAT_MEDIA_ARCHIVE_ENABLED=true (boot fails otherwise);
# media over CHAT_MEDIA_ARCHIVE_MAX_BYTES is not archived and stays inline; with a TTL above, an
# expired file takes the only copy with it. Rows stored before the switch keep their inline copy.
# It saves storage, but not the memory a burst holds: the row is first written with the inline
# copy, then archived. It adds to the archiving memory cost above: each message being archived
# also holds the file read back and the row's metadata while the row is rewritten.
# CHAT_MEDIA_ARCHIVE_MAX_BYTES bounds that cost per message (larger media are not archived), not
# how many archive at once.
# MESSAGE_INLINE_MEDIA=inline # default inline
# Orphaned chat-media reconciliation: how often the sweep re-lists chat-media files to find ones no
# row references (crash leftovers), and how long a file must be seen unreferenced before deletion.
# Unset takes the default; a value that is not a positive integer fails the boot, and so does a
# sweep interval above 2147483647 ms (Node's timer ceiling).
# CHAT_MEDIA_ORPHAN_SWEEP_INTERVAL_MS=3600000 # default 1h
# CHAT_MEDIA_ORPHAN_GRACE_MS=3600000 # default 1h
# Cap on the final rendered text of a send-template request (after variable substitution). Over-cap
# renders are rejected with 400, never silently truncated. A value that is not a positive integer
# fails the boot.
# TEMPLATE_RENDER_MAX_CHARS=65536 # default 64 KiB
# Cap on concurrently-running bulk batches (POST .../messages/send-bulk). 0 = unlimited. A non-finite
# or negative value falls back to the default.
# BULK_MAX_CONCURRENT_BATCHES=50 # default 50
# Storage import/export limits (ADMIN /infra/storage/* endpoints):
# STORAGE_IMPORT_MAX_BYTES=209715200 # per-entry cap for a tar.gz import; aborts on overflow (default 200 MiB)
# STORAGE_IMPORT_MAX_ENTRIES=100000 # max entries in an import archive; aborts beyond this (default 100000)
# STORAGE_IMPORT_MAX_TOTAL_BYTES=10737418240 # total bytes one import may extract; aborts on overflow (default 10 GiB)
# The two STORAGE_EXPORT_* values are raw milliseconds: a value that is not a positive integer
# (`1h` included) fails the boot, and so does a TTL above 2147483647 ms (Node's timer ceiling).
# STORAGE_EXPORT_TTL_MS=3600000 # auto-delete an export archive after this long (default 1h)
# STORAGE_EXPORT_SWEEP_MAX_AGE_MS=86400000 # boot sweep deletes export archives orphaned by a restart, older than this (default 24h)
# How often an S3-configured service re-probes a bucket that was unreachable at boot (media storage is
# degraded to the local fallback dir until it recovers; writes return to S3 automatically). Unset
# takes the default; a value that is not a positive integer, or above 2147483647 ms (Node's timer
# ceiling), fails the boot.
# S3_REPROBE_INTERVAL_MS=60000 # default 60s
# Outbound rows stuck PENDING (process died between the pre-send save and the final save) are marked
# FAILED with a `reapedAt` metadata marker by a periodic reaper, which also re-emits
# `message:persisted` so hook-driven search providers reconcile. Set the interval to 0 to disable
# the reaper; a blank value falls back to the default instead of disabling it, and a negative or
# unparseable value, or one above 2147483647 ms (Node's timer ceiling), fails the boot. The same
# rules apply to the INGRESS_ and WEBHOOK_RECONCILE_INTERVAL_MS knobs below.
# MESSAGE_REAPER_INTERVAL_MS=600000 # how often the reaper sweeps (default 10min)
# MESSAGE_REAPER_GRACE_MS=3600000 # only rows older than this are reaped (default 1h)
# MESSAGE_REAPER_BATCH_SIZE=50 # max rows reaped per sweep (default 50)
# =============================================================================
# RATE LIMITING (all TTLs are in MILLISECONDS)
# =============================================================================
# All three windows (short/medium/long) apply to every non-exempt REST controller route, each
# counted per route handler and client IP; exceeding any one returns 429, and raising one tier does
# not lift the others. Unset values keep the defaults below. /api/health*, /api/metrics and
# /api/ingress/* are exempt (ingress has its own limits). The /mcp and /api/admin/queues mounts sit
# outside the controller pipeline and are not counted here: each has its own pre-auth per-IP
# throttle, set by MCP_IP_RATE_LIMIT_MAX / MCP_IP_RATE_LIMIT_WINDOW_MS.
# RATE_LIMIT_MEDIUM_TTL=60000 # window in ms (default 60000 = 60s)
# RATE_LIMIT_MEDIUM_LIMIT=100 # max requests per window
# RATE_LIMIT_SHORT_TTL=1000 # 1s burst window (default)
# RATE_LIMIT_SHORT_LIMIT=10
# RATE_LIMIT_LONG_TTL=3600000 # 1h window (default)
# RATE_LIMIT_LONG_LIMIT=1000
# WebSocket (/events Socket.IO) limits, enforced in-process by the gateway (WS frames bypass
# the HTTP guard pipeline). Unset or blank takes the default; a value that is not a positive integer
# fails the boot.
# Per-key token bucket on client frames (subscribe/unsubscribe/ping): the dashboard sends ~8
# subscribe frames at page mount and only occasional frames afterwards, so 60/s sustained with
# a 120-frame burst is ~6x headroom over legitimate traffic while bounding a flooding key.
# WS_RATE_LIMIT_FRAME_PER_SECOND=60 # sustained frames per second per API key (default 60)
# WS_RATE_LIMIT_FRAME_BURST=120 # burst capacity per API key (default 120)
# Pre-auth, per-IP throttle on NEW handshakes — gates the DB key validation so an
# unauthenticated handshake flood cannot force a validate per attempt. Socket.IO reconnect
# backs off exponentially (~6 attempts/min per tab), so 10/min covers a few re-mounting tabs.
# WS_RATE_LIMIT_HANDSHAKE_MAX=10 # max handshakes per IP per window (default 10)
# WS_RATE_LIMIT_HANDSHAKE_WINDOW_MS=60000 # window in ms (default 60000 = 1 min)
# Cap on simultaneous sockets per API key (multi-tab dashboards + SDK clients on one key).
# WS_MAX_SOCKETS_PER_KEY=16 # default 16
# Per-instance fairness cap on the Integration Fabric ingress route (POST /api/ingress/:pluginId/:instanceId/*).
# Providers deliver every tenant's webhooks from one shared egress IP, so this is keyed on
# (pluginId, instanceId) instead of IP, so a noisy tenant gets 429'd on this bucket without spending
# its neighbors'.
# The global RATE_LIMIT_* tiers above skip this route; it is bounded only by this limit and INGRESS_IP_LIMIT below.
# Counted only for deliveries that pass signature verification; requests that fail it count against
# INGRESS_IP_LIMIT alone. A scheme 'none' route (ALLOW_UNSIGNED_INGRESS=true) passes every request.
# INGRESS_INSTANCE_LIMIT=120 # max requests per instance per window (default 120)
# INGRESS_INSTANCE_TTL=60000 # window in ms (default 60000 = 60s)
# Second bucket on the same window, keyed on the client instead of the path. It is checked before
# anything else, so it is the only bound on requests that fail verification. Every ingress request
# counts against it, including one the per-instance bucket then sheds, so a tenant that pushes a shared
# provider IP past it sheds its neighbors on that IP too. Sized 10x the per-instance default; raise it
# for a high-volume provider that delivers many tenants from one egress IP.
# INGRESS_IP_LIMIT=1200 # max ingress requests per client IP per window (default 1200)
# Integration Fabric delivery retry + retention (the queued path taken when QUEUE_ENABLED=true).
# INGRESS_MAX_ATTEMPTS=3 # delivery attempts before an event is dead-lettered (min 1)
# INGRESS_RETRY_DELAY_MS=5000 # base for exponential backoff between attempts (ms)
# INGRESS_DEDUP_RETENTION_DAYS=7 # days to keep ingress_events dedup rows (default 7). Dedup rows
# # are a delivery-id oracle, not an audit log — their payloads are
# # retired on dispatch. <=0 does NOT disable this prune (an
# # unpruned dedup table grows without bound); it falls back to 7.
# INGRESS_RETENTION_DAYS=90 # days to keep integration_delivery_failures (DLQ) rows. <=0
# # disables that prune only — set deliberately for
# # compliance-sensitive deployments that manage retention upstream.
# INGRESS_TIMESTAMP_TOLERANCE_SEC=300 # replay window for a declared ingress timestampHeader when the
# # manifest sets no toleranceSec (also the standard-webhooks
# # fallback). Must be a positive integer; invalid values fall back
# # to 300.
# Ingress reconciler: replays persisted events whose dispatch never completed (a crash between
# persist and dispatch, or an enqueue whose outcome was never recorded) using their ORIGINAL
# delivery id — a replay is idempotent against a job that did get enqueued, and re-checks the
# instance is still eligible first. Budget exhaustion marks the event failed and files a DLQ
# row, so recovery continues through the redrive endpoint instead of looping forever.
# INGRESS_RECONCILE_INTERVAL_MS=60000 # sweep interval; 0 disables the reconciler
# INGRESS_RECONCILE_GRACE_MS=60000 # age a 'pending' row must pass before it counts as stuck
# INGRESS_RECONCILE_BATCH_SIZE=50 # stale rows replayed per sweep, oldest first
# INGRESS_RECONCILE_MAX_ATTEMPTS=5 # replays per event before it goes terminal + DLQ (min 1)
# Webhook delivery reconciler: the outbound twin of the block above. Fan-out is fire-and-forget, so
# a hard crash between persisting a message and completing its POST used to lose the delivery with
# nothing left behind. Each delivery is now recorded before it is attempted and retired once a
# durable owner has it (handed to the queue, or completed inline), and this sweep replays whatever
# is still stranded, reusing the STORED idempotency key so a receiver can dedup the retry.
# WEBHOOK_RECONCILE_INTERVAL_MS=60000 # sweep interval; 0 disables the reconciler
# WEBHOOK_RECONCILE_GRACE_MS=60000 # age a 'pending' row must pass before it counts as stranded
# WEBHOOK_RECONCILE_BATCH_SIZE=50 # stranded rows replayed per sweep, oldest first
# WEBHOOK_RECONCILE_MAX_ATTEMPTS=5 # replays before the row goes terminal (min 1)
# WEBHOOK_OUTBOX_RETENTION_DAYS=7 # prune settled records after N days; a non-positive value
# falls back to 7 rather than letting the table grow forever
# Concurrency of the BullMQ workers that process queued webhooks and ingress events. Only meaningful
# when QUEUE_ENABLED=true. Ingress events keep their order within one conversation through a
# per-conversation lock, so raising INGRESS_WORKER_CONCURRENCY parallelizes unrelated conversations.
# Webhook deliveries are not ordered per chat, and raising WEBHOOK_WORKER_CONCURRENCY can reorder
# deliveries for one chat. Neither pool is isolated per webhook or per conversation:
# - A dead webhook receiver holds a slot for up to WEBHOOK_TIMEOUT per attempt. Once a webhook's
# attempt fails, jobs for it that start afterwards take a per-session slot, at most
# WEBHOOK_DEGRADED_SESSION_CONCURRENCY at a time (the rest wait in the delayed set without
# spending an attempt, each wait doubling up to 64 times WEBHOOK_RETRY_DELAY); a job already
# running when the failure lands is not counted. The first 2xx lifts the limit. Keep
# WEBHOOK_WORKER_CONCURRENCY above WEBHOOK_DEGRADED_SESSION_CONCURRENCY times the number of
# sessions whose receivers may fail at once, or those sessions fill the pool and every other
# webhook waits behind them.
# - An ingress job waiting on a busy conversation still holds a slot, so keep
# INGRESS_WORKER_CONCURRENCY above the largest burst on one conversation, and declare a
# conversationId pointer on the route (without one, the whole instance is one conversation).
# WEBHOOK_WORKER_CONCURRENCY=10 # webhook delivery workers (default 10)
# WEBHOOK_DEGRADED_SESSION_CONCURRENCY= # per-session cap on deliveries to failing webhook receivers,
# queued, and inline with QUEUE_ENABLED=false (the direct
# fallback for a rejected enqueue is not capped);
# default a quarter of the pool
# (WEBHOOK_WORKER_CONCURRENCY or WEBHOOK_DISPATCH_CONCURRENCY)
# INGRESS_WORKER_CONCURRENCY=10 # integration ingress workers (default 10)
# Opt-in to load plugins whose ingress routes declare signature.scheme 'none'.
# Such a route is an unauthenticated @Public endpoint: once an instance is provisioned
# against it, anyone who can reach the host can POST a forged payload that triggers
# outbound WhatsApp sends. Default off — every major provider (Meta/Svix, Twilio,
# Chatwoot) offers HMAC, and both official ingress plugins (chatwoot-adapter,
# supabase-otp-hook) use a signed scheme. Only set this for a provider that genuinely
# offers no HMAC, and front the route with a network/reverse-proxy ACL.
# ALLOW_UNSIGNED_INGRESS=false
# =============================================================================
# MCP (Model Context Protocol) — Agent / AI-assistant tool server
# =============================================================================
# Off by default. When enabled, mounts a stateless Streamable-HTTP MCP server
# at POST /mcp on the same port. See docs/24-mcp-integration.md for details.
# MCP_ENABLED=true
# Mount read-only tools only (no sends, no mutations). Recommended for observer agents.
# MCP_READONLY=true
# Per-key sliding-window rate limit for tool calls.
# Any blank/non-positive/non-numeric value falls back to the default.
# MCP_RATE_LIMIT_MAX=60 # max tool calls per key per window (default 60)
# MCP_RATE_LIMIT_WINDOW_MS=60000 # window size in ms (default 60000 = 1 min)
# Pre-auth, per-IP sliding-window throttle for the /mcp and /api/admin/queues mounts (gates
# invalid-key requests before key validation, since the raw mounts bypass the global throttler).
# Each mount counts separately. Same fallback rules.
# MCP_IP_RATE_LIMIT_MAX=120 # per IP per window: JSON-RPC messages on /mcp, HTTP requests on /api/admin/queues (default 120)
# MCP_IP_RATE_LIMIT_WINDOW_MS=60000 # window size in ms (default 60000 = 1 min)
# =============================================================================
# GLOBAL MESSAGE SEARCH
# =============================================================================
# Global message search (optional). Default ON, using the built-in DB full-text
# provider (Postgres tsvector/GIN, SQLite FTS5) — zero external services.
# SEARCH_ENABLED=true # set to false to disable the /search route + module entirely
# SEARCH_PROVIDER=auto # auto | builtin-fts | none (plugin ids selectable once Spec 2 lands)
# SEARCH_LIMIT_MAX=100 # hard cap on the `limit` query param
# =============================================================================
# DASHBOARD STATISTICS
# =============================================================================
# The /stats aggregates scan the messages table; responses are memoized in-process for this
# TTL so dashboard polling doesn't re-run the scans on every request.
# STATS_CACHE_TTL_MS=30000 # memo TTL in ms (default 30000 = 30s); 0 disables the memo
# =============================================================================
# PLUGINS
# =============================================================================
# Where installed plugin packages live. This is also the default; it must stay inside the data
# directory, because that is where the plugin registry and every plugin's persisted storage are —
# plugin code kept elsewhere (e.g. in a container layer) is lost while its config and secrets
# survive, leaving registry entries that point at code that no longer exists.
PLUGINS_DIR=./data/plugins # Plugin directory (default: ./data/plugins)
# Where the plugin REGISTRY and per-plugin storage live (default: ./data). Distinct from
# PLUGINS_DIR, which is where plugin packages are installed. It moves plugin state only: the
# databases, sessions, media and auth dirs each carry their own path knob and none of them follow
# this one.
# Moving it does NOT carry the existing state across: point it at an empty directory and the
# gateway starts with no record of installed plugins, even though their packages are still under
# PLUGINS_DIR. Copy `registry.json` and every `<plugin id>/key-*.json` from `<old root>/plugins`
# into `<new root>/plugins` when you change this: carrying the registry alone leaves each plugin
# enabled with its persisted ctx.storage gone. Copy those files rather than the directory, because
# on the default layout `<old root>/plugins` IS PLUGINS_DIR, and moving it would take the installed
# packages away from the loader.
# PLUGIN_STATE_DIR=./data
# Cap on a plugin .zip downloaded by install-from-URL (matches the 5 MB upload limit). A value that
# is not a positive integer fails the boot.
# PLUGIN_DOWNLOAD_MAX_BYTES=5242880 # default 5 MiB
# Require a #sha256=<hex> integrity pin on EVERY plugin install from a URL (production default;
# https alone authenticates the channel, not the bytes-as-reviewed). false lifts the requirement.
# PLUGIN_INSTALL_REQUIRE_PIN=true
# Host-side budget for one sandboxed plugin capability call; a wedged call is failed and its in-flight
# slot freed (the late-settling work is only logged, not cancelled). A value that is not a positive
# integer, or above 536870911 ms (send verbs wait four times it), fails the boot.
# PLUGIN_CAP_TIMEOUT_MS=30000 # default 30s
# Per-plugin bound on ctx.storage writes; writes beyond it are rejected. A value that is not a positive
# integer fails the boot.
# PLUGIN_STORAGE_MAX_BYTES=52428800 # default 50 MiB
# Allow an https plugin-download URL to redirect to a plain-http hop (default: refused — the package
# is executable code and an http hop exposes it to on-path substitution). Enable only if your vendor
# or release host genuinely downgrades (e.g. behind TLS-terminating infrastructure). Only a hop that
# downgrades AFTER an https hop is refused; a chain that starts on plain http is not itself a
# downgrade (though an http→https→http chain is still refused).
# PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS=false
# Catalog the plugin browser reads its list of installable packages from. Point it at your own
# published catalog to install from a private index instead of the public one. 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.
# PLUGIN_CATALOG_URL=https://raw.githubusercontent.com/rmyndharis/OpenWA-plugins/main/plugins.json
# Tell dashboard admins when a newer OpenWA release exists. The gateway asks api.github.com for the
# latest release through the SSRF guard, caches the answer for six hours (fifteen minutes after a
# failure) and shows nothing while the host has never been reached. The request goes out directly, not
# through HTTP(S)_PROXY. Set to false to make no request at all.
# UPDATE_CHECK_ENABLED=true
# =============================================================================
# SECURITY
# =============================================================================
# First-boot seed for the initial ADMIN key, read only when no API key exists yet. Leave it
# empty to generate a random key (data/.api-key). Changing it later has no effect: rotate by
# minting a new ADMIN key via POST /api/auth/api-keys and revoking the old one. In production the
# boot refuses a value shorter than 32 characters or one that is a known default.
# API_MASTER_KEY=
# First-boot default admin key. By default a cryptographically random key is
# generated (printed in the startup banner / written to data/.api-key). Set this
# to true ONLY for local development to seed the well-known, insecure
# `dev-admin-key` instead. Ignored when API_MASTER_KEY is set.
# ALLOW_DEV_API_KEY=true
# Where that generated first-boot key 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 instead.
# BOOTSTRAP_KEY_FILE=./data/.api-key
# Optional server-side pepper for API-key hashing (HMAC-SHA256 instead of plain SHA-256).
# Recommended in production; set it before the first boot. Setting or changing it on an install
# with keys locks out every key, admin included: recover by restoring the previous pepper, or by
# clearing api_keys in the main database and restarting so a new admin key is seeded.
# Leave unset to keep the current (unpeppered) behaviour.
# API_KEY_PEPPER=
# Browsers auto-upgrade http:// to https:// for the dashboard (CSP upgrade-insecure-requests).
# On by default in production, which is correct behind a TLS-terminating reverse proxy.
# Set false when serving the dashboard over plain HTTP (e.g. a trusted private network, or a
# direct host:port deployment) — otherwise the browser upgrades the UI's own scripts to https,
# the non-TLS server can't answer, and the dashboard renders a BLANK WHITE SCREEN. (#611, #731)
# CSP_UPGRADE_INSECURE_REQUESTS=false
# =============================================================================
# DEVELOPER SETTINGS
# =============================================================================
# ENABLE_SWAGGER=true # Serve the API docs + Swagger UI at /api/docs. Default: ON outside
# production, OFF under NODE_ENV=production. Left commented on purpose:
# this file ships NODE_ENV=production, so an uncommented value here would
# pin the opt-in the production default exists to withhold. The mount is
# not behind the API-key guard, so serving it exposes the schema and the
# exact running version to anyone who can reach the port.
# VALIDATION_ERROR_DETAIL=true # Return field-level messages on a 400 instead of a generic one.
# Default: ON outside production, OFF under NODE_ENV=production, so
# a rejected request there does not reflect the DTO shape back to
# the caller. Set true to debug an SDK or integration against a
# production instance without flipping NODE_ENV; false forces it off
# everywhere. Same exact-string contract as ENABLE_SWAGGER above.
# BODY_SIZE_LIMIT=25mb # Max request body size (base64 media sends ride in the JSON body)
# Units b, kb, mb, gb, tb, pb (a bare number is bytes); 0 or an
# unknown unit (e.g. 50M, 50MiB) fails the boot.
# Aggregate cap on request-body bytes buffered across ALL connections. Once exceeded, new requests
# get 503 + Retry-After without their body being read (protects against slow-body memory pinning).
# Keep it >= 2 x BODY_SIZE_LIMIT: an API key may hold at most half of it, so below that a full-size
# body sent with a key is refused with 413. Default: 4 x BODY_SIZE_LIMIT (100 MiB with the default
# 25mb cap). Below 3 x, unkeyed traffic at its pool limit leaves keyed requests less than one
# full-size body; below 2 x, one full-size unkeyed body can leave no room for ingress deliveries.
# Requests without an active API key (ingress webhooks, unauthenticated callers) share a quarter of
# it (at least two BODY_SIZE_LIMIT); the rest is kept for keyed requests. Each API key may hold at