Added
- The JavaScript, Python, PHP, Go and Java SDKs expose
POST /sessions/:id/chats/pinandchats/mute, the two routes this release adds, so neither ships without a client.muteUntilis epoch milliseconds andnullunmutes; both are required, since an omitted value reads as either. POST /sessions/:sessionId/channels/:channelId/owner/transferhands a channel to a new owner on the Baileys engine; it is irreversible, and the account cannot take the channel back. The whatsapp-web.js engine answers501.POST /sessions/:sessionId/channels/:channelId/admins/demotedemotes a channel admin back to a subscriber on the Baileys engine; neither library has a promote counterpart, so promote from the WhatsApp app. The whatsapp-web.js engine answers501.POST /sessions/:id/chats/pinpins a chat to the top of the list or unpins it, on both engines;success: falsereports WhatsApp's three-pin cap, which only the whatsapp-web.js engine can observe.POST /sessions/:id/chats/mutemutes a chat until an epoch-milliseconds timestamp or unmutes it with an explicitnull, on both engines; unlikechats/archiveit has no declined outcome, since the change is not keyed to the chat's last message.engine-inventory-parity.spec.tsfails when adocs/29exposure or event mark no longer matches the adapters: a symbol marked as used must appear in adapter code rather than only in a comment, and an event marked consumed must have a listener behind it.docs/29-engine-capability-matrix.md(docs/29) now covers all 152 Baileys socket methods, all 81 whatsapp-web.js Client methods, all 34 + 31 library events and all seven install-time patches, each mapped to the interface method that uses it or marked unexposed.- An onboarding modal the "What's new" probe does not recognise is now logged once with its heading and confirm-button labels (
onboarding_dialog_unrecognized), so it can be covered viaWWEBJS_ONBOARDING_CONTINUE_LABELSbefore WhatsApp unlinks the companion. Refs #1072. npm run check:sdk-eventscompares each typed SDK's webhook event list to the contract, so an event the gateway accepts can no longer be missing from the SDKs.npm run check:sdk-docscompares the SDK design doc and the coverage tables to the shipped client surface, so a client method can no longer ship undocumented.npm run check:sdk-coveragefails when the contract publishes a route no client exposes, which is the directioncheck:sdk-routesnever ran. It reports per SDK, so a route three clients have and two do not is a failure rather than a healthy total, and it reads its exclusion list fromsdk/README.mdinstead of restating it.POST /sessions/:sessionId/calls/linkgenerates a shareable WhatsApp call link on both engines, returning the finishedhttps://call.whatsapp.com/…URL; a WhatsApp-side failure answers403rather than a success carrying an empty link.DELETE /sessions/:sessionId/profile/pictureremoves the account profile picture on both engines; removing one that is already absent is a no-op answering200, so the call is safe to repeat.docs-29-counts.spec.tsbinds every count-shaped claim indocs/29to the capability matrix, so a figure restated in the intro, the architecture prose, a mermaid node or a section heading cannot drift from the source it restates.npm run check:chartasserts what the rendered chart would do in a cluster, whichhelm lintandkubeconformcannot: config changes roll the pods, the startup budget exceeds the liveness budget, the metrics selector names one Service.- The JavaScript, Python, PHP, Go and Java SDKs expose
PUT /sessions/:id/presence, which sets the account's own global presence — not a chat's; the per-chat typing and recording states are unchanged. - The JavaScript, Python, PHP, Go and Java SDKs expose the three group membership-request routes — list, approve and reject; omitting the participant list acts on every pending request, and a partial refusal still answers
200with each outcome inresults. - The JavaScript, Python, PHP, Go and Java SDKs expose
GET /sessions/:sessionId/contacts/blockedaslistBlocked(list_blockedin Python), reading back whatblockandunblockhave done. It needs only a read-level key. - The JavaScript, Python, PHP, Go and Java SDKs expose
POST /sessions/:sessionId/calls/link, which creates a shareable WhatsApp call link.startTimeis absolute epoch milliseconds and is required, so a link for right now carries the current timestamp rather than an omitted field. - The JavaScript, Python, PHP, Go and Java SDKs expose the two channel administration routes — demote an admin back to a subscriber, and transfer ownership. Both need an
OPERATORkey, the transfer is irreversible, and the whatsapp-web.js engine answers501for either.
Changed
- ⚠️ Breaking (behavior).
POST /sessions/:sessionId/groupsanswers501on the whatsapp-web.js engine instead of attempting the creation. whatsapp-web.js still declarescreateGroup, but its page code reaches a WhatsApp Web internal that no longer exists, so every call already failed — as an opaque500rather than a stated refusal. Measured against a live session on two WhatsApp Web builds, one auto-resolved and one pinned, with the sameTypeError; bare and@c.us-qualified participant ids fail identically. Action required: create groups through the Baileys engine, which is unaffected. The route, its body and its Baileys behaviour are unchanged.
Fixed
POST /sessions/:id/chats/pinandchats/muteanswer400instead of an undeclared500on the whatsapp-web.js engine when handed a chat the session cannot resolve. Both routes are new in this release and were the only two members of their family without the resolution guard their neighbours carry, so a stale chatId reached the page and rejected there —No LID for userfor a pin, aTypeErrorfrom inside_muteUnmuteChatfor a mute. An unknown chat is reported as bad input rather than assuccess: false, which on the pin route already means the three-pin cap refused a real chat. A page-side failure on a chat that does resolve is still a500, since that is the only signal a renamed WhatsApp Web internal produces. The Baileys engine writes app state without resolving the chat first and still answerssuccess: true.docs/29's architecture diagram counted four whatsapp-web.js patches and one Baileys patch while this release shipped a fifth and a second, and🔧⁷marked no cell, so the three whatsapp-web.js participant writes read as working against a stock library. The patch counts are now derived fromscripts/bydocs-29-counts.spec.ts, which bound only the interface figures before.docs/09§9.6 also omitted the two gates added to CI and described the release workflow as a lighter gate than the one it now runs.check:sdk-coverageno longer passes when a client drops a route entirely: a wildcard the client built for a path parameter —contacts/${contactId}— was read as a one-builder family and stood in for every concrete sibling under it, so removingcontacts/blockedfrom a client left the gate green. A wildcard now counts as a family only where the contract publishes no route of that shape.- The Go SDK no longer approves or rejects every pending join request when handed an empty participant list:
omitemptydropped the empty slice, so[]string{}reached the gateway as the bodyless "act on every request" rather than the400the other four clients receive. A nil slice still means every request. - Approving or rejecting a join request by bare phone number works on the whatsapp-web.js engine instead of answering
500: the participant guard accepts a bare number and the three other participant writes qualify it, but these two routes handed it to the engine unqualified, so it reached the page as an unaddressable id. The same request already worked on Baileys. Refs #1220. - A burst of inbound media on the whatsapp-web.js engine no longer loses the media of everything past the eighth, which the Baileys repair in this release left in place on the other engine: its download queue was capped at its active slots too, making admission a constant 8 whatever the batch size. A burst now parks rather than shedding, and only the download waits — a text message never enters the limiter. The wait for a slot is bounded by
MEDIA_DOWNLOAD_TIMEOUT_MS, so a burst queued behind a hung page still emits its messages without media rather than waiting on a slot that never frees. - A webhook's
filtersis published as nullable, the sibling of thelastTriggeredAtrepair in this release: the field is stored asnullfor every webhook created without filters and the description offersnullas an input, but the schema declared neither, so a client generated from the contract rejected a value the route both sends and accepts. An invariant now fails when any property documented as carryingnulldoes not publish it. - The two channel administration routes reject a user id that does not name an individual with
400instead of handing it to WhatsApp: both bodies accepted any non-empty string, and an id the engine cannot classify was forwarded verbatim, so free text or a group id failed opaquely. A bare phone number is qualified as it is on the group participant writes, and the check sits in the service rather than the body so it holds for any caller. - The chart's optional ServiceMonitor now selects on a new
openwa.io/scrape-targetlabel, so it scrapes one target per pod instead of two; series previously collected through the headless target stop appearing, so check anything keyed on theservicelabel. - The Helm chart's new startup probe stops the kubelet killing a pod that is still booting, allowing 295s (
periodSeconds: 5,failureThreshold: 60) where the liveness settings allowed 50. It fires immediately, so a fast boot goes live sooner than before, not later — and it is not limited to auto-start, since the database connect retry alone can consume 30 of the old 50 seconds. - A Helm upgrade that changes only
envorsecretEnvnow restarts the pod through ConfigMap and Secret checksums, so the new configuration reaches the running container. WithexistingSecretset the chart renders no Secret, so that path still needskubectl rollout restart;values.yamlnotes what publishing the Secret checksum as a pod annotation means. - A participant id with a recognised domain but a nonsense user-part —
NOT A USER@c.us,@c.us— is now rejected with400instead of reaching WhatsApp Web as an undiagnosable500; the same rule backs thementionsvalidator and the membership-request routes, and a bare phone number is qualified before reaching the Baileys socket. Fixes #1220. - Messages that predate the full-text index are indexed on the next boot instead of staying permanently unsearchable, and edits and deletes on them work again; the
messages_ftsemptiness guard is now a rowid-level completeness check. - A WhatsApp-level refusal of a group participant add, remove, promote or demote on the Baileys engine is now
403instead of an unhandled error; a dropped connection or a timeout is still reported as itself. Refs #1220. - Removing, promoting or demoting group participants on the whatsapp-web.js engine now reports who WhatsApp actually acted on: a request naming only non-members answers
403rather than500or a false200, and a mixed request reports the untouched entries as404. An installation whosenode_modulespredates this release keeps the previous reporting. Refs #1220. - A group participant id that does not name an individual — free text, a group id, a number too short — is now rejected with
400naming the offending entries instead of reaching the engine as an undiagnosable500. The check sits in the service, so the MCP tools are guarded like the REST routes. Refs #1220. - Creating a channel on the Baileys engine no longer answers
500while leaving the channel behind, so a retry no longer leaks an orphan newsletter; a new install-time patch reads the create response defensively and returns the channel's id. - A profile-picture lookup that fails on the whatsapp-web.js engine now answers
503instead of the{"url": null}the route documents as "no picture"; the batch route keeps its best-effortnull. - Promoting a participant who is already an admin, or demoting one who is already not, answers
200instead of500on the whatsapp-web.js engine; the install-time patch skips a participant whose status already matches, making both operations idempotent. - The JavaScript, Python, Go and Java SDKs list
group.join_request, an event the gateway has accepted and dispatched all along, so a typed client can subscribe to it without casting past its own type. - A webhook's
lastTriggeredAtis published as a nullable date-time string instead of an object, so a client generated from the contract no longer rejects both of the values the field actually carries. The response itself is unchanged. - The group-list and status routes no longer appear twice in the OpenAPI contract under different path-parameter names, each key carrying one verb of the same URL.
GET /sessions/{id}/groupsis now{sessionId}, and the status read and delete both take{id}in place of{contactId}and{statusId}. The URLs are unchanged and no request that worked before behaves differently, but a client generated from the contract needs regenerating: the two half-endpoints collapse into one and the parameter names move with them. The status media route keeps{statusId}, since nothing collided there. - A bare
@SkipThrottle()no longer leaves a route throttled:/api/metricsand the/api/health*probes were rate-limited despite being documented as exempt. All four are also@Public()and now carry no limit — rate-limit them at your proxy if they are internet-facing. - The chat-media retention purge and orphan sweep now run while
CHAT_MEDIA_ARCHIVE_ENABLEDis off, which previously stopped both. Consequence for such a deployment: the sweep now deletes any file underchat-media/that no message row references, once unreferenced forCHAT_MEDIA_ORPHAN_GRACE_MS(default 1h). - A plugin whose code went missing is recoverable through the API again: reinstalling now writes over its surviving
ctx.storagedirectory instead of answering409, and uninstalling a known-but-unloaded id no longer answers404. - A sticker sent through the Baileys engine no longer reaches WhatsApp as non-WebP bytes labelled
image/webp: the adapter convertsimage/*to a 512×512 WebP, passes genuine WebP through unchanged, and refuses the rest with400. docs/18-sdk-design.mdand the SDK READMEs now list every method the five clients ship: the wholemediaresource plus 26 methods across nine others were missing, so server-side media conversion, presence subscription, session config and webhook delivery diagnostics read as unavailable.- The engine parity check no longer skips optional interface members.
probeLiveness?()did not match its member pattern, so the capability matrix could omit any optional method while the check reported green. .env.exampleand the troubleshooting FAQ no longer name a specific WhatsApp Web build to pin withWWEBJS_WEB_VERSION; the build they named has been withdrawn, and one the registry no longer serves is silently ignored. Both now point at the registry'shtml/folder.- A WhatsApp Web build that cannot be resolved from the wa-version registry is now reported (
web_version_resolve_failed) with the reason, the registry URL and the remedy; an unreachable registry previously continued without a pin and logged nothing. POST /sessions/:id/pairing-codeanswers409while a whatsapp-web.js session is still starting up, instead of a500carrying a rawTypeError; a code requested straight afterPOST /startlanded in the Chromium launch window. It is now accepted only fromqr_ready.- A burst of inbound media messages no longer loses the media of everything past the eighth: the Baileys download queue was capped at its active slots, making admission a constant 8 whatever the batch size. It is now unbounded.
- The JavaScript SDK reports why a production gateway rejected a request instead of ending its error message in
[object Object]; NestJS omitserrorfrom the envelope underdisableErrorMessages, the default whenNODE_ENV=productionandVALIDATION_ERROR_DETAILis unset. - Stopping or deleting a session id that does not exist no longer leaves an entry in the teardown-mark set, which nothing could clear and which grew by one for every such
404. - A plugin loaded from the legacy plugins directory can be enabled, uninstalled and updated like any other, and its config UI loads; every operation built its path as
<plugins.dir>/<id>while the package was elsewhere, so enabling left it inERRORand uninstall silently kept the code. - Auto-starting previously authenticated sessions no longer delays the HTTP listener; NestJS binds the port only after every bootstrap hook finishes, so a host with several sessions kept the port closed past the Helm chart's liveness budget and the Docker
HEALTHCHECK.
Security
- An advisory usage-statistics write no longer persists the whole API-key row: a key deleted mid-request was re-inserted and authenticated again, and a revocation or a narrowing of role, allowlist, IP allowlist or expiry was reverted. The write is now scoped to the two usage columns.
.env.exampleno longer shipsENABLE_SWAGGER=trueuncommented: the template also declaresNODE_ENV=production, so copying it pinned the opt-in that production withholds and served the schema and running version at/api/docs, which sits outside the API-key guard. Bare-metal operators who already copied it should check their own.env; Docker and Helm are unaffected.