Added
- API keys can carry an
allowedChatsallowlist next toallowedSessions, scoping a key to a chosen set of groups and contacts (omit or leave empty for unrestricted). A restricted key is refused with403on every route not explicitly marked safe, and on a marked route each chat it names is checked against the allowlist, with identity resolved through thelid_mappingstable so a phone entry also matches its resolved@lidform; of the list routes onlyGET /sessions/:sessionId/chatsis usable, and it filters before paginating. Thanks @bhavyachopra99 and @lasithadilshan. - Baileys inbound button, template quick-reply, list-row and native-flow replies arrive as
type: "text"with a structuredbutton { id, text? }onmessage.received(whatsapp-web.js still has no interactive reply fields). The REST chat-history route is whatsapp-web.js only and does not carry these fields. Thanks @gabrielmmoraes1999. - Baileys inbound business prompts that offer clickable buttons (or list rows) also carry
buttons: [{ id, text }, …]onmessage.received(URL/call CTAs are omitted, since they cannot be clicked), so choices like Sim/Não are no longer flattened away intobodyonly. Thanks @gabrielmmoraes1999. POST /api/sessions/:sessionId/messages/click-buttonsends a structured button/list reply against a stored WhatsApp Business prompt on Baileys (whatsapp-web.js returns501). ClassicbuttonsMessage/templateMessage/listMessageprompts are supported; native-flowinteractiveMessagereplies are unverified. The SDKs expose it asmessages.clickButton(JavaScript, Java, PHP),messages.click_button(Python) andMessages.ClickButton(Go). Thanks @gabrielmmoraes1999.- The dashboard Chats thread shows a quote preview and call detail on history-loaded messages, which previously rendered on live messages only. Thanks @gabrielmmoraes1999.
- The dashboard Chats thread renders inbound Baileys prompt
buttonsand taps them throughPOST .../messages/click-button; prompt choices are also kept in persisted messagemetadataso they survive reload for rendering. Clicking still requires the prompt to be in the engine store, so an evicted prompt 404s. Thanks @gabrielmmoraes1999. - Webhook and automation filters accept a
chatIdcondition, so a webhook can be scoped to specific groups or chats instead of only to a sender (#1634). Thanks @krishshah9944 and @bhavyachopra99. - The dashboard Message Tester sends to several groups at once: the Group dropdown is now a searchable checkbox list, and each selected group is messaged in turn (#1650). Thanks @C24212.
- The dashboard Templates list has a delete button on each row, so a template can be deleted without opening it in the editor first. Like the editor's delete button, it shows only for keys that can write templates. Thanks @C24212.
- On the dashboard Chats page, Escape closes the open chat, channel or status viewer and returns to the list. It leaves the key alone while a dialog, a menu or the media viewer is open, since those handle Escape themselves. Thanks @C24212.
- The dashboard Message Tester's Bulk mode can attach a file or a media URL, sent as image, video, audio or document, with the message text as the caption of an image, video or document; audio carries none, so text next to audio is refused. An inline file too large to repeat for every recipient is refused before sending. Thanks @C24212.
- The dashboard sidebar tells admins when a newer OpenWA release exists, as a link to its release notes next to the version.
GET /api/infra/update-check(ADMIN) reads the latest published GitHub release through the SSRF-guarded fetch and caches it for six hours, and a failed check never surfaces as an error;UPDATE_CHECK_ENABLED=falseturns the request off (#988, #1678). Thanks @voosam and @OneArmArmy for the request. - whatsapp-web.js sessions log the WhatsApp Web build their page actually runs when they reach
ready(web_version_running), and warn with both builds when it is not the pinned one (web_version_pin_not_applied), since a pin is not guaranteed to hold (#1679). Thanks @DavidgFernandes for the report.
Changed
- Baileys
listMessage,buttonsResponseMessage,templateButtonReplyMessageandlistResponseMessagenow classify astype: "text"(they previously fell through tounknown). Consumers filtering ontypewill see those shapes as text. Thanks @gabrielmmoraes1999. - The PostgreSQL data connection is pinned to UTC: parameters bind as UTC, naive timestamps read back as UTC, every pooled connection sets its session
TimeZone, and boot fails when the effective zone is not UTC year round. - Credentials on a
socks4://session proxy are reported at session start as unauthenticatable: SOCKS4 sends the user name as the connect request's user id and drops the password. - whatsapp-web.js clicks a
WWEBJS_ONBOARDING_CONTINUE_LABELSlabel only on a button inside a visible dialog. It matched any visible button with that exact text anywhere on the page, and every click counts toward the limit that moves a ready session toaction_required(#1679). Thanks @DavidgFernandes for the report. - The
session:createdplugin hook carries the session in the REST API shape, withoutproxyUrlorconfig, assession:deletedalready did. POST /api/plugins/{id}/disableon the engineengine.typeselects answerssuccess: falseinstead of reporting a disable that never took effect.- whatsapp-web.js sessions with no WA Web version pin no longer read or write
./.wwebjs_cache/, so they always load WhatsApp's live build.
Fixed
- The dashboard Chats page drops a staged reply when another chat or session is opened, so a text send there no longer fails with
404and a media send no longer quotes the previous chat's message. - whatsapp-web.js chat history resolves each message's sender through
getContact(), as the livemessagehandler already does, so a group participant outside the account's contacts gets a sender label in history too. Thanks @TanmayChachra. - A WebSocket client that emits without an ack callback now receives command replies at all. The gateway answered by returning a frame, which Socket.IO delivers through the ack callback and nowhere else, so a client written to the documented
messageevent saw no subscribe confirmation, no pong, and none of the refusals, including the session-scope denial. Replies now go out onmessageas well as through the ack. - A Baileys message the account sent from its phone is no longer lost when the message store cannot be read. The repeat-delivery check threw on a locked database or an unparseable row and the message was dropped with it, and WhatsApp does not re-deliver one it has already acked; the check now fails open, so at worst such a message is reported twice rather than never.
- Baileys
editMessageanswers with the edited message's id and original send time instead of the edit envelope's. The id named a message no route could address, and both fields disagreed with the whatsapp-web.js engine, which reports the message rather than the edit. - Deleting a Baileys message for yourself works again.
DELETE /api/sessions/:sessionId/messages/:messageIdwithforEveryone=falsebuilds the request from the stored message's send time, and the message store returns that field as a string rather than the number its type promises, so every such call failed with500before anything reached WhatsApp. - Saved Baileys contacts are no longer evicted by peers seen once in a group or a broadcast. Both populations shared one capped map, so a busy session answered
GET /api/sessions/:sessionId/contactswith fewer and fewer of the contacts the account actually saved.BAILEYS_SESSION_STORE_MAX_ENTRIESnow bounds the peer side of that map; the saved side is bounded by the account's own address book. - A Baileys business prompt no longer offers a call-to-action as a clickable choice. A button that opens a URL or dials a number reached
buttons[]onmessage.receivedand was accepted byPOST /api/sessions/:sessionId/messages/click-button, although WhatsApp has no reply form for it. - A webhook or automation filter written against an
@lidchat or sender keeps matching once the gateway learns that identity's phone number. Only the event side was resolved through the lid mapping, so an exclusion started delivering the chat it was meant to keep off the wire, and an inclusion stopped delivering, with the rule still reading correctly in the dashboard. - An ingress delivery whose dedup header is present but blank is keyed on the body hash instead of on the empty string. Every such delivery shared one key, so all but the first were dropped with no job enqueued and no dead-letter row, while each provider was answered with the route's success ack.
POST /api/sessions/:sessionId/automation-rulesanswers404for a session that does not exist, instead of letting the foreign key surface as500.- A session proxy whose credentials contain a bare
%is refused byPOST /api/sessionsandPATCH /api/sessions/:sessionId/proxywhen it is set, instead of being stored and then failing every later start and every proxied URL fetch with an opaqueURI malformed. - A WebSocket handshake whose transport closes while the key is being validated no longer burns a slot of the per-key connection cap for the life of the process.
- A repeated query parameter on an ingress verification challenge is read as its first value instead of answering
500: the value arrives from Express as an array and reached a constant-time compare that accepts only strings. - A plugin's declared ingress ack can no longer write the response headers that decide how a browser treats its own reflected body, set a cookie on the gateway's origin, or take over how the response is framed and decoded. A declared content encoding described bytes the ack does not carry, so the provider failed to decode it and retried a delivery already queued; a declared transfer encoding re-framed a body the host had already given a length; and a declared
Trailermade Node refuse to write the response at all. The declared content type still applies, through the allowlist that already governed it. - An ingress route whose manifest declares a non-string ack body or header value is refused at install and at boot, naming the field, instead of loading and then answering every delivery with that part of the ack silently missing. An already-installed plugin with such a manifest stops loading on upgrade and is recorded in error until the manifest is corrected, which is the same treatment every other manifest fault gets.
- Uploading a zip whose trailer parses but whose directory does not answers
400, not500. - A
socks5://orsocks4://proxy at an IPv6 literal connects: the bracketsURLkeeps were going on the wire as part of a hostname. - A Baileys session whose credentials carry no LID of their own no longer reports a
group.joinfor a group it created itself: every LID comparison answered false, so the creator was never recognised. The session's own LID mapping settles it instead. - Starting a session that turns out to be linked already no longer opens a QR modal over it, which then polled for a code that could never arrive. The decision now reads the list the dashboard re-reads after the start, not the state it held before it.
GET /api/sessions/:sessionId/contacts/:contactIdanswers with the saved contact when the same person occupies two store entries, one keyed by LID and one by phone number. Only one of the two carries the saved name, and the lookup returned whichever it reached first, so a saved contact could answer with no name andisMyContact: false. A contact reached through its LID also carries itsnumbernow, read from the resolved id rather than from the LID key, andGET .../contactslists such a person once instead of twice when both entries carry the saved name.- A QR code that arrives while the dashboard is asking the server about a disconnect no longer has its modal closed underneath it. The handler blanks the displayed code, re-reads the session list, and closed the modal when the answer said no engine was left; a reconnect completing inside that window had already pushed a fresh, scannable code.
- Escape dismisses the Chats emoji picker instead of closing the whole conversation behind it.
- A click on a WhatsApp Business prompt quotes the prompt in the stored reply, so the dashboard renders the question above the answer instead of an empty quote box.
- A send that quotes a message stores the quoted text, whatever the send is. Only replies did: an image, video, document, location, contact, poll or text sent with
quotedMessageIdstored the quoted id with an empty body, and the dashboard drew a blank quote box above it. A quoted message that has no text of its own, a caption-less image for instance, still renders as an empty box; only its text is recovered here. - A template prompt that numbers only some of its buttons no longer answers the bot with an index belonging to a different button. A template that carries its own numbering is answered with it, one that carries none is answered by position, and a choice left unnumbered inside a numbered template is answered by id alone, with no index at all. Such a choice is still offered in
buttons[]and still accepted by the click route. - Baileys sessions list the saved address book again after a process restart. WhatsApp sends neither history nor an app-state snapshot to a device that has synced once, and the contact store is in memory, so a restarted gateway answered
GET /api/sessions/:sessionId/contactswith only the peers it had seen since. Each session now re-pulls the snapshot of the app-state collection that carries saved contacts, once per engine start. That also repairs a PARTIAL address book, which is the usual shape: during the initial sync the library's event buffer folds the saved-contact updates into the history batch it is already holding, where their names are stripped as chat titles, so the store keeps plenty of contacts with the saved ones missing. The list is also the address book only: a peer known by pushname alone is no longer listed, whileGET .../contacts/:contactIdstill resolves it withisMyContact: false, and a chat title from history sync no longer overwrites a saved name. Thanks @gabrielmmoraes1999. - whatsapp-web.js sessions send media again on the WhatsApp Web builds rolled out on 2026-09-17: the library spread the media model into the outgoing message after its id, and the model's private
__x_idclobbered it, so every image, video, audio, document and status media send failed withData passed to getter must include an id propertywhile text kept working. An install-time patch carries upstream's one-line fix until a whatsapp-web.js release ships it (#1643, #1636). Thanks @15874611923 and @Magnarks for the reports. - An ingress route can declare
dedupOn: "body"to key provider retries on the raw body instead of the delivery-id header. A provider that mints a fresh delivery id on every retry attempt, as supabase/auth does inside its hook retry loop, was never deduplicated when an ack was lost, so the contact received the message twice; routes that do not declare it keep the header (#1641). - Baileys sessions record a message the account sent from its phone while the gateway was down, and dispatch
message.sentfor it, as they already did for one sent while the gateway was online. The offline replay carried the sameappendtag as the library's echo of an API send, which the upsert handler dropped wholesale; it now skips only the ids this session sent itself, and the phone's reactions, edits and revokes from that window are replayed the same way (#1667). - Baileys sessions no longer drop the inbound messages WhatsApp queued while they were down. The offline replay is tagged
append, which the upsert handler treated as history and skipped for anything older than the reconnect, so every message sent during an outage was never stored and never dispatched (#1660). Thanks @fransarni for the report. - A whatsapp-web.js session that comes up without its page-side call hook says so in the log instead of silently never reporting an incoming call. The library installs the hook only when the page's call-collection module exposes an
onmethod, so a WhatsApp Web build that keeps the module but drops that method leaves calls undetected while messages keep working. The check stays quiet when it cannot tell, and is skipped entirely when thepatch-wwebjs-ready-syncinstall-time patch is missing, since a session can then reach ready before the hook is installed and the warning would be false (#1655). - Stopping, unlinking or force-killing a session announces
session.status: disconnectedafter the engine is released, not while it is still tearing down, so a dashboard tab or a webhook consumer no longer learns the session is down in the one moment the API still reports its engine as loaded. On whatsapp-web.js that window lasted as long as Chromium took to close, and left an open QR modal on a dead code with the started actions still offered (#1649). - The dashboard blanks a session's displayed QR code as soon as the session disconnects, so a code minted by a connection that is gone is never left on screen to be scanned (#1649).
- A pairing-code request whose retry budget runs out on a reloading WhatsApp Web page answers
503instead of500, so a client can tell a retryable transport failure from a broken gateway (#1654). - A re-delivery of an already-persisted ingress event is answered with the route's declared ack, with the same status and headers the first delivery received (a body template renders from the retry), instead of a hardcoded
200 duplicatethat bypassed the ack entirely. A provider that validates the ack no longer fails on the retry path dedup exists for (#1638). - The
session-alivepreflight's503carries aRetry-After, so a provider that retries a 503 only when that header is present comes back instead of failing the call; the rejection writes no dedup row, so the retry is treated as a new delivery (#1639). - A
429from a rate-limit window carries a plainRetry-Afterin seconds alongside the existingRetry-After-<window>, which no HTTP client reads. The suffixed names stay, since they are what identify which window shed the request (#1639). - An ingress route's declared ack
content-typeofapplication/jsonortext/plainreaches the provider instead of being overwritten withtext/plain, so a provider that requiresapplication/jsonon a 200 or 202 accepts the ack; any other declared type is still sent astext/plain(#1637). Thanks @wesamdev for the report. - Restoring a data archive into PostgreSQL from a gateway that does not run in UTC no longer shifts every timestamp by the host offset, and no longer shifts it again on each further restore (#1624).
- Retention sweeps on PostgreSQL delete the rows their window names instead of taking up to the host's UTC offset of younger rows with them, and the
todaymessage counts cover the host's local day. - Session leases on PostgreSQL compare as instants across nodes in different time zones and across a daylight-saving change.
- Live WebSocket sockets are re-validated against the API-key table once a minute, so a key deleted, revoked, expired or narrowed on another node or by a direct database write drops its sockets there too, and a socket that connected while its key was being revoked no longer keeps that authorization for the life of the connection (#1625).
- A WebSocket subscribe whose socket is evicted while it is in flight no longer registers its rooms after the disconnect.
- The dashboard no longer opens a QR modal after a start that left the session without an engine.
- A failed start in the dashboard that left no engine shows the gateway's error in a toast.
- The dashboard closes a session's QR modal when the session fails, or disconnects with no engine left, instead of leaving it spinning.
- The dashboard disables a session's Start and Reconnect buttons while its start request is in flight.
- whatsapp-web.js sessions no longer report a call rejection that did not stop the call: the call reject route answers
501andautoRejectCallslogs a failed auto-reject. - A Baileys session added to or joining a group emits
group.join, as whatsapp-web.js already did. - A send that fails inside the engine logs a warning carrying the session, chat, message id and the engine's error, where only Nest's generic
[ExceptionsHandler]line recorded it before (#1679). Thanks @DavidgFernandes for the report. - A whatsapp-web.js send that fails inside the page reports what the page threw and the WhatsApp Web build that was running, in the failure log, the bulk batch result and the
message:failedhook, instead of the minifiedt: t(#1679). Thanks @DavidgFernandes for the report. - whatsapp-web.js sessions reach ready in the Docker image, Compose and Helm when no WA Web version is pinned; the library's local HTML cache tried to write to the read-only app directory.
- A stopped built-in
openwa-postgrescontainer is started before the data connection dials it, instead of the gateway crash-looping at boot. POST /api/infra/import-dataon PostgreSQL answersimported: falsewith the rejected row's database error instead of500.- A fresh Baileys link pulls the saved address book once its initial sync ends, instead of keeping a partial one until the next reconnect.
- Baileys: a reaction made from the account's phone in a 1:1 chat is attributed to the account instead of overwriting the contact's reaction.
- Baileys: a re-delivered inbound message no longer reaches the
message:receivedplugin hook a second time. - Baileys: a refused channel follow or unfollow answers
403, and an unknown channel or invite code404, instead of500. - Baileys: a write to a group that no longer exists, picture changes included, answers the documented
404instead of403; a group the account left still answers403. - whatsapp-web.js: reply and forward on a chat the session cannot resolve answer
404instead of a500that counted toward the send breaker. - whatsapp-web.js: an unknown label id answers
404instead of500, and adding or removing a label on an unknown chat answers404instead of reporting success. - whatsapp-web.js: deleting a contact's status answers
403instead of500. - whatsapp-web.js: a browser command timeout on group, invite, label, contact and chat operations answers
503instead of a false404,400orsuccess: false. - A media send by URL whose download fails answers
400, or413over the size cap, instead of500, and no longer counts toward the send breaker; a failure before any response through a session proxy answers503. - Media send, status, profile picture and group picture routes and the MCP send tools refuse a media
urlthat is not an absolute http(s) URL, instead of sending it as garbled base64; a bulk item'surlis checked aftervariablesare applied. - MCP tools refuse an empty
chatId,messageIdorquotedMessageId, as the REST routes do. GET /api/stats/messagescountsbyTypewithin the selected period only.POST /api/sessions/{sessionId}/templatesfor a missing session answers404instead of500.GET /api/searchrefuses anoffsetabove 100000 with400instead of returning the wrong page.- A bulk batch whose
batchIdishistoryor contains/,?or#can be read and cancelled;.and..are refused with400. - Bulk sends persist each item's own media and caption instead of the first media key in its content.
- Queued webhook deliveries use the webhook's current URL, headers and secret, and are dropped once the webhook is deleted, disabled or unsubscribed from the event.
- A
webhook:beforehook that returns a payload that is not an object no longer fails the delivery; its result is skipped, keeping any earlier hook's rewrite. - A request forwarded to the node that owns the session is labelled
application/json, so a form-encoded request no longer fails there with400. - Filtering messages or statuses by phone or by
@lidfinds rows stored under the other form after the lid mapping has left the cache, and accepts an upper-case or@hosted.lidid. - A human takeover keeps silencing bot plugins after the chat's lid resolves to a phone number.
- whatsapp-web.js group participant operations accept mixed-case and device-suffixed ids instead of reporting a member as not a member.
- A sandboxed plugin's send from an ingress handler or a timer no longer skips other plugins'
message:sendingvetoes andmessage:sent/message:failedhooks while one of its own hooks is pending. - A sandboxed plugin's hooks run at the lowest priority its handlers ask for; a priority that is not a finite number runs at the default 100.
- A
message:receivedormessage:senthook that returnsnullor a non-message no longer erases the message; its result is skipped, so the message and any earlier hook's rewrite are kept. - An ingress ack
Content-Typewith malformed parameters is sent as its bare media type instead of answering500after the delivery was stored. - Ingress manifests with an ack header value Node cannot send, a non-final ack status, a non-numeric
toleranceSec, or a route that cannot travel as one URL path segment are refused at load, and minted ingress URLs percent-encode the route. GETandDELETE /mcpanswer405instead of404, so Streamable HTTP clients stop reporting an SSE error on connect.- A mixed-case or padded
POSTGRES_SCHEMAis refused at boot, by the init script, by the migration CLI and on the Infrastructure page, instead of splitting the tables across two schemas. backup.shfails before staging anything whenBACKUP_DIRis not writable, such as the default on the container's read-only root, and names theBACKUP_DIRto use inside the container, plus theTMPDIRunder docker compose, whose/tmpis memory-backed.- Dashboard: the multi-group send refuses an empty message or a missing media source and stops on a
409instead of repeating the failure for every group. - Dashboard: the Message Tester follows a selected session that drops out of the ready list instead of sending to it.
- Dashboard: a bulk send no longer starts polling its progress after the page is left.
- Dashboard: the Chats search popup keeps Escape to itself, stays open while loading more, closes on a pick, works from the keyboard, and searches the text shown when refocused.
- Dashboard: read-only keys are no longer offered reply, react, delete, prompt buttons, Post a status or Disconnect, and send no mark-as-read.
- Dashboard: a failed session stop is reported on the home and Sessions pages instead of only in the console.
- Dashboard: the Webhooks page says why the list failed to load instead of also claiming none are configured, and every event in Available Events has a description.
- Dashboard: the API Keys show toggle, which could never reveal a key, is gone.
- Dashboard: the message chart shows hourly buckets in local time and labels daily buckets as UTC.
Documentation
- The troubleshooting FAQ covers the two linking failures reported most often: the passkey step WhatsApp now requires on some accounts, which neither engine library implements and no client-side change escapes (#560), and a Baileys pairing code answered with "Couldn't link device", which
BAILEYS_BROWSER_NAMEresolves (#1666). Thanks @adampalli and @Muhammad-Suban for the reports. - The pairing-code route, the phone-pairing example and the dashboard's phone tab warn that on whatsapp-web.js a code requested for a number that already has a linked session can end with WhatsApp unlinking that device; the capability matrix records the measurement (#1653).
- The docs and the OpenAPI field descriptions scope
maxReconnectAttemptsandreconnectBaseDelayto the gateway's own reconnect: on Baileys that is only the reconnect after a logged-out close, since the engine retries every other drop itself, with a fixed 1s to 60s backoff and no attempt cap (#1651). - The docs, the README, the OpenAPI field descriptions and the dashboard's auto-reject hint mark call rejection,
autoRejectCallsand the call outcome events as Baileys only, andcall.receivedas not reliable on whatsapp-web.js (#1118). Thanks @etondeengole for the report. - The FAQ,
.env.exampleand the whatsapp-web.js unrecognised-dialog warning describe the onboarding-modal language correctly:--lang=en-USis appended automatically, WhatsApp Web can still render the modal in the account's language, and a label inWWEBJS_ONBOARDING_CONTINUE_LABELSplus a restart of OpenWA covers it (#1679). Thanks @DavidgFernandes for the report. - The FAQ and the phone-pairing example say that
BAILEYS_BROWSER_NAMEtakes effect after a restart of OpenWA, not of the session, since the name is read at boot (#1666). Thanks @Muhammad-Suban for the report. - docs/06: a re-delivered ingress event gets the route's ack with the retry's
{timestamp}, and every media route lists the URL-fetch400and413. - docs/06 and the OpenAPI text: webhook
retryCountcounts total attempts,GET /labelsanswers200 []on a personal account, and the reconnect cap is the per-sessionmaxReconnectAttempts. - docs/06: a filter condition on a field the event lacks passes the event for
isNotand for a booleanfalse. - A URL fetch through an HTTP or HTTPS session proxy is not DNS-rebind pinned; the docs and
.env.examplesay so. - docs/17 lists which dashboard components ship no stylesheet.
Dependencies
socks(^2.8.10) is now a direct dependency: the undici dispatcher that routes a caller-supplied URL fetch through a session's SOCKS proxy speaks the protocol itself, since undici ships no SOCKS4 transport and its experimental SOCKS5 agent hands the proxy percent-encoded credentials.
Upgrade notes (behavior changes)
- A repeat ingress delivery is no longer distinguishable by its response. A route with no declared
responseanswers202 acceptedwhere it answered200 duplicate, and a route with a declared ack answers that ack, including a non-2xx one, so a provider retrying such a route no longer stops after the first retry. Theingress_eventsdedup row remains the record of which deliveries were repeats. - A client that honours
Retry-Afternow sees one on every rate-limit429, where before it saw only the suffixedRetry-After-<window>and typically ignored it. When thelongwindow sheds a request that value can be up to an hour, so a client that sleeps for it will now wait rather than retry immediately. - PostgreSQL deployments: the data connection issues
SET TIME ZONE 'UTC'per connection and verifies the result at boot. A deployment where that cannot hold (a pooler that drops session state) now fails to start, naming the effective zone; set the default instead withALTER DATABASE "<database>" SET TimeZone='UTC'. SQLite deployments, and any gateway already running in UTC, are unaffected and no data moves. - PostgreSQL deployments whose gateway ran outside UTC before this release: the twelve columns the app writes itself hold that host's local wall time and now read as UTC, so they appear shifted by the offset. They are
sessions.connectedAt,sessions.lastActiveAt,sessions.claimedAt,sessions.leaseExpiresAt,webhooks.lastTriggeredAt,webhook_outbox_events.lastAttemptAt,ingress_events.lastDispatchAt,message_batches.started_at,message_batches.completed_at,lid_mappings.updatedAt,chat_states.updatedAtandbaileys_stored_messages.createdAt. The last three carry aDEFAULT now()that never fires, because their only writer passes the value. With the gateway stopped, convert each with the host's old zone, which resolves daylight saving per row:UPDATE sessions SET "connectedAt" = ("connectedAt" AT TIME ZONE 'Asia/Jakarta') AT TIME ZONE 'UTC' WHERE "connectedAt" IS NOT NULL;.claimedAtandleaseExpiresAtare cluster runtime state: clear them, do not convert them, with every node stopped and before the first start on this release, or the lease reads shifted by the old offset and the session is unusable until it lapses. East of UTC it reads hours into the future, so every node treats the session as held elsewhere andPOST /sessions/{id}/startanswers409; west of UTC it reads already lapsed, so a peer can adopt a session that is still running.UPDATE sessions SET "nodeId" = NULL, "claimedAt" = NULL, "leaseExpiresAt" = NULL, "nodeUrl" = NULL;. Leavinglid_mappings.updatedAtandchat_states.updatedAtunconverted also mis-ranks the boot preload of both caches, which orders by that column under a cap. - The remaining eighteen
createdAt/updatedAtcolumns are written by PostgreSQL itself (DEFAULT now()) in the server's zone, not the gateway's. On a UTC server, which is the image default and what the bundled Compose file starts, they are already correct and must not be converted; convert them only if the server itself ran outside UTC, with the server's old zone. messages.createdAtis the exception among those eighteen and must not be converted in bulk. A live message takes the server default, but a row written by the Baileys history backfill carries the message's own time, bound by the gateway, so on a gateway that ran outside UTC that column holds both conventions at once and nothing in the row says which. The residual skew affects only the backfilled rows, and it shows up in chat ordering, thetodaycounters and the media retention window for them.- A table that has had an archive from a SQLite gateway restored into it holds those rows in correct UTC while the app wrote its own in local time. The two are indistinguishable within the column, so a blanket
UPDATEwould move the rows that are already right; correct such a column row by row against a known archive, or leave it as it is. - An archive a PostgreSQL gateway outside UTC exported before this release carries the columns the app wrote at their true instant but every
DEFAULT now()column one offset behind: the export read each value back as local time, which undoes the local-time bind only for the columns the app wrote. A restore binds that text as it stands, and each further export and restore before this release took the whole table one more offset back. On a table holding nothing but restored rows, apply the inverse to each timestamp column once per restore of a pre-release archive,UPDATE sessions SET "createdAt" = ("createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Jakarta';, then whatever conversion above the column takes anyway. The two cancel once for the twelve app-written columns, so after a single restore those are already correct and take no update, while on a UTC server theDEFAULT now()columns take the inverse once per restore. The exception issessions.claimedAtandleaseExpiresAton a session any node held when the restore ran: the restore keeps the live values rather than the archive's, so they take no inverse, only the treatment above.messages.createdAtsplits the same way between backfilled and live rows, so it still takes no bulk update. Where the table also holds rows written after the restore, no blanket conversion is safe. - Re-export after upgrading for an archive whose stamps are the instants they claim; a pre-release archive restored after upgrading still carries its shift in, and counts as one restore above.
- A WebSocket client can now be disconnected with an
UNAUTHORIZEDframe up to a minute after its key changed, where before only the node that processed the change disconnected it; reconnect and resubscribe on that frame. A rename, and the key's usage counters, evict nobody. - WebSocket command replies are pushed on the
messageevent as well as returned through the ack callback. A client that passes an ack AND listens onmessagetherefore sees each reply twice from this release; handle it in one place. - A caller-supplied URL leaves through the session proxy from this release. Set
SESSION_PROXY_URL_FETCH=falsewhen a session proxy is a WhatsApp-only route that cannot reach arbitrary media hosts. - Baileys:
GET /api/sessions/{sessionId}/contacts, the MCPContactFindAlltool and a plugin'sengine.getContactslist only contacts with a name saved on the phone. A peer known by pushname alone is no longer listed, thoughGET /api/sessions/{sessionId}/contacts/{contactId}still resolves it withisMyContact: false; whatsapp-web.js still lists unsaved chat partners. - whatsapp-web.js:
POST /api/sessions/{sessionId}/calls/{callId}/rejectanswers501instead of a200that did not stop the call from ringing. - whatsapp-web.js: a
WWEBJS_ONBOARDING_CONTINUE_LABELSlabel is clicked only on a button inside a[role="dialog"]or[aria-modal="true"]container, the same scope theonboarding_dialog_unrecognizedwarning reports labels from. - The gateway makes an outbound request to
api.github.comwhen an admin opens the dashboard, to find the latest release. The answer is cached for six hours, or fifteen minutes after a failure. SetUPDATE_CHECK_ENABLED=falsewhere that egress is not wanted. - A plugin that read
proxyUrl,configor other raw entity fields from thesession:createdpayload now receives the REST session shape. - With
QUEUE_ENABLED=true, deliveries queued for a webhook that is then deleted, disabled or unsubscribed are dropped; after a URL or secret change they go to the new URL, signed with the new secret. - A media
urlthat is not an absolute http(s) URL answers400on every send route; a bulk item whose renderedurlis not one fails that item only. - Media conversion refuses an input that is not a single-file media container with
400. - An installed plugin whose ingress manifest declares a route that is not one URL path segment, a non-numeric
toleranceSec, a1xxack status or an ack header value Node cannot send now fails to load at boot and stays in the error state until the manifest is fixed. - A mixed-case or padded
POSTGRES_SCHEMAnow fails the boot, naming the rule. - A takeover or a resolved conversation left on a chat's lid form before the upgrade also silences bots on its phone form from now on; hand the chat back to the bot through the plugin that holds it to release it.
- A number WhatsApp recycled keeps its earlier owner's lid mapped to it, so a handover decision on either owner's chat applies to both.
- IPv6 clients in one /64 now share every per-client rate-limit bucket; where several reach the gateway from one IPv6 network, raise the
RATE_LIMIT_*,INGRESS_IP_LIMIT,WS_RATE_LIMIT_HANDSHAKE_MAXandMCP_IP_RATE_LIMIT_MAXlimits, andINFLIGHT_BODY_BUDGET_BYTESfor concurrent uploads.
Security
adm-zipmoves to0.6.1, which bounds the memory a declared uncompressed size can ask for (GHSA-7q85-xj36-vmfc) and stops extraction following symlinks out of the target directory. The plugin installer reads entries one at a time under its own byte cap rather than extracting the archive, so neither vector was reachable there.- Baileys sessions with a SOCKS4 proxy fetch through it instead of connecting direct: inbound media, the WhatsApp Web version lookup, the initial-sync payloads and a product card's image URL, which 0.23.5 routed through HTTP, HTTPS and SOCKS5 proxies only (#1626).
- A media URL passed to a send route or to
POST /api/sessions/{sessionId}/media/convert/voiceor.../convert/video, and the link preview of a text send, are fetched through the named session's egress proxy on both engines, instead of leaving from the gateway's own address (#1626). - A proxy password no longer reaches the log. A failed SOCKS connect carries the whole proxy config as the error's only property, and
BAILEYS_LOG_LEVEL=debugwrote it to stdout verbatim. Logs written at that level by an earlier release may hold the password; rotate it. - Media conversion runs only the ffmpeg demuxers of single-file media containers, so an operator key can no longer make a crafted input read other local files on the host (GHSA-c9fv-6j9g-8p98).
- The MCP pre-auth per-IP limit counts each message of a JSON-RPC batch, so one request can no longer run more unauthenticated key lookups, or write more audit rows, than
MCP_IP_RATE_LIMIT_MAXallows. - Per-client rate limits key an IPv6 client on its /64, so rotating addresses inside one allocation no longer escapes them (#1686). Thanks @Saksham-official.
- The MCP, Bull Board and WebSocket pre-auth limiters, the WebSocket rate-limit audit sampler, the per-client upload body budget and the health route's auth-failure audit limiter key an IPv6 client on its /64 as well (#1695).