Changelog
Auto-generated from the project CHANGELOG at build time.
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.7.7] - 2026-06-28
Added
- Dashboard chat thread UX: URLs in messages are now clickable links, WhatsApp text formatting (bold/italic/strikethrough/monospace) renders, images open in a photo lightbox, and the scroll position is remembered per chat. Thanks @softronicve. (#484)
- The Infrastructure page now shows the actual WhatsApp Web build the whatsapp-web.js engine is using (e.g.
2.3000.1042251103-alpha) and how it was chosen (pinned viaWWEBJS_WEB_VERSION, auto-resolved, or native), surfaced via/infra/status. The engine card previously showed only the npm library version (whatsapp-web.js 1.34.7), which is unrelated to the WA Web build that actually governs connection stability. (#488) - Infrastructure data backup & restore: export all Data-DB tables to a JSON file and import them back, wired into the database-switch flow. When you change the database backend, the restart dialog now warns that the new database starts empty and offers a one-click backup before switching; a storage switch warns that existing media is not moved. (#488)
- The Infrastructure page flags any database/redis/storage setting that is pinned by an environment variable (its running value differs from the saved config), so it's clear a dashboard change won't apply until that variable is unset, instead of the control silently having no effect. (#488)
- The storage card now warns when S3 is selected but unreachable (a dead/misconfigured bucket no longer shows a misleading green badge), via a new
s3Availablefield on/infra/status; the check re-probes (throttled) rather than latching the boot-time result, so a bundled MinIO that comes up after the app self-corrects. A backup import that exceeds the request size limit now reports an actionable message (raiseBODY_SIZE_LIMIT) instead of a bare "Payload Too Large". (#488) - Data-loss & availability hardening for the new infra flows: importing a backup now refuses an empty/garbage file (it no longer wipes the database and reports success) and asks for confirmation first; selecting the built-in Postgres/MinIO no longer crash-loops a production boot on the default-secret guard (the bundled containers run on the internal-only network); and a transient failure fetching the WhatsApp Web version is no longer cached, so it retries instead of permanently falling back. (#488)
- Human-readable console logs: the
LoggerServicenow renders a colorized, NestJS-style line ([OpenWA] <pid> - <timestamp> <LEVEL> [Context] <message>with dimmedkey=valuemetadata and stack traces on their own line) instead of always emitting raw JSON, so application logs line up visually with NestJS's own framework logs. The format defaults to structured JSON in production (NODE_ENV=production, for containers and log aggregators) and human-readable pretty everywhere else, and can be pinned withLOG_FORMAT=pretty|json.NO_COLOR/FORCE_COLORare honored. JSON output is byte-for-byte unchanged when selected. (#469)
Fixed
- whatsapp-web.js sessions that scanned the QR then immediately disconnected (looping
qr → authenticating → disconnected) when noWWEBJS_WEB_VERSIONwas pinned — the common Docker default. The engine now auto-resolves the current known-good WhatsApp Web build from the wppconnectwa-versionregistry and pins it, instead of relying on whatsapp-web.js's auto-select which could latch onto an incompatible bleeding-edge build that authenticates but never reaches "ready".WWEBJS_WEB_VERSION=offkeeps the old native auto-select; an explicit version still pins exactly. (#488) - Dashboard message-analytics charts no longer silently vanish on PostgreSQL:
/stats/messages(top-chats) ordered by an unquoted mixed-case alias (ORDER BY messageCount), which PostgreSQL case-folds and rejects withcolumn "messagecount" does not exist(500). It now orders by the aggregate directly, so the query — and the dashboard charts it feeds — work on PostgreSQL as they already did on SQLite. The chart section also shows a clear notice on a real error instead of rendering nothing (it previously treated every error as a non-admin 403 and hid itself). (#488) - The Infrastructure page now shows what is actually running for the database, Redis, storage, and engine — the badge/selected card follow the live
/infra/statusinstead of the saveddata/.env.generated, which could disagree when a setting is supplied via environment variable. Previously a stack running PostgreSQL viaDATABASE_TYPE=postgresshowed "SQLite" (the first-run default still in the saved file)./infra/statusnow also reportsredis.enabled. (#488) - The "Use Built-in PostgreSQL/Redis/MinIO Container" toggles now reflect whether OpenWA's bundled container is actually running and backing the service (detected from the labeled container + the configured host), not just the saved intent — so a Postgres stack started via the
postgrescompose profile correctly shows built-in, and a stopped/external one shows off. Falls back to the saved flag when Docker isn't reachable. (#488) - Switching away from a built-in backend (built-in → external/disabled) now tears down the bundled container reliably even after a page reload: removal is derived server-side from the saved
*_BUILTINflags + the running labeled containers, instead of only trusting the browser's in-memory list (which reset on reload and left the container orphaned). Named volumes are preserved, so re-enabling reuses the data. (#488) - Dashboard "by type" message chart: each message type now gets a stable, distinct color keyed by type name (with a deterministic hash fallback) instead of a rotating array-index palette, so a slice keeps its color when the set of present types changes between requests and types past the eighth no longer collide. (#486)
- Removed the oversized decorative watermark icons bleeding through the dashboard stat cards. (#488)
- Dashboard switches for the database, Redis, and storage backends now actually take effect after a restart, matching how the engine switch already worked. The bundled
docker-compose.ymlforwards these settings blank (${VAR:-}) so the dashboard's saved selection (indata/.env.generated) is honored, while a real value set in your.env/host still pins it (and the UI now says so). Previously compose forwarded concrete defaults that silently shadowed the dashboard's choice, so switching had no effect under Docker. (#488)
Changed
- ⚠️
docker-compose.ymlnow forwards the S3 credentials under their canonical namesS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY(and addsS3_REGION), matching what the app and dashboard read. The legacyS3_ACCESS_KEY/S3_SECRET_KEYare still accepted as a fallback, so existing setups keep working, but updating your.envto the canonical names is recommended. (#488) - ⚠️ Database/Redis/storage selection is now sourced from the dashboard-managed
data/.env.generatedwhen not pinned by an environment variable (see Fixed, above). If you previously relied on the compose file's concrete defaults overriding a staledata/.env.generated, set the value explicitly in your.env/host to pin it. First-run defaults (SQLite, local storage, Redis off) are unchanged. (#488)
[0.7.6] - 2026-06-26
Changed
- CI now runs the dashboard unit tests, and re-runs the client-SDK suites when a server DTO or the engine interface changes (not only on SDK edits), so contract drift is caught at its source. (#478)
- The Postgres connection pool now applies query/connection timeouts (
statement_timeout,idleTimeoutMillis,connectionTimeoutMillis) on the runtime connection, so a stuck query or a saturated pool fails fast instead of hanging requests. The migration connection keeps idle/connection timeouts but neverstatement_timeout, so a longCREATE INDEXis not aborted. Env-tunable (DATABASE_STATEMENT_TIMEOUT_MS,DATABASE_IDLE_TIMEOUT_MS,DATABASE_CONNECTION_TIMEOUT_MS), conservative defaults,0disables; SQLite is unaffected. (#480)
Fixed
- A plugin whose enable failed after it had already subscribed hooks no longer leaves stale hook registrations behind; a later successful enable could otherwise dispatch each event to the plugin more than once. (#477)
- The WebSocket
message.ackevent now carries the same{ id, messageId, status, ack }shape over the socket as the matching webhook does — the socket previously omittedidand the legacyack. (#477) - Reconnect timers are no longer stacked when two disconnects arrive back-to-back, and a terminal engine failure now cancels any pending reconnect so a
FAILEDsession cannot be resurrected by a stale timer. (#477) - The dashboard recovers from a stale lazy-loaded chunk after a redeploy with a single guarded reload instead of replacing the whole UI with the error screen; the Content-Security-Policy
img-srcnow allowsblob:so the outgoing image-attachment preview renders. (#477) - The Baileys engine's number-check (
GET /sessions/:id/contacts/check/:number) now returns a neutral<phone>@c.usid, matching the whatsapp-web.js engine, instead of a raw@s.whatsapp.netid. (#477) - The data export/import now includes the
lid_mappingsresolution cache, so a backup/restore or a SQLite↔PostgreSQL migration no longer drops it. (#477) - The JavaScript client SDK applies the JSON
Content-TypeandX-API-Keyafter caller-supplied headers, so they can no longer be overridden bydefaultHeaders(matching the Python and PHP SDKs); an unfollowed redirect (HTTP status0) now raises a clear error instead ofOpenWA API 0. (#478) - The infrastructure status endpoint reports the active S3 bucket when storage is in S3 mode, instead of only the unused local media path. (#478)
- The migration CLI now honors the dashboard-written
data/.env.generated, somigration:run:prodtargets the configured database (e.g. PostgreSQL) instead of silently defaulting to SQLite. (#479) - The first-run generated config writes
STORAGE_LOCAL_PATH(the key the backend reads) instead of the deadSTORAGE_PATH. (#479) - The Sessions page now keeps the shared dashboard cache in sync, so creating/stopping/deleting a session no longer leaves the Dashboard showing stale session counts or status until a refresh. (#479)
Security
- The startup banner prints the full admin API key only when it is first created; on subsequent boots the key is masked, so the live credential is not re-written to the log pipeline on every restart. (#478)
- The production secret guard now rejects a placeholder
REDIS_PASSWORD(e.g.changeme); an empty/unset password is still allowed so passwordless private-network Redis continues to boot. (#478) - The published PHP SDK package no longer ships its test suite, PHPUnit config, or
composer.lock. (#478) - The production weak-secret guard now also rejects the common defaults
123456,qwerty,root,test, anddemo. Matching stays an exact full-value comparison, so a strong secret that merely contains one of these words is not blocked. (#480) - The gateway now logs a startup warning when
API_KEY_PEPPERis unset in production (stored API-key hashes then use plain SHA-256). Advisory only — enabling a pepper invalidates existing key hashes, so it stays opt-in and is never enforced. (#480)
[0.7.5] - 2026-06-26
Fixed
- The stats/analytics endpoint no longer crashes on PostgreSQL. The message time-series query grouped by an output alias named
timestamp— a reserved type keyword in PostgreSQL — soGROUP BY timestampwas not read as the alias and the query failed with "column m.createdAt must appear in the GROUP BY clause" (SQLite tolerated it, so unit tests on the SQLite test DB never caught it). The alias is nowbucket; the API response field is unchanged. (#474)
Documentation
- Added a Traefik / Coolify reverse-proxy guide to the troubleshooting FAQ: WebSocket forwarding, the
docker-proxydouble-hop that causes intermittent504s behind Coolify (held-open Socket.IO connections exhausting the pool to the single-port upstream), and idle-timeout tuning. (#467)
[0.7.4] - 2026-06-25
Fixed
- WebSocket events are now delivered exactly once to a client subscribed to overlapping rooms (for example both a specific event and the
*wildcard for the same session). The real-time fan-out previously sent one copy per matching room, so such a client could receive the same event two to four times. The bundled dashboard was unaffected (its pages subscribe to disjoint rooms); this fixes duplicate delivery for custom WebSocket clients. (#468) session.authenticatedandsession.disconnectedare now emitted over the WebSocket (with{ phone, pushName }and{ reason }respectively), matching the existing webhook payloads. They were advertised as subscribable but were only ever delivered via webhooks, so socket subscribers never received them. (#468)- The infrastructure status endpoint (
GET /api/infra/status) now reports the actual media storage path — it readsstorage.localPath(default./data/media), the key the storage service uses — instead of a non-existentstorage.pathkey that always reported./uploads. (#472) - The JavaScript client SDK's
timestampfields (MessageResponse,MessageRecord) are documented as Unix seconds (the real passed-through value, previously mislabelled milliseconds), and the PHP SDK'sClient::request()is correctly typed (mixed $body): mixed). (#472)
Changed
- The WebSocket
group.join/group.leave/group.updateevents are no longer accepted as socket subscriptions — they have no engine source and were never delivered on the socket. Subscribing to one now returns a clear validation error instead of silently never delivering. They remain reserved on the webhook side. Webhook subscriptions are unaffected. (#468)
Documentation
- The
docs/set was reconciled against the v0.7.3 implementation — API specification and collection, operational runbooks, troubleshooting, system architecture, security, database, dashboard, SDK, and plugin docs — correcting drift accumulated across releases. (#471)
[0.7.3] - 2026-06-25
Added
- MCP server (opt-in,
MCP_ENABLED=true): exposes a curated ~39-tool agent surface (sessions, messaging, contacts, basic group ops, webhook reads) over the Model Context Protocol atPOST /mcp, on the existing single port. Off by default — the MCP SDK is not loaded unless enabled, and every REST route is unchanged. Tools call the existing services and reuse the same API-key auth, role, and per-session scoping as REST; reads vs writes are tiered andMCP_READONLY=truemounts read tools only. Destructive/privileged operations are deliberately excluded from the surface. (relates to #256; salvages result-shaping from #461 — thanks @tobiasstrebitzer) - Client SDKs: official, hand-written client libraries for the REST API in JavaScript/TypeScript (
@rmyndharis/openwa), Python (rmyndharis-openwa), and PHP (rmyndharis/openwa), replacing the previous single-file stub (sdk/). Each exposes the same fluent resource surface —sessions,messages,contacts,groups,chats,webhooks,labels,channels,catalog,status,templates,health— over an injectable HTTP transport, with a typed error hierarchy mapping the NestJS error envelope (401/403/404/409/429/501) to typed exceptions and a timeout error. Request/response types mirror the server DTOs exactly. The JavaScript package ships dual CJS + ESM with bundled type declarations (consumable via bothrequire()and nativeimport(), guarded by a packaging smoke test); the Python package ships PEP 561 type information (py.typed); the PHP package is PSR-4 / Guzzle 7. Redirects are never auto-followed (so the API key is never re-sent to a redirect target), auth/JSON headers always take precedence over caller-supplied defaults, path segments are percent-encoded, and a base-URL path prefix (e.g. behind a reverse proxy) is preserved. The SDK does not retry — wrap calls with your own backoff. Published at0.1.0on npm (@rmyndharis/openwa), PyPI (rmyndharis-openwa), and Packagist (rmyndharis/openwa). (#463)
Changed
- CI now runs the JavaScript, Python, and PHP client SDK test suites (path-filtered to
sdk/**, including the dual-format CJS/ESM packaging smoke test), and the Packagist mirror of the PHP SDK is gated on its tests passing so a broken SDK can no longer auto-publish.
Fixed
- Reconnection no longer stalls when a wedged browser fails to shut down: the engine teardown during an automatic reconnect is now time-bounded (10s, matching every other teardown), so a stuck Chromium can't leave a session permanently disconnected without self-healing. Affects the
whatsapp-web.jsengine; recover an already-stuck session with force-kill. - Message timestamps are now consistently returned as a number on both SQLite and PostgreSQL. PostgreSQL previously returned the
bigintcolumn as a string, which broke strictly-typed SDK clients and arithmetic in non-coercing consumers. - A blank
DATABASE_PASSWORDforwarded by the bundled Docker Compose file is now treated as unset, so an external-PostgreSQL password saved via the dashboard is applied instead of being shadowed by the empty value (a real host/.envvalue still keeps top precedence). - The Python and PHP SDKs now treat an unfollowed redirect (any
3xx) as an error response, matching the JavaScript SDK. Redirects are never followed (so the API key is never re-sent to the target), which makes a3xxan unusable result rather than a fake success. - Duplicate inbound webhook deliveries: a single inbound WhatsApp message could reach a registered webhook (and the
messagestable) more than once because the engine can re-fire the message event. Inboundmessage.receivedis now de-duplicated server-side, enforced by aUNIQUE(sessionId, waMessageId)constraint (added with a lossless de-duplicating migration), so each message is persisted and dispatched once. The guard fails open — a transient DB error still delivers the message. Webhook delivery remains at-least-once (engines re-fire, failed deliveries retry), so handlers should still be idempotent on theX-OpenWA-Idempotency-Keyheader, now documented under Webhook Delivery Semantics. (#464)
[0.7.2] - 2026-06-24
Added
- Sessions (Baileys): pre-connection chat history is now persisted. On a fresh link Baileys pushes the recent (and, with
BAILEYS_SYNC_FULL_HISTORY=true, full) message history viamessaging-history.set; these batches are now mapped and saved into the messages table for the chat view, so a newly linked session shows past conversations instead of an empty panel. The batches are de-duplicated and stamped with each message's real timestamp, and are persisted only (no webhook/hook/websocket dispatch, since they predate the live session). Sender push-names from the history are also harvested so chats show names rather than bare ids, and each chat's last-message preview and sort time are seeded from the history so the chat list no longer reads "No messages yet". - Sessions (Baileys): chat display names are now backfilled on connect. Baileys 6.7.x frequently skips the initial app-state sync (the state machine goes Online before it runs when the first history notification is non-processable) and the
PUSH_NAMEsync can fail to decrypt, so chats showed bare ids/numbers. On connection open the adapter now fetches group subjects viagroupFetchAllParticipatingand re-triggers an app-state resync (best-effort) to recover saved contact names; both are non-fatal and complement the push-names that arrive on live messages. - Webhooks: an opt-in
WEBHOOK_CONTACT_DETAILSflag enriches themessage.receivedpayload's sendercontactobject with the free, already-cached WhatsApp contact fields —id,number,shortName,type,isMyContact,isWAContact,isBusiness,isEnterprise,verifiedName,verifiedLevel,isBlocked, andlabels(IDs) — alongside the existingname/pushName. Off by default (the payload keeps the minimalname/pushName). All fields are read synchronously from the contact already fetched per message, so no extra WhatsApp API calls are made (profile picture and about/status are intentionally excluded to avoid rate-limit/ban risk).
Fixed
- Sessions (Baileys): when a session is logged out — unlinked from the phone or via the API — the now-invalid on-disk auth state is cleared, so re-linking shows a fresh QR instead of getting stuck silently reloading the dead credentials. (#453 — thanks @ulises2k)
- Webhooks: registering a webhook (
POST /sessions/:id/webhooks) to a host whose DNS lookup rejects (NXDOMAIN, or a transientEAI_AGAIN/ESERVFAILunder resolver pressure) now returns400 Could not resolve host: <host> (<code>)instead of a generic500 Internal server error. The SSRF guard's DNS deadline already mapped resolution timeouts and empty results to a 4xx; a rejected lookup leaked the raw DNS error, which surfaced as an intermittent 500 during back-to-back session-create → webhook-register flows. - Infrastructure: the dashboard config form no longer shows Server, Webhook, and Rate-Limit sections that were never persisted — they returned a fake "saved" while silently discarding every value. The form now exposes only the settings it actually writes (Database, Redis/Queue, Storage, Engine); the removed settings remain configurable via environment variables.
- Infrastructure: data export/import (the documented backup and SQLite↔PostgreSQL migration flow) is now complete. It previously exported and restored only sessions, webhooks, messages, and message batches — so a restore silently lost all message templates and stored Baileys messages (cascade-deleted with the old sessions and never re-imported) and dropped every webhook's filters, causing a filtered webhook to come back firing on all events. Templates, stored Baileys messages, and webhook filters now round-trip intact.
- Engine selection: pinning the engine from the environment works again after the v0.7.1 compose change. The bundled compose files forward
ENGINE_TYPEinto the container again (- ENGINE_TYPE=${ENGINE_TYPE:-}) and the app treats a blank value as unset, so an.env/hostENGINE_TYPE=baileysis honoured while the dashboard's Infrastructure > Engine selection still wins when no engine is pinned..env.exampleno longer shipsENGINE_TYPEpre-pinned. Upgrade note: if you relied onENGINE_TYPE=baileysin your.env, confirm the active engine after upgrading. (#453 — thanks @ulises2k)
Security
- Infrastructure: configuration values saved from the dashboard are now rejected if they contain a line break, which could otherwise write an extra
KEY=valueline intodata/.env.generatedand inject an arbitrary environment variable on the next boot.
[0.7.1] - 2026-06-24
Added
- Dashboard: a Message Analytics section — a period selector (24h / 7d / 30d) with messages-over-time, messages-by-type, and top-chats charts, sourced from the existing statistics endpoints. The charting bundle is code-split so it loads only with the Dashboard, not on the login screen.
- Infrastructure: an Engine Configuration tile to pick and configure the active WhatsApp engine (whatsapp-web.js / Baileys), mirroring the Database tile. Selecting an engine persists the choice and applies on restart.
Changed
- Dashboard: the Messages Today card is now populated from real data, and the previously-empty API Calls card is replaced with a Total Messages metric. The sidebar version is now read live from the backend, so a stale-built bundle no longer shows the wrong version.
- Plugins: the WhatsApp engine adapters are no longer listed as plugin cards — they are configured under Infrastructure → Engine. The Plugins page is now extensions-only.
- Plugins config dialog: the Configuration/Sessions and install tabs use a cleaner segmented control; the modal caps its height and scrolls its body with a pinned header and footer (Save is always reachable) and is wider for config-heavy plugins.
- ⚠️ Deployment: the bundled docker-compose files no longer pin
ENGINE_TYPE, so the active engine can be chosen from Infrastructure → Engine (persisted todata/.env.generated). A real container/hostENGINE_TYPEenv still takes precedence; leave it unset to let the dashboard control the engine. - Docker Compose: the production data-path settings (
SESSION_DATA_PATH,STORAGE_LOCAL_PATH,PLUGINS_DIR) and the dev-compose environment are now overridable via${VAR:-default}without editing the compose files. (#450, #451 — thanks @MS-Jahan)
Fixed
- Chats: voice notes and videos now play — the Content-Security-Policy was missing a
media-srcfordata:URIs, so the browser blocked inline audio/video. - Chats: stickers, images, videos and documents loaded from history now render instead of collapsing to an empty timestamp-only bubble (history is fetched with its media payload).
- Chats: on small screens the conversation back-button icon is now visible (inherited button padding had squeezed it to zero width).
- Plugins config dialog: radio buttons on the Sessions tab no longer stretch to full width and strand their labels.
- Infrastructure: the engine status and the engine config form now reflect the real saved headless / session-path / browser-argument values instead of always showing defaults.
- Docker (production builds): the builder stage now forces
devDependencies(npm ci --include=dev) sonest buildand the dashboard build no longer fail withnest: not foundwhen a PaaS (e.g. Coolify) leaksNODE_ENV=productioninto the image build. (#449 — thanks @MS-Jahan)
[0.7.0] - 2026-06-23
v0.7 — plugin-contract expansion. Richer plugin config (declarative + sandboxed-iframe editors), per-session activation and config, SSRF-guarded outbound HTTP, and the removal of the bundled reference extensions in favour of the marketplace. ⚠️ See the Removed note before upgrading.
Added
- Plugins: richer config schema vocabulary — a
textareatype,min/max/patternvalidation hints, and composite kinds (itemsfor arrays, including array-of-rows whenitemsis an object;propertiesfor nested objects;enumrenders a select). The dashboard config form renders them recursively, and secret redaction/restore now recurses so asecretfield at any depth is masked on read and preserved on an unchanged write. (#439) - Plugins: a plugin may ship a sandboxed-iframe config editor via manifest
configUi { entry, height? }. The host serves the entry over an authenticatedGET /plugins/:id/config-ui(ADMIN, path/realpath escape-guarded, CSP-sandboxed) and the dashboard injects it as ansrcdocinto asandbox="allow-scripts"iframe (opaque origin). The editor exchanges config over apostMessagebridge — the API key never enters the iframe, and it only ever receives schema-declared, secret-redacted config. (#440) - Plugins: per-session config overrides. A session-scoped plugin can carry per-session config on top of its base (
'*') config viaPUT /plugins/:id/config/:sessionId;ctx.config(read inside a hook) is the override shallow-merged over the base for the firing session, resolved race-safely viaAsyncLocalStoragefor both in-process and sandboxed plugins. Overrides are secret-redacted per slice on the API and survive a restart. (#441) - Plugins: per-session activation. A session-scoped plugin can now be activated for all numbers (
*) or an explicit set of sessions viaPUT /plugins/:id/sessions, and only receives hook events for the sessions it is active for (enforced at delivery). A plugin declaressessionScopedin its manifest (defaulttrue); a global plugin (false, e.g. a metrics logger) always runs. The active set is surfaced on the plugin API and survives a restart. (#438) - Plugins: a new
ctx.net.fetchcapability lets a sandboxed plugin make outbound HTTP through the host's SSRF guard (resolve-once-pin, redirects refused), gated by anet:fetchpermission plus a manifestnet.allowhost allowlist (host:port, barehost, or*for any public host; internal IPs are always blocked). Responses are bounded by a timeout and a streamed size cap. (#437) - Chats: opening a conversation now shows its recent history. The dashboard backfills messages directly from WhatsApp when the gateway has none stored yet and merges them with locally-persisted messages, so a freshly connected session shows the conversation instead of an empty thread.
- Engine (whatsapp-web.js): a reconnect that stalls mid-authentication now self-heals — the stale local auth is cleared and a fresh QR pairing is started — and the WhatsApp Web build can be pinned via
WWEBJS_WEB_VERSIONfor environments where the auto-selected build drifts. - Dashboard: a searchable plugin catalog, audit-log CSV export across all pages (not just the current view), the running version shown in the sidebar, and an engine-aware engine-configuration dialog (Baileys no longer shows Puppeteer-only fields).
Changed
- Dashboard, small screens: the chat view is now a single-pane list → conversation flow with a back control instead of a cramped two-pane; page headers place the description directly under the title; and keyboard focus is a consistent, cross-browser, keyboard-only ring. Plus assorted copy and empty-state refinements.
- Plugins (install): install-from-URL / catalog downloads now follow CDN redirects safely — each hop is re-validated through the SSRF guard — so plugins published on GitHub Releases install correctly.
Removed
- ⚠️ Breaking: the bundled reference extensions
auto-replyandtranslationhave been removed from core. They are superseded by the marketplace pluginschat-flow(interactive auto-reply) andgroup-translate(LibreTranslate group translation), which target the v0.7 contract. Upgrade: if you had either enabled, install the replacement from the dashboard (Plugins → Catalog, orPOST /plugins/install-url) and re-enter its config. The idsauto-reply/translationremain reserved (an uploaded package can't claim them). Built-in engines (whatsapp-web.js, Baileys) are unaffected.
Fixed
- Plugins: an operator's per-session activation (and now per-session config) was silently dropped from the on-disk registry on the second restart, because the registry entry was rebuilt on each load without carrying those fields. Both are now preserved across restarts. (#441)
- Docker: the multi-arch image build failed on
linux/arm64(Cannot find module lightningcss.linux-arm64-gnu.node) — the builder stage was QEMU-emulated per target and the emulated arm64 install couldn't fetch lightningcss's (Vite's native CSS minifier) arm64 binary. The builder, which only produces arch-independent artifacts, is now pinned to$BUILDPLATFORMso it runs natively; per-arch runtime deps still install in the target-platform stage. Restoreslinux/arm64GHCR publishing. - Inbound media is now size-capped before the full attachment is buffered into memory, on both engines, and concurrent inbound media downloads are bounded — lowering peak memory under bursty load.
- Plugins: composite (object/array) config fields marked
secretare now fully masked when read back; plugin storage files and directories are created with owner-only permissions; and several runtime robustness fixes (timeout, validation, and error handling) in the sandbox and installer.
Security
- Session scope is now enforced on the session-statistics overview and on per-session plugin activation, so an API key restricted to specific sessions can no longer read or change state for sessions outside its scope.
[0.6.2] - 2026-06-23
Plugin platform follow-ups (sandbox hardening, install-from-URL + catalog), a mark-chat-unread endpoint, and a batch of correctness/housekeeping fixes.
Added
- Install plugins from a URL / catalog.
POST /plugins/install-urldownloads a plugin.zipfrom an HTTP(S) URL through the SSRF guard (host validated, connection pinned, redirects refused, size-capped) and runs the exact same validate-write-load pipeline as an uploaded package.GET /plugins/catalogfetches a configured remote catalog (PLUGIN_CATALOG_URL, default the OpenWA-pluginsplugins.json) and annotates each entry withinstalled/installedVersion/updateAvailable. The dashboard install modal gains a Catalog tab to browse and one-click install. Add a non-public catalog/release host toSSRF_ALLOWED_HOSTS. (#433) - Update a plugin in place.
POST /plugins/:id/updatedownloads the new package (same SSRF-guarded path) and swaps it in while preserving operator config and the enabled state — it unloads the running plugin (keeping its registry entry, so config survives), writes the new files, reloads, and re-enables if it was enabled. The package id must match; the old version is backed up and restored if the update fails. The dashboard Catalog tab shows an Update button when a newer version is available. (#433) - Mark a chat as unread:
POST /sessions/:id/chats/unread(andsessionApi.markChatUnreadon the dashboard client), the inverse of mark-as-read, supported on both the whatsapp-web.js and Baileys engines. (#432)
Security
- Untrusted (uploaded) plugins now run with a minimal, allowlisted worker environment instead of inheriting the host process environment, so a plugin can no longer read host secrets (database/Redis credentials, the API master key and pepper,
DOCKER_HOST) out ofprocess.env. (#431)
Fixed
- Webhook delivery no longer POSTs an empty (
undefined) body when awebhook:beforeplugin hook returns a result without apayloadkey — it now falls back to the original payload. (#434) - The
session.qrWebSocket event is now actually emitted from the QR callback, so the dashboard can render the QR live instead of only pollingGET /qr. (#434) - Storage usage now reports real S3 object sizes instead of a 100KB-per-file estimate, and local file writes no longer block the event loop during an import. (#434)
- A sandboxed plugin whose
load/onEnable/onDisablehangs no longer blocks the enable/disable request (and the request behind it) indefinitely — plugin lifecycle calls are now time-bounded, and a disable always tears the worker down even ifonDisablefails, so a misbehaving plugin can't leak its worker thread. (#431) - Sandboxed plugins now receive
onConfigChange(config updates reach the worker instead of being silently ignored until disable + re-enable) and have their realhealthCheckrun —GET /plugins/:id/healthpreviously always returned the default "healthy" for sandboxed plugins. (#430) - Plugin
onDisablenow runs on graceful shutdown (OnModuleDestroy), so stateful plugins can flush buffers / close connections / persist state instead of losing in-flight work on every restart or deploy. (#430) - A concurrent enable of the same plugin no longer double-runs
onEnableor double-registers its hooks (a synchronous in-progress lock rejects the racing call). (#430) - Plugin storage writes —
ctx.storage.set()and the plugin registry — are now atomic (write to a temp file then rename), so a crash mid-write can't leave a truncated file that silently degrades to lost state. (#430)
Changed
- The plugin-management UI strings (install/uninstall, the status rail, and the install modal) are now translated into every locale instead of falling back to English. (#429)
[0.6.1] - 2026-06-22
A patch closing a plugin-hook gap.
Fixed
- The
message:ackhook event was declared in theHookEventunion but never emitted, so a plugin registered for it (e.g. a delivery-status logger) silently never fired. It now fires for every delivery/read receipt with{ messageId, status, ack }(source: 'Engine', scoped to the session), consistent withmessage:received/message:sent. Delivery failures surface asmessage:ackwithstatus: 'failed'; the send-timemessage:failedhook is unchanged.
[0.6.0] - 2026-06-22
The plugin platform release: untrusted plugins now run sandboxed, and you can install and uninstall them from the dashboard. One breaking change for plugin authors (the sandbox context), so this is a minor bump.
Added
- Install and uninstall plugins from the dashboard. Upload a plugin packaged as a
.zip(POST /api/plugins/install) and remove it (DELETE /api/plugins/:id). The Plugins page is redesigned into a status rail — the active engine + library version, enabled/installed counts, and the live list of active plugins — alongside a catalog with an Install button and a per-plugin Uninstall. Uploaded packages are validated (manifest, safe id, zip-slip + size guards), onlyextensionplugins are installable (engines and other tiers stay built-in), and built-ins cannot be uninstalled.
Changed
- ⚠️ Breaking (plugin authors): plugins loaded from the
plugins/directory now run sandboxed in an isolated worker thread instead of in-process. Their context is curated tomessages,engine,storage,logger,config,pluginId, andregisterHook, with capability calls permission-checked on the host — a sandboxed plugin can no longer reach the hosthookManagerdirectly or share host objects. Bundled/built-in plugins (engines, auto-reply, translation) are unaffected and still run in-process. Seedocs/23-plugin-sandboxing.mdfor the trust model and the boundary's limits. - Engines are now single-active: enabling an engine other than the configured
engine.typeis rejected (switch engines in settings, then restart). The dashboard shows the active engine as Active and the others as Available, fixing the state where two engines could appear active at once. - Calmer plugin cards — the loud gradient, type-colored card headers are replaced with clean cards and a subtle type-tinted icon.
Fixed
- Plugins page: the "Active" state and the Enable/Activate actions were all the same green and hard to tell apart. Actions are now a solid green button and the current state a neutral chip. (#417)
- The dashboard reports each plugin's real built-in status (previously only the WhatsApp Web.js engine was flagged built-in).
- The appearance/theme popover no longer spills outside the sidebar onto the page. (#424)
[0.5.1] - 2026-06-22
A small correctness & consistency patch — no breaking changes. Session engine callbacks no longer
mutate a session after it has been stopped or its engine replaced; bulk-message variables now use the
same {{name}} syntax as message templates (single-brace {name} deprecated but still honored); and
a plugin's declared capability permissions are now actually enforced at the capability boundary.
Changed
- Plugin capability permissions are now enforced. A plugin may use a capability —
ctx.messages.*(send/reply) orctx.engine.*(read-only group/contact/chat queries) — only if its manifest declares the matching permission (messages:send/engine:read); a plugin that doesn't declare it, or declares none, is denied with a clearPluginCapabilityError. Previouslymanifest.permissionswas advisory and unenforced. The built-in extensions declare exactly what they use (auto-reply:messages:send; translation:messages:send+engine:read) and are unaffected; custom plugins must declare the permissions for the capabilities they call. (#412) - Bulk-message variable substitution now uses the same
{{name}}syntax as message templates.POST /sessions/:id/messages/send-bulkpreviously substitutedmessages[].variableswith a single-brace{name}convention, inconsistent with the double-brace{{name}}used everywhere else in the gateway. Bulk content is now rendered by the shared template helper, so the canonical{{name}}placeholders work in bulk content. Existing single-brace{name}content keeps working unchanged. (#69, #411)
Deprecated
- Single-brace
{name}placeholders in bulk-message content. Prefer{{name}}; the legacy{name}form is still substituted for backward compatibility but may be removed in a future major version. (#69, #411)
Fixed
- A session is no longer mutated by callbacks from an engine it has already replaced or torn
down. Each engine's lifecycle/message callbacks (QR, ready, disconnect, state, ack, message,
reaction, …) now no-op once that engine is no longer the live one for the session. This closes a
race where a late callback from a stopped engine — or from a previous engine after a
restart/reconnect — could write a stale status (e.g. flip a stopped session back to
ready), schedule a reconnect for a session meant to be down, or persist a stray message/ack against the wrong engine generation. The guard is a no-op for the live engine and for ordinary network-drop reconnects. (#410)
[0.5.0] - 2026-06-21
A security & reliability hardening release. One behavior change (the reason for the minor bump):
the contact / group / chat list endpoints now paginate with a default cap of 1000 items — opt into
limit/offset; accounts with fewer than 1000 items are unaffected. Everything else is hardening and
correctness: time-bounded SSRF DNS resolution, validated webhook custom headers (blocks CR/LF
injection), Swagger off by default in production, boot-time validation of numeric env vars and of a
SQLite data/main path collision, plugin reads gated to ADMIN, a session-scoped key no longer denied on
non-session routes, no resurrection of a session stopped mid-startup, a hardened dashboard config-save
path (browser-flag parsing + 0600 secret file), and cleaner fresh-install schema.
Changed
- The contact, group, and chat list endpoints are now paginated (default cap 1000). ⚠️ Behavior
change.
GET /sessions/:id/contacts,/groups, and/chatspreviously serialized the operator's entire address book / group / chat set into one response — a heap/GC hazard for very large accounts. They now accept optionallimit(clamped[1, 1000]) andoffsetquery params, and default to returning at most 1000 items when nolimitis given. Accounts under 1000 items are unaffected; larger accounts page withoffset. Chats are returned most-recent first, so a capped response is the newest chats rather than an arbitrary slice. In-process callers (plugins using the engine directly) still receive the full set. (#401) - Fresh databases no longer create the unused
api_keys/audit_logstables on the data connection. Those auth/audit tables belong solely to the separate "main" SQLite connection, but the data-connection baseline migration also created them (with a stalekeyPrefixwidth), leaving dead, unused tables on the data database. New installs are now clean. Existing installs are unaffected — an already-applied migration is never re-run, so their harmless leftover tables remain and no destructive drop is performed. (#400)
Fixed
- Browser launch flags saved from the dashboard are now applied correctly. The Infrastructure
form persists the Puppeteer/Chromium arguments space-separated, but the engine config parser only
split on commas — collapsing every flag into a single malformed argv token, so
--no-sandbox(and any other flag) was silently never applied. In a hardened/containerized environment that can wedge session startup. The parser now accepts either delimiter, and an already-saved space-separated value is repaired on the next boot. (#397) - A session-restricted API key is no longer wrongly denied on non-session routes. The guard
derived the session for a key's
allowedSessionsscope from the:idroute param, but:idis also the resource id on unrelated routes (e.g.auth/api-keys/:id,plugins/:id) — so a session-scoped key got a spurious401there. Session scoping is now applied only where:idactually denotes a session; enforcement on the realsessions/:id/...routes is unchanged. (#398) - Boot is now rejected when the SQLite
DATABASE_NAMEcollides with the internal main database file. The auth/audit ("main") and application ("data") connections must be separate SQLite files; pointingDATABASE_NAMEat./data/main.sqliteran two connections — each with its own migration ledger and synchronize policy — against one file, risking schema divergence and lock contention. Startup validation now fails fast with a clear message (paths are normalized, so relative spellings of the same file are caught). Postgres is unaffected (itsDATABASE_NAMEis a bare db name). (#399) - Numeric environment variables are validated at boot. The rate-limit windows/limits, webhook
timeout/retry settings, and the database pool size were parsed with an unbounded
parseInt; a non-integer value (e.g.RATE_LIMIT_SHORT_LIMIT=abc) becameNaNand silently disabled the corresponding limit. Startup now rejects a non-negative-integer violation with a clear message, consistent with the existing port validation. (#402) - The whatsapp-web.js engine now detects remote media URLs case-insensitively. A media
datastring was treated as a URL only with a lowercasehttp:///https://prefix, so a mixed-case scheme (e.g.HTTPS://…) was mistaken for base64 instead of being fetched through the SSRF-guarded path — diverging from the Baileys engine. Both engines now use the same case-insensitive check. (#404) - A session stopped or deleted mid-startup is no longer resurrected to
READY. Ifstop/deletelanded whilestart()was awaiting the engine'sinitialize(), the freshly-created engine was left registered and running.start()now re-checks the stopping flag after initialization and tears the engine down (mirroring the existing reconnect guard), so a concurrent stop/delete wins. (#405)
Security
- DNS resolution in the SSRF guard is now bounded by a deadline. The guard resolved a hostname
with an unbounded lookup, so a hanging or very slow resolver could pin a worker indefinitely. The
lookup now races a deadline (default 10s, overridable via
SSRF_DNS_TIMEOUT_MS) and fails closed with a clear error on expiry. Healthy resolvers are unaffected. (#404) - Custom webhook headers are now validated as a flat, control-character-free string map. The
headersfield accepted any object shape with no per-value checks, so a value containingCR/LFcould attempt header injection into the outbound webhook request, and non-string values silently broke delivery. Creation/update now reject invalid header names, non-string or control-character values, and over-large maps (max 50 entries, value max 1024 chars). The delivery-time reserved-name filter is unchanged. (#403) - Swagger UI (
/api/docs) now defaults OFF in production. The interactive API schema was served unauthenticated by default everywhere; it is reconnaissance surface. It remains on outside production and can be re-enabled in production withENABLE_SWAGGER=true(and is still disabled anywhere withENABLE_SWAGGER=false). The startup banner only advertises the docs URL when it is actually served. (#402) - Plugin inventory, detail, and health reads now require the ADMIN role.
GET /plugins,GET /plugins/:id, andGET /plugins/:id/healthwere readable by any authenticated key (including the read-only VIEWER role), exposing installed plugin versions, non-secret configuration, and health/error text. They now require ADMIN, matching the plugin write routes and the infrastructure endpoints. (Secret config values were — and remain — redacted regardless.) (#398) - The dashboard-generated env file is now written owner-only (
0600). Saving Infrastructure configuration wrotedata/.env.generated— which can hold the database, S3, and Redis credentials — with default permissions (world-readable0644) until the next restart re-tightened it. It is now written0600at save time through the same owner-only helper used for the generated env at first boot, closing the exposure window on shared or bind-mounted hosts. (#397)
[0.4.8] - 2026-06-21
A maintenance release — no breaking changes; everything is a fix or internal hardening.
Reliability: the configurable whatsapp-web.js first-boot timeout (WWEBJS_AUTH_TIMEOUT_MS) now
actually takes effect in Docker (it was never forwarded into the container) and is validated as a safe
integer; the dashboard now collapses duplicate connection-lost toasts during a reverse-proxy outage.
Resource limits: outbound base64 media is now size-capped (413 when too large) on a par with the
remote-URL and inbound media caps, and bulk-send media payloads are validated as typed objects.
Release & tooling: a published GitHub Release now waits for the container image build, and the data
migration CLI is scoped to the data-owned tables. Note: bulk-send media validation is now stricter — a
bulk request carrying unknown or malformed fields inside a media object is now rejected with 400.
Changed
- A published GitHub Release now waits for the container image build. The release workflow's
GitHub Release job now depends on the Docker image job, so a
v*tag can no longer publish release notes without a matching multi-arch image on GHCR. A failed image build leaves the tag without a Release until the workflow is re-run. (#389) - The data migration CLI is scoped to the data-owned tables.
data-source.ts(used bymigration:generate/migration:run) now lists only the data connection's entities (session/webhook/message/template/engine), mirroring the runtime data connection, instead of a broad glob that also pulled in the main-ownedapi_keys/audit_logsentities. Generating a data migration no longer emits spurious auth/audit DDL into the data database. No runtime or schema change for existing installs. (#391)
Fixed
- Dashboard collapses duplicate connection-lost toasts during a reverse-proxy outage. When the
backend is unreachable behind a reverse proxy that returns a non-JSON
502/503page, the dashboard now folds the repeated request failures into a single connection-lost toast instead of stacking ordinary error toasts. The thrown error now always carries the HTTP status code (which the toast de-duplication matches on), rather than a status text that is empty over HTTP/2. (#388) WWEBJS_AUTH_TIMEOUT_MSnow takes effect in Docker, and is validated as a safe integer. The configurable first-boot init timeout added in 0.4.7 was never forwarded into the container by Docker Compose, so setting it in.envhad no effect on the recommended deployment path — the engine kept the 30000ms default. Both compose files now pass it through (unset still means the default). The value is also validated as a positive safe integer, so an accidental huge or overflowing value falls back to the default instead of making the engine's first-boot wait run effectively unbounded. (#393)- Outbound base64 media is now size-limited. Sending media as a base64 string (single and bulk
sends) was bounded only by the coarse whole-request
BODY_SIZE_LIMIT, unlike remote-URL and inbound media which already enforceMEDIA_DOWNLOAD_MAX_BYTES. The decoded size of an outbound base64 blob is now checked against the sameMEDIA_DOWNLOAD_MAX_BYTEScap (default 50 MiB) before it is sent or persisted; an oversized blob is rejected with413 Payload Too Large(the documentedMESSAGE_MEDIA_TOO_LARGE). The bulk-send nested media payloads are now validated as typed objects, so unknown or malformed media fields are rejected rather than silently persisted — bulk requests carrying junk inside a media object will now get a400. (#394, #395)
[0.4.7] - 2026-06-21
A webhooks, reliability, and dashboard release — no breaking changes; everything is additive or a
fix. Webhooks gain optional smart pre-dispatch filters: a trigger can carry AND-ed conditions
(sender/recipient/body/type/mentions/fromMe/hasMedia/isGroup) and fires only when they all match,
with engine-neutral WaId contact matching and a FilterBuilder UI — a webhook with no filters
behaves exactly as before. The whatsapp-web.js engine's first-boot init timeout is now configurable
(WWEBJS_AUTH_TIMEOUT_MS) for slow environments. Fixed: the dashboard no longer crashes on
PostgreSQL when a webhook exists (a JSON column type mismatch). Dashboard: a downed backend no
longer floods the screen with error toasts.
Added
-
Smart webhook filters (optional, additive). A webhook trigger can now carry an optional set of pre-dispatch conditions, evaluated per event before delivery: it fires only when all conditions match (AND). Conditions match on
sender/recipient/body/type/mentions/fromMe/hasMedia/isGroupwithis/isNot/contains/equalsoperators; message-only conditions are skipped for non-message events, so a*-subscribed webhook still fires on session events. A webhook with no filters behaves exactly as before. Contact-id conditions (sender/recipient/mentions) match by the engine-neutralWaIdkey, so a filter written as a plain number or in any dialect (@c.us/@s.whatsapp.net/@lid) matches the same person - and a lid-addressed sender (e.g. an unresolved@lidgroup participant) matches a phone filter once the persistentlid -> phonetable knows the mapping. Configurable via the API (filterson create/update) and a new FilterBuilder UI on the dashboard's Webhooks page. (#379) -
Configurable first-boot init timeout for the whatsapp-web.js engine (
WWEBJS_AUTH_TIMEOUT_MS). On slow first boots (e.g. WSL2 or low-resource containers) the engine's fixed 30s wait for WhatsApp Web to finish loading could expire before the QR code was generated, aborting startup. SetWWEBJS_AUTH_TIMEOUT_MSto a larger value in milliseconds (e.g.120000) to extend it; unset keeps the previous 30000ms default, so existing deployments are unchanged. (#353)
Changed
- Dashboard collapses connection-error spam into a single toast. When the backend is unreachable
(
failed to fetch, network errors, HTTP 502/503), the dashboard now shows one translated "Server Connection Lost" toast that auto-dismisses, instead of stacking an error toast per failed request — de-duplicated on a stable key so translation can't break it. Original work by @quinton-8. (#293)
Fixed
- Dashboard no longer crashes ("Something went wrong") when a webhook exists on PostgreSQL. JSON
columns (
webhooks.events/headers,sessions.config,messages.metadata,message_batches.*) were declaredjsonbin their entities but created astextby the baseline migration, so on Postgres the driver returned them as raw JSON strings and the dashboard'sevents.map()threw an uncaught error.jsonColumnType()now resolves tosimple-jsonon both dialects (parsed in JS on read) — no schema migration or data conversion, since the write format was already identical. This also corrects the same latent string-instead-of-object behavior for session reconnect config, message-reaction persistence, and bulk-send batches on Postgres. The dashboard additionally normalizes webhookeventsto an array at the query boundary as defense-in-depth. (#385)
[0.4.6] - 2026-06-20
A reliability, correctness, and dashboard release. Identity & engine: Baileys gains a persistent,
cross-session lid -> phone table (shared resolution that survives restarts) plus a new from message
filter, and its contact/chat listing ids are now engine-neutral (@c.us). Webhooks: message
reactions now also fire as a message.reaction webhook (previously WebSocket-only). Dashboard:
selectable appearance palettes with light/dark/system mode, and a redesigned Templates workspace.
Hardening: the LibreTranslate client pins its outbound connection, and Baileys group-participant
operations address participants in the engine wire dialect. Two consumer-visible notes: Baileys
contact/chat-list ids flip @s.whatsapp.net -> @c.us (whatsapp-web.js already used @c.us), and
webhooks subscribed with * now also receive message.reaction.
Added
-
Persistent, cross-session
lid -> phoneresolution + afromfilter on message history. A newlid_mappingstable (on thedataconnection) records thelid -> phonemappings WhatsApp pushes us (history sync, contacts) so resolution is shared across sessions and survives restarts, instead of living only in one Baileys session's in-memory map.GET /api/sessions/:sessionId/messagesnow accepts afromquery param that resolves through this table: filtering by a phone returns not just messages stored as<phone>@c.usbut also those whose sender was an unresolved<lid>@lidthat has since resolved to that phone - closing a gap where a phone-based filter silently missed the same person's lid-addressed (e.g. group) messages. The table is populated at runtime from the lid<->phone pairs the Baileys engine observes (inbound messagesenderPn/participantPn, thechats.phoneNumberShareevent, contacts, and history sync), so it fills continuously without re-auth. Internally these ids are now carried by a typedWaIdvalue object; it is in-memory only and serializes to the exact same neutral string, so no webhook / WebSocket / REST response shape changes. (#374) -
Webhook parity for message reactions (
message.reaction). Reactions were broadcast over the WebSocket only; they are now also delivered as amessage.reactionwebhook with the same payload (the reaction plus the post-apply reactions snapshot) and are selectable in the dashboard event picker. Idempotency is salted per dispatch, so a re-reaction is a distinct delivery while retries dedupe. Consumer-visible: webhooks subscribed with*now also receive this event. (#380) -
Dashboard appearance palettes + redesigned Templates workspace. A new Appearance menu switches light / dark / system mode and selectable accent palettes (persisted and applied across the UI). The Templates page is redesigned into a searchable workspace with a saved-template library, editor, live preview, and placeholder inputs. (#361)
-
BAILEYS_LOG_LEVEL(trace|debug|info|warn|error, silent by default) surfaces the Baileys library's own diagnostics;tracedumps the decoded WhatsApp wire frames to stdout (context "baileys-wire") for analysis. (#375)
Fixed
-
Baileys engine: contacts, chats and recent history now sync on connect. Baileys defaults
shouldSyncHistoryMessageto() => !!syncFullHistory, so withsyncFullHistoryunset it silently disabled the entire initial sync - the address-book/app-state sync never ran, so no contacts, chat list, recent messages, orlid -> phonemappings ever arrived. The adapter now passesshouldSyncHistoryMessage: () => true, enabling the sync while keeping the full-archive download opt-in viaBAILEYS_SYNC_FULL_HISTORY(WhatsApp sends the recent window + contact snapshot, not the entire message history). (#375) -
Message history
chatIdfilter now matches across dialects. A chat addressed as<phone>@c.us(the neutral list id) now also returns messages stored under<phone>@s.whatsapp.net(e.g. an outbound send addressed by a raw engine id), so the conversation view is no longer empty when the stored and queried dialects differ - the same resolution thefromfilter uses. (#375) -
Baileys engine: contact and chat listing ids are now engine-neutral (
@c.us).getContacts/getChats/getContactByIdpreviously returned the raw<phone>@s.whatsapp.netid (visible in the dashboard, and mismatched against the@c.uschatId stored on messages). They now emit the neutral@c.usdialect like the message payloads; the read-back paths (sendSeen/deleteChat/ contact lookup) accept the neutral id and fold it back internally, so sending and marking-read still round-trip. Consumer-visible: Baileys contact/chat-list ids flip@s.whatsapp.net->@c.us(whatsapp-web.js already used@c.us). (#374) -
Hardened the LibreTranslate translation client against DNS rebinding. The client validated the target host and then issued a separate request that re-resolved DNS at connect time. It now pins the connection to the pre-validated address (the same SSRF-safe path webhook and media delivery use) and refuses redirects, so the API key (sent in the request body) cannot be redirected to an internal target between the host check and the connection. (#377)
-
Baileys group-participant operations now address participants in the engine wire dialect. Add / remove / promote / demote and group creation passed neutral
<phone>@c.usparticipant ids straight to the wire, where they encode as an unknown server suffix instead of thes.whatsapp.netprotocol token. They now fold to the engine dialect before the call (matching how 1:1 sends already round-trip);@lidand the@g.usgroup id are untouched, and the returned group info stays neutral@c.us. (#378) -
Italian translation corrections. Updated and corrected the Italian (
it) dashboard locale. (#376)
[0.4.5] - 2026-06-20
A Baileys engine quality-and-correctness release, plus a chat-history enhancement. Identity: inbound
Baileys message ids are now engine-neutral (@c.us, matching whatsapp-web.js), the dashboard Chats list
shows saved/contact names instead of raw JIDs, and @lid (privacy-id) senders resolve to a phone number.
Messaging: an opt-in deep=true mode lets the live chat-history endpoint reach up to 2000 messages
back on whatsapp-web.js, and Baileys can now send captions with document messages. One behavior change
to note: message.received / revoked / reaction webhook and WebSocket payloads from a Baileys
session now carry @c.us ids where they previously carried @s.whatsapp.net (or a resolved @lid) — a
consumer that stored or compared the old ids will see the new value.
Added
- Opt-in deep chat history (
deep=true).GET /sessions/:id/messages/:chatId/historywas capped at 100 messages per request — OpenWA's own bound, not a WhatsApp limit, since whatsapp-web.js can load earlier messages on demand. A newdeep=truequery raises the ceiling to 2000 so callers can reach weeks/months back. Deep mode is metadata-only (it ignoresincludeMedia, since base64 for up to 2000 messages would be an enormous payload). The default path is unchanged (default 50, max 100). The Baileys engine has no history sync, so the endpoint still returns501there regardless ofdeep. (#347)
Fixed
-
Baileys engine: the Chats list now shows saved/contact names instead of a raw number or
@lid. When Baileys supplied a chat without a title, the dashboard Chats list fell back to the raw JID user-part (a bare number, or a privacy-id for@lidcontacts). The session store now resolves a best-known display name from the synced contacts — preferring the saved name, then the businessverifiedName, then the pushName (notify) — and for a@lidchat it also looks up the contact behind the resolved phone. The raw user-part remains the last resort, so a name is shown whenever WhatsApp has delivered one. No API shape change (ChatSummary.nameis simply better populated). (#369) -
Baileys engine:
@lidsenders now resolve to a phone number.senderPhoneandGET /sessions/:id/contacts/:id/phonealways returnednullfor privacy-id (@lid) contacts on Baileys: the resolver only consulted mappings fromcontacts.*/messaging-history.set, which don't fire for a fresh inbound@lidsender, and baileys@6.7.23 has nogetPNForLIDlookup. The adapter now learns thelid -> phonepair that Baileys attaches to the inbound message key (senderPn/participantPn), so the sender of an incoming message resolves to its number and later contact lookups succeed. Still best-effort by design — a number is only revealed once WhatsApp delivers the mapping (e.g. an inbound message from that contact). (#362) -
Baileys engine: inbound message ids are now engine-neutral (
@c.us). The Baileys adapter emitted its native<phone>@s.whatsapp.net/<lid>@lidids in message payloads (from/to/chatId/author, plus revoked and reaction events), while the whatsapp-web.js engine and the rest of the system use the<phone>@c.usconvention - so the same contact was addressed under a different id depending on the engine, and@lid(privacy-id) contacts could not be resolved to a phone. Baileys now canonicalizes these to the neutral dialect (resolving a@lidto its phone when the mapping is known, keeping it as@lidotherwise), matching whatsapp-web.js. Group participant and owner ids are canonicalized through the same path, so admin/controller recognition (e.g. the translation plugin) keeps working. Consumer-visible:message.received/revoked/reactionwebhook and WebSocket payloads from a Baileys session now carry@c.usids where they previously carried@s.whatsapp.net(or a resolved@lid); a consumer that stored or compared the old ids will see the new value. Outbound sending and contact/chat list ids are unchanged for now. -
Baileys engine: documents can now be sent with a caption.
sendDocumentMessagedroppedmedia.captionon the Baileys engine, while whatsapp-web.js already forwarded it. Baileys now sends the caption too (parity across engines); the document stores the caption as its message body, falling back to the filename when absent. (#363)
[0.4.4] - 2026-06-20
A reliability and correctness patch. Engine: Baileys reconnect no longer leaks its socket, and a session
keeps its operator config even if the engine plugin fails to enable before onLoad. Templates: names are now
unique per session (deterministic resolve, 409 on duplicate, with a lossless de-duplicating migration).
Tooling: the migration CLI can manage the main (auth/audit) connection, and the Docker image ships procps
so a missing-ps cleanup path can't crash the container. One behavior change to note: PUT /settings
now returns 501 — settings are environment-derived and read-only at runtime — instead of a misleading 200
(no dashboard flow uses the write).
Added
- CLI migration commands for the main (auth/audit) connection. The app runs the main connection as a separate
always-SQLite connection, but the migration CLI only managed the data connection. New
migration:run:main,migration:generate:main,migration:show:main, andmigration:revert:mainscripts (plus:prodvariants) manage it — needed whenMAIN_DATABASE_SYNCHRONIZE=falsedisables boot auto-migration. Purely additive. (#364)
Changed
PUT /settingsnow returns501 Not Implementedinstead of a misleading200. Settings are derived from environment variables and consumed at boot (andConfigServiceis immutable at runtime), so the previous handler mutated an in-memory copy and reported success while persisting and applying nothing. The endpoint is now honest about being read-only;GET /settingsand the ADMIN guard are unchanged, and no dashboard flow uses the write. (#364)
Fixed
- Baileys reconnect no longer leaks the previous socket. An internal (transient-drop) reconnect overwrote the live socket without tearing the old one down, leaking its WebSocket and event listeners on every reconnect. The previous socket is now detached and ended before its replacement is created. (#364)
- Engine sessions keep operator config when the engine plugin fails to enable. The engine config blob is now also
supplied at plugin construction, so
sessionDataPath/executablePath/authDirstill apply if a plugin fails to enable before itsonLoadruns (they previously dropped silently to defaults). The healthy path is unchanged. (#364) - Template names are unique per session. A composite unique index makes resolve-by-name deterministic and rejects
duplicate names with
409 Conflict; a migration losslessly de-duplicates any pre-existing collisions (keeps the earliest, renames the rest) before adding the index. The{{var}}/{var}template-syntax split is unchanged and still tracked in #69. (#364) - Container no longer crashes on browser-cleanup paths when
psis missing. The production image is based onnode:22-slim, which omits thepsbinary; cleanup code that shells out tops(e.g. process-tree kills) fails withspawn ps ENOENT, and that unhandled child-process error can take down the whole Node runtime. The image now installsprocps. This does not change the underlying browser-init timeout — it only prevents the missing-pscleanup failure from being fatal. (#359)
Documentation
- Documented chat-history limits. A new guide explains the difference between the local message-history
endpoint (
GET /sessions/:id/messages, reads OpenWA's database) and the bounded live-history endpoint (GET /sessions/:id/messages/:chatId/history, asks the engine): live history defaults tolimit=50and is clamped to[1, 100](solimit=999returns 100, not the full account history), and is a recent-history helper rather than a complete server-side import. (#356)
[0.4.3] - 2026-06-19
A security-hardening and reliability release: outbound-request and storage hardening, plugin/message persistence
fixes, delivery-status and concurrency correctness, and lifecycle robustness — including a force-kill recovery
for stuck sessions and its dashboard button. No breaking changes for a correctly-configured deployment; the
only behavior change to note is that a misconfigured ENGINE_TYPE/STORAGE_TYPE now fails fast at boot instead
of silently falling back to the default.
Added
- Force-kill a stuck session.
POST /sessions/:id/force-kill(OPERATOR) recovers a session whose engine is wedged and won't respond to a normal stop/delete: the whatsapp-web.js engine SIGKILLs its own Chromium process directly (never a process-wide kill that could take down other sessions), then best-effort tears the client down; the Baileys engine ends its socket. The teardown is time-bounded and isolated, and the session is leftDISCONNECTEDand restartable. (#352) - Dashboard "Kill Stuck" button. Session cards in a
failedstate get a Kill Stuck action that confirms, then calls the force-kill endpoint above. (#351)
Security
- Outbound webhook and media fetches are pinned to the SSRF-validated IP. The host check and the actual
connection previously resolved DNS independently, leaving a DNS-rebinding window; the connection now reuses
the exact vetted address (preserving the hostname for TLS SNI/
Host, with A-record failover) across webhook delivery (direct/queued/test) and server-side media downloads. (#338) - IPv6 SSRF blocklist closes embedded-IPv4 gaps (6to4
2002::/16, NAT6464:ff9b::/96, IPv4-compatible::/96); the LibreTranslate plugin client is SSRF-guarded; per-sessionproxyUrlis validated as anhttp(s)/socks4/socks5URL. (#344) - Secret/auth hardening. Generated secret files (
data/.env.generated,data/.api-key) are written0600; an opt-inAPI_KEY_PEPPERhashes API keys with HMAC-SHA256;allowedIpsentries are validated as IPv4/CIDR; the queue dashboard (Bull Board) auth uses the same trusted-proxy IP model as the API; the production secret-guard inspects the canonical S3 variables. (#345) - Storage import/key hardening. A
tar.gzimport is bounded against decompression bombs (per-entry byte cap- entry-count cap); storage-key containment is enforced at the backend-agnostic boundary so the S3 path
inherits it; a plugin's
ctx.storageis sandbox-contained against..traversal. (#346)
- entry-count cap); storage-key containment is enforced at the backend-agnostic boundary so the S3 path
inherits it; a plugin's
Fixed
- Webhook subscriptions for session lifecycle events now deliver.
session.status,session.qr,session.authenticated,session.disconnectedwere accepted on subscribe but never dispatched; they now fire from the engine lifecycle (the n8n docs are corrected to the real event names). (#335) - Plugin enable/disable and configuration now persist across restarts (they previously updated only in-memory state while the API reported success). Plugins are not auto-enabled on boot for safety; their saved configuration is preserved. (#339)
- Bulk-sent messages are recorded, their errors no longer leak internal addresses, and a running batch can be cancelled across instances. (#340)
- Forwarded messages on the whatsapp-web.js engine report a real WhatsApp message id, so their delivery
status advances (the synthetic
fwd_<id>could never match an ack). (#341) - A late delivery/read receipt is no longer lost (the ack retries once when it arrives before the send's id is committed); concurrent reactions no longer overwrite each other (serialized per message); a plugin hook that reports an error no longer has its partial output applied; a failed ack write is logged with context. (#348)
- Storage export no longer accumulates copies on the data volume — it writes under
data/exports/with a TTL sweep and an async read (instead of a synchronous read that blocked the event loop). (#346) WEBHOOK_TIMEOUTis honored on the queued and test delivery paths (not just the deprecated direct one); graceful shutdown is bounded (a half-open Redis socket can't blockapp.close()); unsupported status/catalog operations return a consistent501; a misconfiguredENGINE_TYPE/STORAGE_TYPEfails fast at boot. (#350)
Changed
- The
/api/metricsscrape is memoized for a few seconds so back-to-back scrapes don't each run a full session scan plus aggregates; removed a dead branch in the WebSocket connect handler. (#350)
Documentation
- Added a phone-number pairing example. (#343)
- Documented the webhook
idempotencyKey/deliveryIdfields (body +X-OpenWA-*headers) and the dedup rule; corrected the.env.examplerate-limit variable names (RATE_LIMIT_MEDIUM_TTL/_LIMIT, in milliseconds). (#350)
[0.4.2] - 2026-06-19
Bug-fix and hardening release: access-control tightening, session-lifecycle resilience, data-migration correctness, and a PostgreSQL analytics fix. No breaking changes — existing deployments and the default (ADMIN) dashboard key are unaffected.
Security
- The well-known development API key is refused in production. With
ALLOW_DEV_API_KEY=true(and noAPI_MASTER_KEY), the server seeded the documenteddev-admin-keyas an ADMIN credential in any environment. Production now fails fast whenALLOW_DEV_API_KEY=true, anddev-admin-keyis rejected as anAPI_MASTER_KEY. Development behaviour is unchanged. - Webhook by-id operations and the webhook list are scoped to their session.
GET/PUT/DELETE/sessions/:sessionId/webhooks/:idand the test endpoint now verify the webhook belongs to the URL's session (a mismatch returns 404), andGET /webhooksis scoped to the calling key's allowed sessions — closing cross-session access to another session's webhook configuration. GET /sessionsis scoped to the API key's allowed sessions. A session-restricted key no longer lists every session.- The audit log and global statistics require ADMIN.
GET /audit,GET /stats/overviewandGET /stats/messages(cross-session, unscoped reads) now require an ADMIN key. The per-session stats route is unchanged (already scoped by its session parameter). - Plugin secrets are redacted on read.
GET /pluginsandGET /plugins/:idnow mask config fields a plugin markssecret(e.g. API keys); updating config preserves the stored secret when the masked value is submitted back unchanged.
Fixed
- Baileys: inbound and sent messages no longer fail to persist for a recreated session (#319). An orphaned adapter writing under a stale session id raised a foreign-key error on every message and left the message store empty (breaking reply/forward/react/delete by id). The store now skips the write for an absent parent session, logging once instead of erroring per message.
import-datano longer silently loses message history. The restore targeted non-existent columns for themessagesandmessage_batchestables, so every row failed while the endpoint still reported success — after the destructive delete. Column mapping is corrected for both SQLite and PostgreSQL, and a partial restore now rolls back and reportsimported: falseinstead of committing a half-wiped database.- Statistics work on a PostgreSQL data database. The time-series and hourly-activity queries used a SQLite-only date function and returned 500 on PostgreSQL; the date bucketing is now dialect-correct.
- Concurrent session start no longer orphans an engine. Two near-simultaneous
POST /sessions/:id/startfor the same session could both create an engine, leaking a Chromium process the lifecycle could never clean up. The second start is now rejected with a clear error. - A stuck engine teardown no longer wedges a session.
delete()andstop()now time-bound and isolate the engine teardown, so a hanging Chromium can't prevent the session row from being removed or its status from being updated. A genuine database failure on delete still surfaces as an error. - Reconnect backoff is bounded. An unvalidated
reconnectBaseDelay/maxReconnectAttemptsin a session's config could drive an immediate-relaunch storm or an unbounded reconnect loop; the values are now coerced and clamped (the defaults are unchanged). - Inbound media is size-capped. Media on an inbound message is bounded by
MEDIA_DOWNLOAD_MAX_BYTES(default 50 MiB; previously this cap applied only to outbound URL sends). Oversized media is dropped — the message envelope is preserved — instead of being base64-encoded into memory, persisted, and broadcast. reply/forward/react/deleteon a missing message return 404 instead of a generic 500.- Swagger now reports the current API version (it was pinned to an old value).
Documentation
- Added an n8n appointment-booking workflow example and webhook signature-verification examples, and corrected
the
message.receivedwebhook payload field reference.
[0.4.1] - 2026-06-18
Bug-fix release found while verifying v0.4.0 on both engines (whatsapp-web.js and Baileys): the Baileys QR
now renders in the dashboard, a synchronize-created SQLite data DB no longer crashes when adopting
migrations, and graceful shutdown is clean. No API or breaking changes.
Fixed
- Baileys QR code is now scannable from the dashboard. The Baileys engine returned the raw WhatsApp QR
ref string from
GET /sessions/:id/qr, while the dashboard (and the whatsapp-web.js engine) expect a PNG data URL — so the dashboard's<img>showed a broken image and Baileys sessions could not be linked via the UI. The Baileys adapter now renders the QR to adata:image/pngURL, matching the whatsapp-web.js engine's contract (the REST response shape is now consistent across engines). - Adopting migrations over a
synchronize-created SQLite data DB no longer crashes on boot. A data DB whose schema was created byDATABASE_SYNCHRONIZE=truehas an empty migrations table, so the baseline migration re-ranCREATE TABLE "sessions"and aborted startup withtable "sessions" already exists. The baseline migration is now idempotent (it skips when the schema already exists, mirroring the other migrations), so switching a SQLite data DB from synchronize to migration-managed boots cleanly and the DB becomes migration-managed going forward (existing rows preserved). Fresh deployments are unaffected. - Graceful shutdown no longer logs "could not find DataSource" on SIGTERM. With two named TypeORM
connections (
main+data),@nestjs/typeorm's shutdown hook resolved the default (unnamed) DataSource token and threwNest could not find DataSource element, leaving the DataSources undestroyed and the process exiting non-zero. The connection factories now carry theirname, so the shutdown hook resolves the correct named DataSource and the app shuts down cleanly (exit 0).
Changed
- Internal: the SQLite data-DB configuration comment and a dead
synchronizedefault inapp.module.tsnow reflect the actual behavior (the data DB is migration-managed by default;DATABASE_SYNCHRONIZE=trueopts into synchronize). No runtime behavior change.
[0.4.0] - 2026-06-18
Single-port deployment. The API now serves the bundled dashboard SPA itself, and the bundled Traefik reverse proxy is removed. This is a deployment/packaging change only — there are no API or application-code changes.
Changed
- BREAKING — single-port dashboard: the API now serves the bundled dashboard SPA. In production the
NestJS API serves the built dashboard from its own port (default
2785) via@nestjs/serve-static, so there is no separate dashboard container and the UI is available by default wherever the API runs./apiand/socket.ioare excluded so they keep returning real API/WebSocket responses. Opt out withSERVE_DASHBOARD=false. Dev is unchanged:npm run devstill runs the Vite dev server on:2886(HMR) proxying to the API. Split-origin hosting (dashboard on a separate origin/CDN) still works: build withVITE_API_URL=<api-origin>and hostdashboard/distanywhere. (#275) - The API's Content-Security-Policy now allows
https://fonts.googleapis.com(style-src) andhttps://fonts.gstatic.com(font-src) so the dashboard's webfonts load now that it is served under the API's CSP. (#275) - BREAKING — removed the bundled Traefik reverse proxy. With the API serving both the UI and the API
on one port, the shipped Traefik service was a single-backend passthrough that added no value (it
terminated no TLS out of the box). Removed the
traefikservice, thetraefik/configs, and thewith-proxyprofile. For TLS / public exposure, put your own reverse proxy (nginx, Caddy, a cloud load balancer, or a k8s Ingress) in front of the API — seedocs/12-troubleshooting-faq.md. (#276)
Added
npm run build:all(build API + dashboard) andnpm run prod(build then serve) for running the production build directly without Docker. (#275)
Migration
- The dashboard moved from
:2886(separate nginx container) to the API port:2785. Update bookmarks, monitoring, and any external reverse-proxy config accordingly. (#275) - The
with-dashboardandwith-proxycompose profiles are removed, and theDASHBOARD_PORT,PROXY_ENABLED, andDASHBOARD_ENABLEDenv vars are gone (silently ignored if still set).--profile fullnow starts the optional datastores (postgres, redis, minio). If you relied on the bundled Traefik for TLS, front the API with your own reverse proxy. (#275, #276)
[0.3.0] - 2026-06-18
Engine pluggability and plugin extensibility. OpenWA can now run on a second, browser-free WhatsApp engine (Baileys) as a peer to whatsapp-web.js, and bot-shaped features can ship as first-party extension plugins on a scoped capability layer instead of living in core (#265).
⚠️ Breaking (plugin API):
PluginContext.getServiceis removed. It was a stub returningundefinedwith no real consumers; out-of-tree plugins must migrate to the newctx.messages/ctx.enginecapabilities.
Added
- Baileys engine (
ENGINE_TYPE=baileys) — a second, browser-free WhatsApp engine built on@whiskeysockets/baileys(WebSocket/Noise protocol, no Chromium), selectable as a peer to the default whatsapp-web.js engine. It supports linking (QR + pairing code); sending text, media (image/video/audio/document/sticker), location, and contacts; reply / forward / react / delete-for-everyone; full group management (create, participants, subject/description, invite codes), profile pictures, and block/unblock; contacts, chats, and read receipts; and receiving messages with their media, captions, location, quoted context, reactions, and remote deletes. URL media is fetched through the same SSRF-guarded path as the default engine. Reply/forward/react/delete are backed by a per-session persisted message store (baileys_stored_messages, bounded byBAILEYS_MESSAGE_STORE_LIMIT, default 5000; cleared on logout; CASCADE-deleted with its session).getChatHistoryand labels/channels/status/catalog remain unsupported (HTTP 501) — Baileys has no on-demand history API, and the rest are parity with the whatsapp-web.js engine. Config:BAILEYS_AUTH_DIR(default./data/baileys); proxy is not yet supported on this engine. The engine loads lazily (dynamicimport()only when selected), so default-engine operators are unaffected and there is no global Node version floor. (#299, #307, #308, #309, #310, #312) - Plugin capability layer (Tier-2 extension plugins): scoped
ctx.messages(sendText/reply, routed throughMessageServiceso persistence and the send pipeline are preserved) and read-onlyctx.engine(getGroupInfo/getContacts/getContactById/checkNumberExists/getChats) onPluginContext, replacing the stubbedgetService. A manifest-declaredsessionsscope is enforced at the facade before any engine access (default['*']), and a capability call to a dead/unstarted session fails withPluginCapabilityErrorinstead of a raw error. (#294) HookManagerre-entrancy guard (AsyncLocalStorage): a plugin that sends from inside a hook handler can no longer recurse into the same event (synchronous re-entry; the asyncmessage:sentecho loop is documented as out of scope for now). (#294)auto-replyreference extension plugin, first-party and registered disabled by default — enable it viaPOST /plugins/auto-reply/enableto exercise the capability layer end-to-end. (#294)- Group auto-translation extension plugin — a first-party, disabled-by-default plugin that auto-translates incoming group messages via LibreTranslate, built entirely on the new capability layer (supersedes the earlier in-core approach). (#300)
- Schema-driven plugin config form (dashboard): the Plugins page now renders an editable config form
for any plugin that exposes a
configSchema(text / secret / number / boolean / enum), saved via the existing plugin-config endpoint — previously only the engine plugin had editable fields. (#303) - Spanish (
es) dashboard locale at full parity with English. (#292)
Changed
- Engine config is now opaque per-engine:
EngineFactorypasses only engine-neutral fields (sessionId/proxyUrl/proxyType) to an engine plugin and supplies engine-specific config (Puppeteer for whatsapp-web.js) as a blob via the plugin context, so a non-browser engine can be added without the factory knowing browser fields. No env-var or behavior change for existing deployments. (#296)
Fixed
- Dashboard stops polling for a QR code once its session is connected, and the dev Docker Compose setup proxies the dashboard to the API service correctly. (#311)
- Italian locale: the message-template strings are now fully translated. (#301)
[0.2.10] - 2026-06-17
Completes the v0.2.9 non-breaking batch with three dashboard/CI follow-ups that belonged to the same improvement set. No breaking changes.
Fixed
- MessageTester (dashboard) resolves the recipient through the engine, not a hand-built
…@c.usJID: it calls the check-number endpoint for the engine-canonical chat id and surfaces a clear "not registered on WhatsApp" message for unknown numbers, instead of silently sending to a guessed id (#265). NewmessageTester.notOnWhatsAppstring across all 8 locales. (#279) - Dashboard message bubbles use the engine-neutral
MessageTypevocabulary end-to-end — incoming websocket/revoked payloads are coerced viaasMessageType(), and an attachment's optimistic bubble is typed from its MIME (e.g. a PDF isdocument, notapplication), matching the backend normalization shipped in #270. (#281)
Internal
- CI: bump
docker/setup-qemu-actionv3 → v4 (Node 24), clearing the Node-20 deprecation warning on the image-build/publish jobs. (#280)
[0.2.9] - 2026-06-17
A reliability, security, and accessibility hardening release — no breaking changes. It tightens RBAC on
write endpoints, patches the ws/qs advisories, makes the busy message path and graceful shutdown
crash-resistant, fixes bulk-message terminal status, finally honors LOG_LEVEL, adds audit-log and
webhook-job retention, and improves dashboard accessibility and load-error states.
⚠️ RBAC tightening (action may be required): write endpoints for groups, contacts, labels, channels, catalog, and status now require the
OPERATORrole. If you used aVIEWERkey for any of these writes, switch it toOPERATOR(orADMIN). Everything else is backward-compatible.
Security
- Write endpoints for groups, contacts, labels, channels, catalog, and status now require the
OPERATORrole, closing an unintended privilege gap where aVIEWER-role API key could create/leave groups, manage participants, block contacts, post statuses, send products, and mutate labels. Read (GET) endpoints remain open to any valid key, matching the message/session controllers. (#284)⚠️ If you used a
VIEWERkey for any of these write operations, switch it toOPERATOR(orADMIN). - Patched a high-severity
wsadvisory (and a moderateqsDoS) on the live socket.io transport by bumping in-range deps (ws→8.21.0,engine.io→6.6.9,qs→6.15.2, plus the incidental re-resolutionsnpm audit fixpulled in) in both the API and dashboard. Lockfile-only — nopackage.json/API change. The remaining advisories are build-only (sqlite3→node-gyp→tar) and require a breakingsqlite3major, deferred. (#283)
Added
LOG_LEVELis now honored. It was read into config/compose but never applied (logging was hardcoded toinfo); the level (error/warn/info/debug/verbose) is now set at bootstrap. (#287)- Automatic audit-log retention. Audit logs older than
AUDIT_RETENTION_DAYS(default 90;0disables) are pruned daily and once at startup — the existingcleanup()was never scheduled, soaudit_logsgrew without bound. (#287)
Fixed
- Bulk-message batch status is now correct on cancel and stop-on-error. A cancelled batch could be
silently reverted to
PROCESSING(the final save overwrote theCANCELLEDstatus with the stale in-memory one), and astopOnErrorabort was reported asCOMPLETEDwhenever at least one message had already been sent. The terminal status is now re-derived (cancelled →CANCELLEDwith reconciled counters; stop-on-error →FAILED; otherwiseCOMPLETED/FAILED). Bulk-message itemtypeis also validated against the allowed set (text/image/video/audio/document) with@IsIn, so an invalid type is rejected up front instead of failing mid-send. (#286) - Graceful shutdown is now robust.
onModuleDestroyclears reconnect timers first and destroys engines in parallel, each isolated and time-bounded — so one hung/throwing Chromium can no longer abort teardown of the other sessions or stall shutdown. A session that exhausts its reconnect attempts is now markedFAILEDwith a reason (surfaced vialastError) instead of sitting silentlyDISCONNECTEDforever, and BullMQ webhook jobs are auto-evicted (removeOnComplete/removeOnFail) so completed/failed job payloads no longer accumulate unbounded in Redis (audit M19). (#287) - Engine-event handlers no longer risk unhandled promise rejections. Webhook dispatch is now
self-contained (a failed webhook lookup is logged and swallowed, not rejected into the fire-and-forget
callers), the
onMessage/onMessageCreatehook chains carry a.catch(), and a process-levelunhandledRejectionbackstop logs (instead of crashing) anything that still slips through. A transient DB hiccup on the busy message path can no longer drop the event silently or take the process down. Audit-log writes are also best-effort: a failed audit insert is logged and swallowed instead of turning an otherwise-successful operation (create/delete/start/stop session, etc.) into a500. (#285) - Dashboard accessibility: toast notifications are now an ARIA live region (
role="region"/aria-live, withrole="alert"on error/warning toasts) so screen readers announce success/error feedback, and the toast close button has an accessible name. The API-key visibility toggles on the Login and API Keys pages now have state-reflectingaria-labels (show/hide). Newcommon.showApiKey/common.hideApiKeystrings across all locales. (#288) - Dashboard no longer shows a misleading "nothing here" empty state when a list fetch fails. The
Webhooks, API Keys, and Logs pages discarded the query error and rendered the empty state on failure;
they now surface an accessible error banner (
role="alert") so the user knows the data failed to load. (#291)
Internal
- Added critical-path test coverage for
HookManager,AuditService, and the Postgres-UUID migration (497 tests total). (#289) - Dead-code sweep across the backend and dashboard (unused queue name,
MessageResult.ack, duplicate plugin config,Skeletoncomponent, orphaned React Query hooks/keys). (#290)
[0.2.8] - 2026-06-17
The engine-pluggability release: the whatsapp-web.js delivery-ack, message-type, and JID specifics are
now decoupled behind the neutral engine interface (a different engine, e.g. Baileys, can map its own at
the adapter boundary). Plus dashboard message templates, best-effort @lid → phone resolution, and a
Docker fix for sessions stuck at "authenticating".
⚠️ Breaking for webhook consumers: the
message.received/message.senttypefield is now a neutral enum — incomingchat→text,ptt→voice,vcard/multi_vcard→contact. Update any consumer that matched the raw whatsapp-web.js tokens. See Changed below.
Added
- Message templates (dashboard). Manage reusable message templates from a new dashboard page
(create/edit/delete,
{{variable}}placeholders), backed by the existingsessions/:id/templatesAPI, with full i18n across all locales. Thanks @Leslie-23 (#266). - Resolve a
@lidprivacy id to a phone number (#263), engine-neutral via a newIWhatsAppEngine.resolveContactPhone. On-demand endpointGET /sessions/:id/contacts/:contactId/phone→{ contactId, phone }(MSISDN digits, ornullwhen the engine can't map it — best-effort, since@lidexists to hide numbers). Optional inline resolution: setRESOLVE_LID_TO_PHONE=trueto attach a best-effortsenderPhoneto themessage.receivedwebhook + websocket payload for@lidsenders (off by default; per-sender lookups are cached). A non-whatsapp-web.js engine implements its own mapping.
Changed
- Message delivery status is now engine-agnostic (engine-pluggability decoupling, #265). The raw whatsapp-web.js
ack integer no longer leaks past the engine adapter — a neutral
DeliveryStatus(pending/sent/delivered/read/failed) flows through the interface, services, webhooks, websocket, and dashboard, so a non-whatsapp-web.js engine (e.g. Baileys) can map its own delivery codes at the adapter boundary.- The
message.ack/message.failedwebhooks now include a neutralstatusfield. The legacyackinteger is kept (deprecated) for backward compatibility — new consumers should readstatus. - Dashboard chat delivery ticks now update live over the websocket (the ack push was previously never emitted).
- Minor deprecated-surface deltas: the legacy webhook
ackreports3(not4) for a "played" voice/video receipt, and a play-after-read no longer emits a secondmessage.ack(both map tostatus: 'read').
- The
- Message
typeis now an engine-neutral enum (engine-pluggability decoupling, #265). Raw whatsapp-web.js message-type tokens no longer leak past the engine adapter — incoming live/history messages, persisted rows, and themessage.received/message.sentwebhooks now use a neutralMessageType(text/image/video/audio/voice/document/sticker/location/contact/revoked/unknown), consistent with outgoing sends. A non-whatsapp-web.js engine maps its own tokens at the adapter boundary.- Webhook contract change (both
message.receivedandmessage.sent): incomingtypewas previously raw — e.g.chat→text,ptt→voice,vcard→contact. New consumers should expect the neutral enum. - An idempotent startup backfill rewrites existing
messages.typerows to the neutral vocabulary (runs in every DB mode, including the zero-config SQLite default where data migrations don't), so historical chats render correctly and message-type stats don't split the same kind across old/new tokens. - Fixes a latent dashboard bug where incoming text (
chat) was mis-styled as media and shown as[chat]in reply previews.
- Webhook contract change (both
- JID construction moved into the engine (engine-pluggability decoupling, #265). The check-number endpoint
(
GET /sessions/:id/contacts/check/:number) now returns the engine's canonical chat id via a newIWhatsAppEngine.getNumberId(number)instead of the controller hand-building a…@c.usJID. As a result the returnedwhatsappIdis the engine-resolved id and may be normalized — it can differ from the submitted number's…@c.usform (e.g. a@lididentifier) rather than echoing the input. And status/story broadcasts are flagged with a neutralisStatusBroadcaston the message payload, so engine-neutral code no longer matches the engine-specificstatus@broadcastpseudo-JID. A non-whatsapp-web.js engine supplies its own JID scheme.
Fixed
- The
WWEBJS_WEB_VERSION(andWWEBJS_WEB_VERSION_REMOTE_PATH) workaround for sessions stuck at "authenticating" (#251) is now actually passed through by the Docker Compose files. Theenvironment:blocks enumerate vars explicitly with noenv_file, so settingWWEBJS_WEB_VERSIONin.envpreviously never reached the container — making the documented fix a no-op for Compose users. Added the passthrough (empty default = auto-select, no behavior change when unset) todocker-compose.ymlanddocker-compose.dev.yml. (#273) - Refined the Italian (
it) dashboard translations. Thanks @albanobattistella (#272).
[0.2.7] - 2026-06-16
A feature + fix release: typing simulation (anti-ban, on by default), a delete-chat endpoint, and a fix for duplicate outgoing messages in the dashboard — plus engine-agnostic groundwork and the nginx/ singleton-lock container fixes.
Added
- Typing simulation before single sends (anti-ban), on by default. A text send now shows a "typing…"
indicator and pauses briefly (length-scaled, jittered) before sending, so automated messages don't look
instantaneous. Disable with
SIMULATE_TYPING=false; cap the pause withSIMULATE_TYPING_MAX_MS(default 5000). Exposed engine-agnostically viaIWhatsAppEngine.sendChatStateand a newPOST /sessions/:id/chats/typingendpoint (state:typing|recording|paused). Bulk sends are unaffected (they keep their owndelayBetweenMessagesthrottle). - The engine API (
GET /infra/engines) and the dashboard Active Engine card now report the underlying engine library version (e.g.whatsapp-web.js 1.34.7), distinct from the adapter plugin version. - Delete a chat from the chat list via
POST /sessions/:id/chats/delete(e.g. to clear out groups you've left).OPERATORrole, engine-agnostic DTO. Thanks @tobiasstrebitzer (#261).
Fixed
- Duplicate outgoing messages in the dashboard Chats view. A race between the optimistic placeholder
and the realtime
message.sentecho could render a sent message twice. Reconciliation is now race-safe. (Display-only — the recipient always received exactly one message.) - Dashboard (simple nginx image) proxied API/WebSocket requests to a
openwahost that doesn't match the backend service name;dashboard/nginx.confnow targetsopenwa-apifor both/api/and/socket.io/, matching the production compose andDockerfile.traefik. Thanks @Abhishekrajpurohit (#259). - The container entrypoint now clears stale Chromium
SingletonLock/SingletonSocket/SingletonCookiefiles from session profiles on start, so a session can re-launch after an unclean shutdown instead of failing with "profile appears to be in use by another Chromium process" (exit Code 21). Thanks @Abhishekrajpurohit (#259).
Changed
mark-chat-readchatIdvalidation is now engine-neutral (accepts any engine's JID scheme, e.g. a Baileys…@s.whatsapp.net) instead of hardcoding the whatsapp-web.js@c.us/@g.us/@lidformat.
[0.2.6] - 2026-06-16
A patch release: stop Chromium from failing to launch on hardened read_only containers, and make the
Login language selector legible in dark mode.
Fixed
- Chromium no longer hard-crashes at launch (
Trace/breakpoint trap/chrome_crashpad_handler: --database is required) on hardenedread_onlycontainers. Chromium resolves its home dir from the passwd entry and ignores$HOME, so the home-lessopenwauser pointed it at a nonexistent/home/openwa. It is now given writable, pre-createdXDG_CONFIG_HOME/XDG_CACHE_HOMEdirs (created by the entrypoint, owned byopenwa). This supersedes the ineffective--crash-dumps-dirapproach from 0.2.5, which is a confirmed no-op for the crashpad database on Debian/Ubuntu system Chromium. (#254) - The Login screen's language
<select>option popup is now legible in dark mode. The login route never setsdata-theme, so it relied solely on theprefers-color-schememedia block, which set dark colors but leftcolor-schemeambiguous — rendering the native popup light with light text. (#249)
[0.2.5] - 2026-06-16
A patch release: pairing-code linking, a Chromium crash-dumps fix, and dark-mode native controls.
Added
- Pairing-code linking —
POST /sessions/:id/pairing-codereturns an 8-character code so a session can be linked via WhatsApp's "Link with phone number" instead of scanning the QR (useful for single-device / mobile onboarding). The session must be started and not yet authenticated. (#252)
Fixed
- Chromium is now given an explicit writable
--crash-dumps-dirso its crashpad handler always receives a--database, avoidingchrome_crashpad_handler: --database is requiredbrowser-launch failures on some hardened/container hosts. (#254) - Dashboard native controls (select option popups, scrollbars) now follow the explicit app theme via
color-scheme, instead of only the OS preference. (#249)
[0.2.4] - 2026-06-16
A patch release: stop LAN dashboard logins from 500-ing, add a pin for the WhatsApp Web version (works around sessions stuck at "authenticating"), and harden the data-export stream.
Added
- Pinnable WhatsApp Web version via
WWEBJS_WEB_VERSION. whatsapp-web.js 1.34.x can hang atauthenticating(the post-link sync never completes) when the auto-selected WA-Web version is incompatible; set a known-good version (browse wppconnect-team/wa-version) to pin it. Opt-in — unset keeps the default auto-version behavior. (#251)
Fixed
- Dashboard login over LAN no longer returns 500. A disallowed CORS origin threw inside the
cors callback, surfacing as an Internal Server Error; it now denies without throwing — so the
bundled (same-origin) dashboard works on a LAN/remote host out of the box, while a genuine
cross-origin dashboard still needs its origin in
CORS_ORIGINS. (#250) - Data-export stream now surfaces archive-level errors (gzip/finalize) on the response stream instead of an unhandled rejection or a silently truncated download. (#248)
[0.2.3] - 2026-06-15
A patch release: the dashboard now works when served over plain HTTP on a non-localhost
origin (LAN/remote), plus a configurable dev-compose bind host.
Fixed
- Dashboard now works over plain HTTP on a non-
localhostorigin. Toast notifications and the API-key copy button used secure-context-only browser APIs (crypto.randomUUID,navigator.clipboard) that are unavailable over HTTP on a LAN IP — so creating a session threwcrypto.randomUUID is not a function. Both now degrade gracefully (non-crypto id fallback;execCommand('copy')clipboard fallback). (#244) - The Infrastructure page's "View Bull Board" link no longer hardcodes
http://localhost:2785; it opens the configured API origin, so it works on remote/LAN deployments.
Changed
- The dev compose (
docker-compose.dev.yml) bind host is now configurable viaBIND_HOST(default127.0.0.1); setBIND_HOST=0.0.0.0in.envto reach the dev stack from another host (front it with a TLS proxy for anything public). Thanks @Stanley-blik (#245).
[0.2.2] - 2026-06-15
A security-hardening and reliability release. It tightens defaults (SSRF protection on, datastore secrets required, least-privilege webhook reads), closes a server-side request-forgery vector on media fetches and webhook deliveries, adds an optional Prometheus metrics endpoint, fixes headless Chromium startup in the non-root Docker image, and refreshes dependencies. Please read the Upgrade notes below before upgrading from 0.2.1 — several defaults changed.
Added
- Prometheus metrics at
GET /api/metrics(session/message gauges, process stats). Disabled by default; setMETRICS_TOKENand scrape withAuthorization: Bearer <token>.
Security
- Webhook secrets no longer leak: the HMAC
secretand customheadersare never returned from any webhook API response (responses are mapped through a scoped DTO). - Media-fetch SSRF closed: server-side
MessageMedia.fromUrlnow runs an SSRF host guard + byte cap + timeout before fetching a caller-supplied URL. - Redirects are not followed on webhook deliveries or media fetches, so a
302to an internal host can't bypass the SSRF guard. - Webhook SSRF protection is ON by default and validated at registration.
- Docker hardening: the socket-proxy is isolated on an
internal: truenetwork reachable only by the API (not the dashboard); the API container runs withcap_drop: [ALL](+ a minimal re-add),no-new-privileges, aread_onlyrootfs + tmpfs, and pid/mem limits. - Plugin loader rejects a manifest
mainthat escapes the plugin directory beforerequire(). - WebSocket: the API key is re-validated on every subscribe (a revoked key is
disconnected), is no longer sent in the handshake URL, and CORS uses the configured
allowlist instead of
*. - Production boot guard: the app refuses to start in production with empty/placeholder secrets, and the committed default datastore credentials were removed.
- Rate limiting now keys on the resolved client IP instead of the proxy IP.
Changed
- Webhook read routes now require an
OPERATOR+ key. - Webhook
events[]are validated against the known event types (plus*). - The six inline-body message endpoints (+ label/channel) now validate their input.
- The
mainauth/audit DBsynchronizeis config-driven (MAIN_DATABASE_SYNCHRONIZE, default on) with a bundled migration forapi_keys/audit_logs. - The readiness probe (
/api/health/ready) now performs real database checks and returns 503 when a dependency is down or the app is draining; the containerHEALTHCHECKpoints at it.
Fixed
- Message ack status UPDATE is scoped by
sessionId(no cross-session corruption) and backed by a composite index. getMessagessanitizeslimit/offsetso?limit=abcno longer reaches the query.- The Postgres database name now honors
DATABASE_NAMEconsistently between the runtime and the migration CLI. - Backup/restore scripts (
scripts/backup.sh/restore.sh) capture both databases (incl. the auth DBmain.sqlite) + sessions, so a restore preserves API keys. - Boot-time environment validation rejects an unknown
DATABASE_TYPEand missing Postgres credentials instead of silently coercing. - Message-event idempotency keys are session-scoped.
- Response-envelope documentation corrected to the real raw-payload shape; the unused interceptor/filter were removed; horizontal-scaling docs marked single-instance.
- Headless Chromium now starts in the Docker image as the non-root
openwauser —HOMEpoints at a writable directory, so the engine no longer dies withchrome_crashpad_handler: --database is requiredon a fresh container. (closes #242) - Marking a 1:1 chat as read now accepts the newer
@lid(privacy Linked ID) JID, not just@c.us. Thanks @suraj7974 (#241). - Allowlisted IPv6 literals in
SSRF_ALLOWED_HOSTSnow match whether or not the entry is bracketed (e.g.[::1]and::1). - The dashboard returns cleanly to the login screen on a
401instead of flashing a transient error toast. - A webhook
secretcleared via update is normalized to "no secret" (consistent with create) and is length-capped.
Dependencies
@bull-board/{api,nestjs,express}7.2.1 → 8.0.0 and@types/archiver7 → 8 (aligned with the archiver v8 runtime), plus a batch of minor/patch bumps (NestJS 11.1.27, BullMQ 5.78.1, AWS SDK, ESLint 10.5, Prettier 3.8, typescript-eslint 8.61, and a dashboard dev-tool bump).
Upgrade notes (behavior changes)
- Webhook reads now require
OPERATOR+ — aVIEWERkey reading webhooks gets403. - SSRF protection defaults ON — deployments that deliver webhooks or fetch media from
internal hosts must set
SSRF_ALLOWED_HOSTS(comma-separated) orWEBHOOK_SSRF_PROTECT=false. - Datastore secrets are now required — there is no
openwa/minioadmindefault;docker compose --profile postgres/minio upneedsDATABASE_PASSWORD/S3_*set, and production refuses to boot with placeholder secrets. - Bull Board
?apiKey=removed — authenticate viaX-API-Key/Authorization: Bearer. - New env knobs:
SSRF_ALLOWED_HOSTS,MEDIA_DOWNLOAD_MAX_BYTES,MEDIA_DOWNLOAD_TIMEOUT_MS,MAIN_DATABASE_SYNCHRONIZE,SHUTDOWN_DELAY_MS,OPENWA_MEM_LIMIT,METRICS_TOKEN.
[0.2.1] - 2026-06-15
A patch release.
Fixed
- Dashboard: The API client now honors
VITE_API_URLfor split-origin deployments. It readsVITE_API_URL(the API origin) and appends/apiinstead of always calling the same-origin/api; the same-origin default is unchanged. This fixes the dashboard failing with "Invalid API Key" when it is hosted on a different origin than the API. Thanks @jairo315-bit (#91).
Dependencies
- Dashboard: Bump the TypeScript dev dependency from 5.9.3 to 6.0.3 (#140).
[0.2.0] - 2026-06-15
A major feature- and security-focused release. Adds six dashboard languages and a real-time Chats view, completes the outgoing-message and delivery-state webhook story, introduces message templates and live chat history, hardens the API surface, session lifecycle, and container runtime, and upgrades the WhatsApp engine. See Upgrade notes for the behavior changes.
Added
- Dashboard / Chats: A new real-time Chats view — browse a session's conversations, stream incoming and outgoing messages live over WebSocket, send text and media, and mark chats as read. Thanks @akbarxleqi (#152).
- Dashboard / i18n: Six new languages on a single canonical language picker —
Simplified Chinese, Traditional Chinese, Arabic (full RTL), Telugu, French, and
Italian — alongside the existing English and Hebrew. The picker now also appears
on the Login screen and resolves
zh-Hant/HK/MO/TWregional variants. Thanks @jr-everstar (#150), @7odaifa-ab (#145), @abhinayguduri (#149), and @albanobattistella (#224). - Messages: Server-side message templates with
{{variable}}substitution — full CRUD under/sessions/:id/templatesplus aPOST /sessions/:id/messages/send-templateendpoint that renders and sends. Text templates only; interactive buttons/list/HSM are not supported on the whatsapp-web.js engine. Thanks @esakarya (#69). - Messages:
GET /sessions/:id/messages/:chatId/historyreads chat history live from WhatsApp (bypassing the local DB), with optional base64 media;limitis clamped to 1–100. Thanks @jgalea (#96, closes #162). - Groups: Group payloads now expose
linkedParentJID— the JID of the parent community a sub-group belongs to. Thanks @ferhatte10 (#201). - Webhooks:
message.sentnow fires for every outgoing message — including messages composed on a linked phone (via the whatsapp-web.jsmessage_createevent), not just messages sent through the API. (closes #93, #168, #195) - Webhooks / Sessions: Stored message status now reflects real delivery state
from acks —
delivered,read, andfailed— advancing monotonically (a late or out-of-order ack can never downgrade a higher status). A send that never receives a delivery ack stayssent, so it is visibly "not delivered" instead of falsely "sent". A newmessage.failedwebhook is emitted on an error ack so consumers can detect non-delivery without polling. Independently identified and prototyped by @aminebalti55 (#225). (closes #155, #199, #220) - Webhooks: Opt-in outbound SSRF protection — set
WEBHOOK_SSRF_PROTECT=trueto refuse webhook URLs that resolve to loopback, private, link-local, CGNAT, or cloud-metadata addresses (default off). (#221) - API:
BODY_SIZE_LIMITcaps request body size (default 25 MB, sized for base64 media sends).ENABLE_SWAGGERgates the/api/docsUI (default on; setfalseto disable it on exposed deployments). (#221, #67) - Webhooks:
message.receivedpayloads now include the group sender's identity —author(the participant WID) andcontact{ name, pushName }. Additive and backward compatible. (#223, closes #146) - Sessions: Opt-in auto-start of previously authenticated sessions on boot via
AUTO_START_SESSIONS=true(default off); sessions start sequentially to bound Puppeteer memory and one failure does not block the others. Thanks @mayko7d (#135, closes #218). - Sessions:
PUPPETEER_EXECUTABLE_PATHpoints the engine at a system Chromium/Chrome binary (for Alpine, ARM, or custom base images); unset keeps Puppeteer's bundled Chromium. (#219) - Docs: Community integrations page documenting the community-maintained ioBroker adapter (with a not-endorsed caveat). (#223, closes #134)
Changed
- Engine: Upgraded
whatsapp-web.jsfrom 1.26.1-alpha.3 to 1.34.7 (improved LID handling and stability). (#222) - Dashboard: Responsive layout for small screens and improved dark-mode contrast across pages; the Plugins page no longer truncates the feature list. Thanks @ashiwanikumar (#66).
- Auth: The first-boot admin key is now a cryptographically random
owa_k1_key in all environments by default; the fixeddev-admin-keyis seeded only whenALLOW_DEV_API_KEY=trueis explicitly set. (#221) - Auth: Requests with a valid key but insufficient role now return 403 Forbidden instead of 401. (#221)
- Docker / Podman: Base images are fully qualified (
docker.io/node:22-slim) and the container healthcheck usescurl, so the image builds and runs under Podman as well as Docker; added a Podman compatibility note to the docs. Thanks @3bsalam-1 (#68). - Docs / API: Interactive messages (
Buttons/List) are documented as unsupported on the whatsapp-web.js engine, and the speculative request-body examples were removed from the API collection. (#223, closes #158)
Fixed
- Sessions: An engine operation attempted while a session is disconnected, reconnecting, or still initializing (for example, refreshing the dashboard after disconnecting the session from the phone) now returns 409 Conflict ("session not connected") instead of a 500 Internal Server Error. Thanks @VincenzoKoestler for the related report. (#100)
- Sessions: A terminal engine failure (Chromium failed to launch, or WhatsApp
rejected the stored credentials) now surfaces as a
failedstatus with a human-readable reason on the session and in the dashboard, instead of silently closing the QR modal;auth_failureis treated as terminal rather than triggering a reconnect loop. A status race that could revertqr_readyback toinitializingduring startup is also fixed. (#219) - Engine: The built-in engine plugin now honors
SESSION_DATA_PATHand the configured Puppeteer settings instead of silently falling back to relative-path defaults. (#219) - Infrastructure dashboard: Saved configuration (
data/.env.generated) now applies reliably. The save handler wrote several env names the backend never read (STORAGE_PATH,S3_ACCESS_KEY/S3_SECRET_KEY,ENGINE_HEADLESS/ENGINE_SESSION_PATH/ENGINE_BROWSER_ARGS), so those settings silently reverted to defaults on restart; they now match whatconfiguration.tsreads. Saving also merges into the existing file instead of rewriting it from scratch, so a partial save no longer blanks other keys or stored secrets, and the form hydrates from a newGET /infra/configendpoint. Thanks @VincenzoKoestler (#226).
Security
- CORS: A wildcard (
*) origin is now refused in production (cross-origin requests are blocked), and CORS credentials are only enabled with an explicit origin allowlist. (#221) - WebSocket: A session-scoped API key can no longer subscribe to
*or to sessions outside itsallowedSessionsallowlist, preventing cross-tenant event leakage. (#221) - Authorization: Plugin enable/disable/config and the infrastructure read
endpoints (
/infra/status,/infra/config,/engines,/engines/current,/storage/files/count) now require an ADMIN key. (#221, #226) - Docker: The container reaches the Docker API through a least-privilege
docker-socket-proxyover TCP (DOCKER_HOST) instead of mounting the socket directly, and the Node process runs as a non-rootopenwauser via agosuprivilege-dropping entrypoint (dumb-initstays PID 1 for clean signal handling). Thanks @A831ARD0 (#227, #228; supersedes #129). - Health:
/api/healthis excluded from rate limiting so liveness probes do not exhaust the limiter. (#221)
Dependencies
- CI: Upgraded
softprops/action-gh-releasev2→v3 anddocker/build-push-actionv6→v7 (both move the GitHub Actions runtime to Node 24). (#169, #170)
Upgrade notes
- CORS in production: if you serve the dashboard on a different origin than the
API and relied on the default
CORS_ORIGINS=*, setCORS_ORIGINSto the explicit dashboard origin(s) — a wildcard is now refused in production. - Infrastructure reads are ADMIN-only:
/api/infra/status,/infra/config,/engines,/engines/current, and/storage/files/countnow require an ADMIN key. - Role-denied requests return 403 (was 401) — update clients that branch on the status code.
- Not-ready engine ops return 409 (was 500) — clients calling group/chat/send
endpoints while a session is not connected now receive
409 SESSION_NOT_READY. - First-boot key: non-production no longer seeds
dev-admin-keyby default (a random key is generated and printed in the startup banner / written todata/.api-key). SetALLOW_DEV_API_KEY=trueto restore the fixed local key. - Docker: the bundled Compose now runs a
docker-proxysibling and the API talks to it viaDOCKER_HOST, and the container runs as non-root; review the new Compose if you mounted the Docker socket directly or customized orchestration.
[0.1.8] - 2026-06-13
A bug-fix patch release for self-hosted PostgreSQL (TLS/SSL) deployments and webhook delivery deduplication. Backward compatible; defaults are unchanged.
Added
- Dashboard / Setup: The Infrastructure screen now exposes a Verify SSL Certificate toggle (
DATABASE_SSL_REJECT_UNAUTHORIZED), shown when SSL is enabled, so managed-Postgres TLS can be configured end-to-end from the UI without hand-editing.env. Defaults to verifying certificates; turn it off only for managed Postgres with self-signed certs (Supabase, Heroku, Render, Railway).
Fixed
- Database: The runtime PostgreSQL TypeORM connection now honors
DATABASE_SSLandDATABASE_SSL_REJECT_UNAUTHORIZED. Previously SSL was wired only into the migration CLI, soDATABASE_SSL=truewas silently ignored on the live connection. Defaults are unchanged (ssl: false), so existing deployments are unaffected. Thanks @farrasyakila (#205, closes #204). - Webhooks: Fixed idempotency-key generation for
message.received,message.sent,message.ack, andmessage.revoked. The dispatched payload is anIncomingMessagecarryingid(notmessageId), but the resolver short-circuited on a truthy'unknown'fallback and never readid, so every incoming-message webhook was keyedmsg_unknown— collapsing all messages into one deduplication bucket for consumers relying on theX-OpenWA-Idempotency-Keyheader. The resolver now usesid ?? messageId, with regression tests for the id-only and both-present payload shapes. Thanks @Singh1106 (#179). - Dashboard: The Login screen now derives the displayed version from
package.jsonat build time instead of a hard-coded literal, so it always reflects the installed release rather than a stale placeholder (closes #88).
[0.1.7] - 2026-06-13
A security- and stability-focused patch release. Hardens the API surface, clears a critical dependency advisory, and resolves a batch of self-hosting bugs. Backward compatible except for the two upgrade notes below.
Security
- Path traversal in storage import:
StorageServiceextracted tar archive entries (and read/wrote files) using unvalidated paths, allowing writes outside the storage root. Added a path-containment check on local read/write. Fixes #151. (#207) - Broken access control on infrastructure endpoints: every
/api/infra/*mutating and data-exfiltration endpoint (config, restart, export-data, import-data, storage/export, storage/import) required only any valid API key. They now require the ADMIN role. (#207) - X-Forwarded-For IP spoofing:
ApiKeyGuardtrusted the client-controllableX-Forwarded-Forheader for the per-keyallowedIpswhitelist. It now ignores it by default and only honours it for configuredTRUSTED_PROXIES. (#211) - Fail-closed IP whitelist: a key with an
allowedIpswhitelist but an undetermined client IP previously skipped the check (failed open); it now rejects. The QR endpoint (GET /sessions/:id/qr) now requiresOPERATOR. (#213) - Bull Board queue UI (
/api/admin/queues) was reachable unauthenticated; it now requires an ADMIN API key. (#214) - Critical dependency advisory: bumped
concurrentlyto v10 to clear the criticalshell-quoteadvisories. (#208)
Fixed
- Swagger UI now sends the
X-API-Keyheader (global security scheme). Fixes #173. (#109) - Dashboard Docker build failed on the Vite 8 /
@vitejs/plugin-reactv5 peer conflict; upgraded the plugin to v6. Fixes #103, #123, #197. (#136) - Bulk send (
/messages/send-bulk) returned 400 for text-only messages (missing@IsOptional()on media fields). Fixes #192. (#193) - Group participant endpoints returned 400 because their DTOs lacked
class-validatordecorators. Fixes #190. (#210) - Cross-platform
postinstall: replaced POSIX-only shell syntax that brokenpm installon Windows. Fixes #181. (#209) - Controllers now throw proper NestJS HTTP exceptions instead of generic
Error(correct 400/404 instead of 500). (#102) - Dashboard QR modal shows a loading state and keeps polling until ready. (#97)
- Traefik dashboard image now proxies
/apiand/socket.io. Fixes #116. (#131) - Wired the documented
API_MASTER_KEYenv var into the initial key seed. Fixes #153. (#133) - Fixed the
Locationconstructor ESM/CJS interop in the whatsapp-web.js adapter. (#186) - Incoming webhook messages now include location data for location messages. (#202)
Changed
- Lint is now enforced:
lintruns ESLint in check mode (fails on violations) with a newlint:fixfor local auto-fixing; fixed the latent lint issues this surfaced across the codebase. (#208) - CI publishes multi-arch Docker images (
linux/amd64+linux/arm64). Closes #164. (#166)
Added
- Documented the API key management endpoints. Closes #110. (#130)
- Indonesian Docker deployment guide and an API-spec diagram fix. (#188, #189)
Dependencies
- Dependabot minor/patch group (NestJS, BullMQ, Bull Board, helmet, ioredis,
etc.) and
@types/uuidv11. (#194, #143)
Upgrade notes
- Infrastructure endpoints are now ADMIN-only. Integrations calling
/api/infra/config|restart|export-data|import-data|storage/*with a non-admin key will now receive an auth error; use an ADMIN key. - Reverse-proxy + per-key
allowedIps: if you run behind Traefik/nginx and restrict keys by IP, setTRUSTED_PROXIES(e.g.TRUSTED_PROXIES=172.18.0.0/16) so the real client IP is resolved; otherwiseX-Forwarded-Foris ignored.
[0.1.6] - 2026-05-17
Fixed
- PostgreSQL migration crash:
AddMessageStatus1770108659848migration contained hardcoded SQLite-specific raw SQL (datetimetype,datetime('now')function) that PostgreSQL doesn't recognize. Migration now detects database type at runtime and uses appropriate SQL syntax. SQLite path is byte-for-byte identical to the original (zero regression). PostgreSQL path usestimestamp/NOW()/DEFAULT true/ inline FK constraints. Fixes #59, #62.
Changed
- Version badge sync: Updated version badges in
README.md(was 0.1.4),docs/README.md(was 0.1.0), and Swagger API docs (was 0.1.0) to 0.1.6. - Dependency updates: Merged Dependabot PRs for 12 npm packages (
@aws-sdk/client-s3,@nestjs/swagger,bullmq,class-validator,tar-stream,typeorm,@types/node,eslint,globals,jest,typescript-eslint) and 1 dashboard package (globals). - GitHub Actions: Upgraded
docker/setup-buildx-actionv3→v4,codecov/codecov-actionv5→v6,docker/login-actionv3→v4,docker/metadata-actionv5→v6,actions/upload-artifactv6→v7.
[0.1.5] - 2026-04-27
Fixed
- First-boot crash on SQLite: Data DB now defaults to
synchronize=truefor SQLite so the embedded database "just works" on first boot. ResolvesSQLITE_ERROR: no such table: sessionsthat appeared on fresh installs withoutDATABASE_SYNCHRONIZE=true. - PostgreSQL boot crash on
mainconnection:AuditLog.metadatanow usessimple-jsoninstead of the dynamicjsonColumnType(). Themainconnection is always SQLite, so it must not switch tojsonbwhenDATABASE_TYPE=postgres. FixesDataTypeNotSupportedError: Data type "jsonb" in "AuditLog.metadata" is not supported by "sqlite" database. - Operator env vars ignored:
data/.env.generatedno longer overridesprocess.envor project.env. Loading order is nowprocess env > .env > data/.env.generated, so values from Docker / shell / systemd take precedence over Dashboard-saved config.
Changed
- Auto-run migrations on boot: PostgreSQL data DB now runs pending migrations automatically; SQLite
also runs migrations when the user opts out of
synchronize. - Production migration scripts: Added
migration:run:prod,migration:revert:prod, andmigration:show:prodthat operate fromdist/so they can be executed inside the production container (which stripsts-node).
[0.1.4] - 2026-02-26
Changed
- ESLint 10 upgrade: Upgraded
eslintand@eslint/jsfrom v9 to v10 in both root and dashboard - Dependency updates: Merged Dependabot PRs for 6 root packages, 2 dashboard packages, and
@types/node24→25 - Dashboard peer deps: Added
.npmrcwithlegacy-peer-deps=trueforeslint-plugin-react-hooksESLint 10 compatibility
Fixed
- Dashboard lint: Fixed
no-useless-assignmenterror inInfrastructure.tsxcaught by ESLint 10's new rule - Auto-formatting: Applied Prettier fix to
whatsapp-web-js.types.ts
[0.1.3] - 2026-02-18
Fixed
- Node 22 LTS upgrade: Upgraded CI, release workflow, and Dockerfile from Node 20 to Node 22 (current LTS)
- Lockfile compatibility: Regenerated
package-lock.jsonwith npm 10 to match CI runtime - TypeScript type conflicts: Fixed
whatsapp-web.jstype mismatches after dependency update usingOmit<>pattern - ESLint peer dependency: Pinned
@eslint/jsandeslintto v9 to resolve Dependabot-introduced peer conflict - CI npm audit: Changed audit level from
hightocritical— high-severity findings are all in unfixable transitive dependencies
Changed
- Dependency updates: Merged Dependabot PRs for 12 npm packages, 6 dashboard packages, and 5 GitHub Actions
- GitHub Actions: Upgraded
actions/checkoutv4→v6,actions/setup-nodev4→v6,actions/upload-artifactv4→v6,docker/build-push-actionv5→v6,codecov/codecov-actionv4→v5
[0.1.2] - 2026-02-18
Fixed
- [P1] Database safety: Default
DATABASE_SYNCHRONIZEto false to prevent auto-schema changes in production - [P1] Graceful shutdown: Replace
process.exit()with ShutdownService callback pattern - [P1] PostgreSQL types: Use native
jsonbandtimestampcolumn types when available - [P1] Docker orchestration: Remove duplicate Docker management from main.ts (use DockerService)
- [P1] Queue stub: Remove unimplemented message queue processor that always threw errors
- [P2] Error visibility: Add proper logging to all 12 empty catch blocks across backend services
- [P2] Type safety: Reduce
anyusage from 38 to ~4 with typed interfaces for whatsapp-web.js - [P2] Data consistency: Add TypeORM transaction support for session CRUD; save-before-send pattern for messages
- [P2] Dashboard crashes: Add ErrorBoundary with fallback UI instead of white screen of death
- [P2] Dashboard security: Move API key from localStorage to sessionStorage (cleared on browser close)
- [P2] Dashboard UX: Replace blocking
alert()calls with Toast notifications - [P2] Dashboard error handling: Add logging to all empty catch blocks in dashboard pages
Changed
- Dashboard React Query: Migrate all 8 pages from manual
useState/useEffectto@tanstack/react-querywith automatic caching and deduplication - Dashboard code splitting: Route-level lazy loading with
React.lazy+Suspense— main bundle reduced 36%
Added
- CI npm audit:
npm audit --audit-level=highin CI pipeline to catch vulnerabilities - CI coverage threshold: Jest coverage floor to prevent regression
- CI dashboard job: Lint + build for React dashboard runs parallel with backend CI
- Dependabot: Automated dependency updates — npm weekly, GitHub Actions monthly
[0.1.1] - 2026-02-17
Added
- Unit Tests: 94 new tests across auth, session, message, and webhook modules (110 total, ~17% coverage)
- Release Workflow:
release.ymlGitHub Actions — tag-triggered with test gate, GitHub Release, and Docker semver tagging - SDK Scaffolds: JavaScript/TypeScript and Python client libraries in
sdk/directory - New hook events:
webhook:queued(after queue add) andwebhook:delivered(after actual delivery)
Fixed
- [P1] Idempotency Key: Made
generateIdempotencyKeydeterministic by removingDate.now(). Keys are now content-based for proper deduplication - [P2] Webhook Processor: Added
lastTriggeredAtupdate andwebhook:delivered/webhook:errorhooks after queue delivery - [P2] Hook Semantics: Added
webhook:queuedevent for queue mode;webhook:afternow only fires in direct mode - [P2] QueueModule DI: Added
TypeOrmModule.forFeature([Webhook])andHooksModuleimports for proper dependency injection - [P3] Message Processor: Changed placeholder to throw error so BullMQ correctly marks job as failed
[0.1.0] - 2026-02-05
🎉 Initial Release
OpenWA v0.1.0 is the first stable release featuring a complete WhatsApp API Gateway with all core functionality.
Core Features
- REST API for WhatsApp operations
- Multi-session support with concurrent session handling
- Web Dashboard for visual management
- WebSocket real-time events via Socket.IO
- API Key Authentication with role-based permissions
- Webhook System with HMAC signatures and queue-based retries
Messaging
- Send/receive text, image, video, audio, document messages
- Message reactions and replies
- Bulk messaging with rate limiting
- Location and contact sharing
- Sticker support
Advanced Features
- Groups API - Full CRUD operations
- Channels/Newsletter support
- Labels Management
- Catalog API for product management
- Status/Stories support
- Proxy per Session configuration
- Plugin System for extensibility
Infrastructure
- SQLite (development) and PostgreSQL (production) support
- Redis queue for webhook delivery (optional)
- S3/MinIO storage for media (optional)
- Docker + Docker Compose deployment
- Traefik reverse proxy integration
- Health check endpoints
- Zero-config onboarding with auto-generated API key
Security
- API key authentication with SHA-256 hashing
- Rate limiting (configurable)
- CIDR IP whitelisting
- CORS configuration
- Helmet security headers
- Audit logging for all operations
Dashboard
- Session management with QR code display
- Webhook configuration and testing
- API key management
- Message tester for debugging
- Infrastructure status monitoring
- Audit logs viewer
- Plugin management