github rmyndharis/OpenWA v0.23.6

2 hours ago

Added

  • API keys can carry an allowedChats allowlist next to allowedSessions, scoping a key to a chosen set of groups and contacts (omit or leave empty for unrestricted). A restricted key is refused with 403 on every route not explicitly marked safe, and on a marked route each chat it names is checked against the allowlist, with identity resolved through the lid_mappings table so a phone entry also matches its resolved @lid form; of the list routes only GET /sessions/:sessionId/chats is 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 structured button { id, text? } on message.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 }, …] on message.received (URL/call CTAs are omitted, since they cannot be clicked), so choices like Sim/Não are no longer flattened away into body only. Thanks @gabrielmmoraes1999.
  • POST /api/sessions/:sessionId/messages/click-button sends a structured button/list reply against a stored WhatsApp Business prompt on Baileys (whatsapp-web.js returns 501). Classic buttonsMessage / templateMessage / listMessage prompts are supported; native-flow interactiveMessage replies are unverified. The SDKs expose it as messages.clickButton (JavaScript, Java, PHP), messages.click_button (Python) and Messages.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 buttons and taps them through POST .../messages/click-button; prompt choices are also kept in persisted message metadata so 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 chatId condition, 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=false turns 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, templateButtonReplyMessage and listResponseMessage now classify as type: "text" (they previously fell through to unknown). Consumers filtering on type will 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_LABELS label 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 to action_required (#1679). Thanks @DavidgFernandes for the report.
  • The session:created plugin hook carries the session in the REST API shape, without proxyUrl or config, as session:deleted already did.
  • POST /api/plugins/{id}/disable on the engine engine.type selects answers success: false instead 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 404 and 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 live message handler 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 message event saw no subscribe confirmation, no pong, and none of the refusals, including the session-scope denial. Replies now go out on message as 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 editMessage answers 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/:messageId with forEveryone=false builds 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 with 500 before 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/contacts with fewer and fewer of the contacts the account actually saved. BAILEYS_SESSION_STORE_MAX_ENTRIES now 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[] on message.received and was accepted by POST /api/sessions/:sessionId/messages/click-button, although WhatsApp has no reply form for it.
  • A webhook or automation filter written against an @lid chat 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-rules answers 404 for a session that does not exist, instead of letting the foreign key surface as 500.
  • A session proxy whose credentials contain a bare % is refused by POST /api/sessions and PATCH /api/sessions/:sessionId/proxy when it is set, instead of being stored and then failing every later start and every proxied URL fetch with an opaque URI 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 Trailer made 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, not 500.
  • A socks5:// or socks4:// proxy at an IPv6 literal connects: the brackets URL keeps 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.join for 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/:contactId answers 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 and isMyContact: false. A contact reached through its LID also carries its number now, read from the resolved id rather than from the LID key, and GET .../contacts lists 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 quotedMessageId stored 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/contacts with 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, while GET .../contacts/:contactId still resolves it with isMyContact: 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_id clobbered it, so every image, video, audio, document and status media send failed with Data passed to getter must include an id property while 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.sent for it, as they already did for one sent while the gateway was online. The offline replay carried the same append tag 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 on method, 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 the patch-wwebjs-ready-sync install-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: disconnected after 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 503 instead of 500, 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 duplicate that bypassed the ack entirely. A provider that validates the ack no longer fails on the retry path dedup exists for (#1638).
  • The session-alive preflight's 503 carries a Retry-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 429 from a rate-limit window carries a plain Retry-After in seconds alongside the existing Retry-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-type of application/json or text/plain reaches the provider instead of being overwritten with text/plain, so a provider that requires application/json on a 200 or 202 accepts the ack; any other declared type is still sent as text/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 today message 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 501 and autoRejectCalls logs 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:failed hook, instead of the minified t: 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-postgres container is started before the data connection dials it, instead of the gateway crash-looping at boot.
  • POST /api/infra/import-data on PostgreSQL answers imported: false with the rejected row's database error instead of 500.
  • 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:received plugin hook a second time.
  • Baileys: a refused channel follow or unfollow answers 403, and an unknown channel or invite code 404, instead of 500.
  • Baileys: a write to a group that no longer exists, picture changes included, answers the documented 404 instead of 403; a group the account left still answers 403.
  • whatsapp-web.js: reply and forward on a chat the session cannot resolve answer 404 instead of a 500 that counted toward the send breaker.
  • whatsapp-web.js: an unknown label id answers 404 instead of 500, and adding or removing a label on an unknown chat answers 404 instead of reporting success.
  • whatsapp-web.js: deleting a contact's status answers 403 instead of 500.
  • whatsapp-web.js: a browser command timeout on group, invite, label, contact and chat operations answers 503 instead of a false 404, 400 or success: false.
  • A media send by URL whose download fails answers 400, or 413 over the size cap, instead of 500, and no longer counts toward the send breaker; a failure before any response through a session proxy answers 503.
  • Media send, status, profile picture and group picture routes and the MCP send tools refuse a media url that is not an absolute http(s) URL, instead of sending it as garbled base64; a bulk item's url is checked after variables are applied.
  • MCP tools refuse an empty chatId, messageId or quotedMessageId, as the REST routes do.
  • GET /api/stats/messages counts byType within the selected period only.
  • POST /api/sessions/{sessionId}/templates for a missing session answers 404 instead of 500.
  • GET /api/search refuses an offset above 100000 with 400 instead of returning the wrong page.
  • A bulk batch whose batchId is history or contains /, ? or # can be read and cancelled; . and .. are refused with 400.
  • 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:before hook 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 with 400.
  • Filtering messages or statuses by phone or by @lid finds rows stored under the other form after the lid mapping has left the cache, and accepts an upper-case or @hosted.lid id.
  • 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:sending vetoes and message:sent/message:failed hooks 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:received or message:sent hook that returns null or 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-Type with malformed parameters is sent as its bare media type instead of answering 500 after 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.
  • GET and DELETE /mcp answer 405 instead of 404, so Streamable HTTP clients stop reporting an SSE error on connect.
  • A mixed-case or padded POSTGRES_SCHEMA is 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.sh fails before staging anything when BACKUP_DIR is not writable, such as the default on the container's read-only root, and names the BACKUP_DIR to use inside the container, plus the TMPDIR under docker compose, whose /tmp is memory-backed.
  • Dashboard: the multi-group send refuses an empty message or a missing media source and stops on a 409 instead 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_NAME resolves (#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 maxReconnectAttempts and reconnectBaseDelay to 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, autoRejectCalls and the call outcome events as Baileys only, and call.received as not reliable on whatsapp-web.js (#1118). Thanks @etondeengole for the report.
  • The FAQ, .env.example and the whatsapp-web.js unrecognised-dialog warning describe the onboarding-modal language correctly: --lang=en-US is appended automatically, WhatsApp Web can still render the modal in the account's language, and a label in WWEBJS_ONBOARDING_CONTINUE_LABELS plus a restart of OpenWA covers it (#1679). Thanks @DavidgFernandes for the report.
  • The FAQ and the phone-pairing example say that BAILEYS_BROWSER_NAME takes 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-fetch 400 and 413.
  • docs/06 and the OpenAPI text: webhook retryCount counts total attempts, GET /labels answers 200 [] on a personal account, and the reconnect cap is the per-session maxReconnectAttempts.
  • docs/06: a filter condition on a field the event lacks passes the event for isNot and for a boolean false.
  • A URL fetch through an HTTP or HTTPS session proxy is not DNS-rebind pinned; the docs and .env.example say 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 response answers 202 accepted where it answered 200 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. The ingress_events dedup row remains the record of which deliveries were repeats.
  • A client that honours Retry-After now sees one on every rate-limit 429, where before it saw only the suffixed Retry-After-<window> and typically ignored it. When the long window 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 with ALTER 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.updatedAt and baileys_stored_messages.createdAt. The last three carry a DEFAULT 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;. claimedAt and leaseExpiresAt are 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 and POST /sessions/{id}/start answers 409; 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;. Leaving lid_mappings.updatedAt and chat_states.updatedAt unconverted also mis-ranks the boot preload of both caches, which orders by that column under a cap.
  • The remaining eighteen createdAt/updatedAt columns 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.createdAt is 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, the today counters 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 UPDATE would 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 the DEFAULT now() columns take the inverse once per restore. The exception is sessions.claimedAt and leaseExpiresAt on 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.createdAt splits 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 UNAUTHORIZED frame 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 message event as well as returned through the ack callback. A client that passes an ack AND listens on message therefore 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=false when a session proxy is a WhatsApp-only route that cannot reach arbitrary media hosts.
  • Baileys: GET /api/sessions/{sessionId}/contacts, the MCP ContactFindAll tool and a plugin's engine.getContacts list only contacts with a name saved on the phone. A peer known by pushname alone is no longer listed, though GET /api/sessions/{sessionId}/contacts/{contactId} still resolves it with isMyContact: false; whatsapp-web.js still lists unsaved chat partners.
  • whatsapp-web.js: POST /api/sessions/{sessionId}/calls/{callId}/reject answers 501 instead of a 200 that did not stop the call from ringing.
  • whatsapp-web.js: a WWEBJS_ONBOARDING_CONTINUE_LABELS label is clicked only on a button inside a [role="dialog"] or [aria-modal="true"] container, the same scope the onboarding_dialog_unrecognized warning reports labels from.
  • The gateway makes an outbound request to api.github.com when an admin opens the dashboard, to find the latest release. The answer is cached for six hours, or fifteen minutes after a failure. Set UPDATE_CHECK_ENABLED=false where that egress is not wanted.
  • A plugin that read proxyUrl, config or other raw entity fields from the session:created payload 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 url that is not an absolute http(s) URL answers 400 on every send route; a bulk item whose rendered url is 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, a 1xx ack 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_SCHEMA now 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_MAX and MCP_IP_RATE_LIMIT_MAX limits, and INFLIGHT_BODY_BUDGET_BYTES for concurrent uploads.

Security

  • adm-zip moves to 0.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/voice or .../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=debug wrote 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_MAX allows.
  • 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).

Don't miss a new OpenWA release

NewReleases is sending notifications on new releases.