-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Expand file tree
/
Copy path.env.example
More file actions
512 lines (461 loc) · 33.8 KB
/
Copy path.env.example
File metadata and controls
512 lines (461 loc) · 33.8 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
# =============================================================================
# OpenWA - Environment Configuration
# =============================================================================
# Copy this file to .env and customize as needed.
# This is the Single Source of Truth for all configuration.
# =============================================================================
# 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.
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
LOG_LEVEL=info # error | warn | info | debug
# Console output format. Default: json in production (containers, log aggregators),
# human-readable pretty otherwise. Force one with: json | pretty
# LOG_FORMAT=pretty
# 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 that must stay down can simply be left as-is.
AUTO_START_SESSIONS=false
# 0 = unlimited. Set a positive integer to cap concurrently running/initializing sessions.
# A FAILED session is evicted by design (no live engine), 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 in
# production, 0 elsewhere (a dev hot-reload/Ctrl+C is not slowed). Capped at 30000. A second signal
# forces an immediate exit. In Docker, keep `stop_grace_period` >= this + your worst-case teardown.
# SHUTDOWN_DELAY_MS=3000
# Domain Configuration
DOMAIN=localhost
# 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
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
# =============================================================================
# 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`: WhatsApp Web
# renders in the browser's language and the onboarding-modal handling below matches visible text.
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.
# Required in the Docker image and on hosts without a bundled browser (e.g. Alpine/ARM).
# PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# 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.
# 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). 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 active build + its source (pinned/auto/native) is shown on the dashboard's
# Infrastructure page.
# WWEBJS_WEB_VERSION=2.3000.1040641150-alpha
# WWEBJS_WEB_VERSION=off
# 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), so there is no
# global Node.js version floor. Node.js 22 LTS is recommended for all deployments.
BAILEYS_AUTH_DIR=./data/baileys
# NOTE: proxy egress is configured PER SESSION via the proxyUrl/proxyType fields on
# POST /api/sessions (whatsapp-web.js engine only), NOT via environment variables.
# Pull the FULL message-history archive on connect (large). 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
# Device name shown in WhatsApp Settings → Linked Devices for NEW pairings (existing sessions keep
# the name they were paired with until re-linked). Default: OpenWA.
# BAILEYS_BROWSER_NAME=
# 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
# 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). 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.
# BAILEYS_SESSION_STORE_MAX_ENTRIES=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
# =============================================================================
# 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.
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)
DATABASE_LOGGING=false
# Auth/audit (main) DB schema management. Default ON (zero-config first boot).
# Set to "false" to manage the api_keys/audit_logs schema via the bundled main-owned
# migration instead of synchronize (migrationsRun creates them at boot).
MAIN_DATABASE_SYNCHRONIZE=true
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)
# Postgres pool/query timeouts (ms). Defaults are conservative; set any to 0 to disable.
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 # Enable Redis for queue and caching
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-backed caching, independent of the queue. Off by default (in-memory cache).
# 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.
REDIS_CONNECT_TIMEOUT_MS=5000
# REDIS_USERNAME=
# REDIS_PASSWORD=
# =============================================================================
# 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
# S3/MinIO settings (if STORAGE_TYPE=s3, auto-configured if MINIO_BUILTIN=true)
# 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
# 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 # Delay between retries in ms
# WEBHOOK_DISPATCH_CONCURRENCY=16 # max active inline webhook deliveries per process
# WEBHOOK_DISPATCH_MAX_QUEUED=1000 # max inline deliveries parked behind the concurrency cap
# WEBHOOK_SHUTDOWN_DRAIN_MS=5000 # on shutdown, in-flight deliveries 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.
# WEBHOOK_MAX_PER_SESSION=16 # max webhooks registered per session; new registrations 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; 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
# Days to keep webhook delivery-failure records before pruning them (default 90; set <= 0 to disable).
# WEBHOOK_FAILURE_RETENTION_DAYS=90
# 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.)
WEBHOOK_SSRF_PROTECT=true
# Expose the FULL sender `contact` field set (id, number, shortName, business flags, isBlocked,
# labels, …) on the message.received webhook + websocket payload. 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).
# 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
# 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 non-positive/garbage value falls back to the default.
# 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: disabling also makes hasMedia report false and removes the
# # media field from webhooks and the dashboard.
# STORE_EPHEMERAL_MESSAGES=true # Set to false to skip persisting and dispatching incoming
# # WhatsApp disappearing messages (ephemeralDuration > 0).
# # Default: true (backward compatible — store everything).
# 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 400)
# MEDIA_DOWNLOAD_TIMEOUT_MS=30000 # abort a slow media download (default 30s)
# Inbound media download parallelism (how many inbound media items download at once). Each concurrent
# download holds memory up to MEDIA_DOWNLOAD_MAX_BYTES; this bounds N. A non-positive/garbage value
# falls back to the default.
# INBOUND_MEDIA_CONCURRENCY=4 # default 4
# 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. A non-positive/garbage value falls back to the default.
# 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. A non-positive/garbage value falls back.
# 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. Non-positive/garbage values fall back to the defaults.
# STATUS_ORPHAN_SWEEP_INTERVAL_MS=3600000 # default 1h
# STATUS_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 non-positive/garbage value falls back.
# 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_EXPORT_TTL_MS=3600000 # auto-delete an export archive after this long (default 1h)
# 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).
# S3_REPROBE_INTERVAL_MS=60000 # default 60s
# STORAGE_EXPORT_SWEEP_MAX_AGE_MS=86400000 # boot sweep deletes export archives orphaned by a restart, older than this (default 24h)
# 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 or unparseable value falls back to the default instead of disabling it.
# 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)
# =============================================================================
# The "medium" tier is the one enforced on the API; short/long are optional extra tiers.
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). Any blank/non-positive/non-numeric value falls back to the default.
# 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 — a noisy tenant gets 429'd without throttling its neighbors.
# Independent of, and in addition to, the IP-keyed tiers above.
# INGRESS_INSTANCE_LIMIT=120 # max requests per instance per window (default 120)
# INGRESS_INSTANCE_TTL=60000 # window in ms (default 60000 = 60s)
# 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)
# Concurrency of the BullMQ workers that process queued webhooks and ingress events. Only meaningful
# when QUEUE_ENABLED=true. Ordering within one conversation is preserved by a per-conversation lock,
# so raising these parallelizes unrelated conversations rather than reordering them.
# WEBHOOK_WORKER_CONCURRENCY=10 # webhook delivery workers (default 10)
# 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 mount (gates invalid-key requests before
# key validation, since the raw mount bypasses the global throttler). Same fallback rules.
# MCP_IP_RATE_LIMIT_MAX=120 # max requests per IP per window (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
# =============================================================================
PLUGINS_DIR=./data/plugins # Plugin directory
# Cap on a plugin .zip downloaded by install-from-URL (matches the 5 MB upload limit). A non-positive
# or non-numeric value falls back to the default.
# PLUGIN_DOWNLOAD_MAX_BYTES=5242880 # default 5 MiB
# 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 non-positive or non-numeric
# value falls back to the default.
# PLUGIN_CAP_TIMEOUT_MS=30000 # default 30s
# Per-plugin bound on ctx.storage writes; writes beyond it are rejected. A non-positive or non-numeric
# value falls back to the default.
# 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
# =============================================================================
# SECURITY
# =============================================================================
# Master API key (leave empty to disable, or set to secure value)
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
# Optional server-side pepper for API-key hashing (HMAC-SHA256 instead of plain SHA-256).
# Recommended in production. Note: setting or changing it invalidates all existing key hashes,
# so re-issue keys after enabling. 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 # Enable API documentation at /api/docs (set false to disable on exposed deployments)
BODY_SIZE_LIMIT=25mb # Max request body size (base64 media sends ride in the JSON body)
# 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 >= BODY_SIZE_LIMIT. Default: 4 x BODY_SIZE_LIMIT (100 MiB with the default 25mb cap).
# INFLIGHT_BODY_BUDGET_BYTES=104857600