v0.14.5
Added
- Auto-reject calls can be turned on from the dashboard — the session detail panel now carries a toggle wired to the config endpoint, so the setting no longer requires an API call to reach, and it states that
call.receivedstill fires and that no restart is needed. - Session config can now be changed without re-linking the account —
PATCH /api/sessions/{id}/configsetsautoRejectCalls,maxReconnectAttemptsandreconnectBaseDelayon a session that is already running, where all three were previously fixed at creation and turning one on meant creating a new session and scanning the QR again.
Fixed
-
The tests that lock the channel-refusal contract now use a shape the engine can actually produce — they built the refusal with the numeric code an IQ error carries, which that code path never emits, so they exercised the wrong branch of the classifier and stayed green through a release in which channel refusals regressed from
403to an opaque500. -
Four response schemas published values the API cannot emit — search results documented
directionasinbound/outboundwhile every hit carriesincoming/outgoing, so a client validating against the contract would have rejected all of them; audit entries documentedwarningwhere the code writeswarn; the search example named a provider id that does not exist; and the data export omittedskippedTablesandomittedInlineMedia— the two fields that say the archive is truncated. Group detail also advertised aparticipantsCountand anisAdminthat only the group LIST returns. -
The published image now actually carries the app-state resync fix — the patcher that bounds the loop was wired into the install hook but never added to the two hand-written lists in the Dockerfile, so it was absent from the production stage, the hook's existence check skipped it without a word, and every image shipped unpatched. It is copied and run fatally now, and a derived check fails the build if a patcher is ever added to one list and not the other.
-
A channel refusal answers
403again, not a bare500— narrowing the refusal classifier in the same release fixed the IQ error channel and silently de-classified the other one: WhatsApp'sw:mexsurface reports a refusal as a GraphQL error inside a successful IQ, so it never carries the numeric code the classifier now reads, and deleting or muting a channel the account does not own regressed from403to an opaque500. The two error channels are decoded separately now; a dead socket and an unanswered query still do not read as refusals. -
A profile picture lookup that WhatsApp never answered no longer reads as "this contact has no picture" — the route answered
200withurl: nullafter a silent minute, which is the same answer a contact without a picture gives, so a caller had no way to tell them apart; it now answers503. Passing the engine's own timeout would not have been enough on its own, because the resulting error landed in the catch that produces that samenull. The batch lookup is unchanged: a per-id failure there is documented asnulland stays that way. -
The last fifteen writes that reported success without confirmation now report it — the addressbook saves, block and unblock, the archive/unread/clear/delete chat actions, delete-for-me and star, the four label writes and a call rejection all go through an engine call that discards its own result, so each answered
200whether WhatsApp applied the change or never replied; a rejected call was the sharpest case, since the gateway also published arejectedoutcome for a rejection nothing had acknowledged. All now share the request budget and answer503when no confirmation arrives. -
A dead connection is no longer reported as a bad invite code or a permissions refusal — joining by invite and updating participants both fail without ever entering their
catch, because the engine resolves an unanswered query rather than rejecting it: the join landed on the same "invite code may be invalid"400as a genuinely bad code, and a participant update on the same403as a missing admin right. Both now share the request budget and answer503; an answered-but-empty result still means what it meant. -
Marking a chat read no longer answers a bare
500when WhatsApp stays silent — the read receipt reaches a library helper that destructures the query result, so an unanswered one threw a rawTypeErrorthat nothing could classify; it now shares the same 30-second budget and answers503. The media send path fails the same way and is deliberately left alone: a send is not repeatable, and503is a status the SDKs retry, so making it one would trade an opaque failure for duplicate messages. -
Channel operations no longer answer a bare
500when WhatsApp stays silent — every one goes through a query helper that reports an absent answer as a malformed one, which the refusal classifier cannot place, so the raw error escaped with its message discarded; the channel lookup, invite lookup, subscribe, unsubscribe, delete and mute/unmute now share the same 30-second budget and answer503. Creating a channel is deliberately left unbounded for the same reason as creating a group — it is non-idempotent, and503is a status the Go SDK retries forPOST. -
The three group-metadata reads no longer answer a bare
500when WhatsApp stays silent —extractGroupMetadatathrows a Boom whosedatais normalised to null, so the refusal classifier cannot place it and the raw error escaped with its message discarded;GET /groups/{id}andGET /groups/join-infonow spend the same 30-second budget as the rest and answer503, and creating a group maps a genuine WhatsApp refusal to403instead of the same bare500. Group creation is deliberately left unbounded: it is the one non-idempotent call, and503is a status the Go SDK retries forPOST, which could leave duplicate groups behind. -
A group list WhatsApp never answered is no longer served as "you are in no groups" —
groupFetchAllParticipatingfills its result only behind anif (groupsChild), so an unanswered query returns the same empty object an account with no groups produces, andGET /sessions/{id}/groupsanswered200with an empty array after a silent minute; there is nothing in the value to tell the two apart, so the read now shares the same 30-second budget as the group writes and answers503instead. -
⚠️ Breaking (behavior). A dead socket is no longer reported as a permissions problem — the helper that decides whether a Baileys failure was a server refusal or a transport death guarded ondata !== undefined, but Boom's constructor defaultsdatatonull, so the guard matched every Boom ever thrown and aConnection Closed(428) was classified as a 4xx refusal: the group and channel writes that go through that helper answered403 admin rights or permissions may be missing, joining by invite answered400 invalid invite code, and reading invite info answered404, all for a socket that was simply down. Transport failures now propagate as a 5xx instead — a change of status code on the gateway surface for those cases. The three profile writes were never affected: they do not go through that helper. The tests that were meant to cover this passed because their fixture was a bareErrorrather than a realBoom, and now use one.
Migration. If you branch on the status code from the Baileys group/channel writes,POST /groups/join, orGET /groups/join-info, a403/400/404there no longer covers the case where the connection was simply down — that now arrives as a 5xx. Treat 5xx as retryable transport failure and keep your existing 4xx handling for genuine refusals; a caller that retried on403to work around this should stop, and one that surfaced400 invalid invite codeto end users will now correctly report an outage instead. -
Twelve group and profile writes no longer report success for a change WhatsApp never confirmed —
groupLeave, the subject/description/settings/picture/member-add-mode/disappearing-timer writes and the three profile writes all resolve through a Baileys call that awaits its query and then discards the result, andquery()swallows its own timeout and resolves rather than throwing, so leaving a group you are still in and locking a group that stayed open both answered200 done; each now has a deadline of its own and answers503when no confirmation arrives, which does mean a genuinely slow write that lands just after the deadline is reported as unconfirmed. -
A post-connect app-state resync can no longer spin for the life of the session — Baileys'
resyncAppStateloops until every collection reports no more patches, but every exit from that loop, including the attempt counter its own comment introduces to "ensure we don't enter a loop that cannot be exited from", sits inside afor…inover the decoded patches; an unanswered query decodes to an empty object, so nothing was removed, nothing was counted, and the walk re-asked every sixty seconds until the socket closed. A postinstall patch ends the walk when the decode is empty, and fails the install rather than silently skipping if the upstream shape moves. -
A number check no longer answers "not on WhatsApp" when WhatsApp simply did not answer — Baileys'
onWhatsApphas no else branch after its result check, so an unanswered lookup resolvedundefined, which the adapter folded into the samenulla genuine miss produces;GET /contacts/check/{number}then reportedexists: falseandcheckNumberExistsreturned false — a confident verdict about a number the gateway had heard nothing about, on the one route the send documentation tells callers to trust before sending. An empty result is still a real answer and still reportsfalse; only the absent one now raises503. -
A group invite code now says why it could not be read — fetching one is admin-only, but the groups list returns every group the account belongs to whatever its role, so the request lands on ids the caller was just handed: Baileys let WhatsApp's refusal escape as a bare
500(13 of 16 groups on the test account) while whatsapp-web.js stringified its missing result into{"inviteCode":"undefined"}and the linkhttps://chat.whatsapp.com/undefinedbehind a200(53 of 101). Both engines now answer403, the same status the group writes have answered since they were fixed, and an unanswered query answers503instead of the codeless linkhttps://chat.whatsapp.com/. -
A catalog read no longer stalls for a minute and then reports an empty catalog — Baileys answers an unanswered query with
undefinedrather than an error and its catalog parsers are null-safe, so a request WhatsApp never replied to was indistinguishable from a business with no products:GET /catalogand/catalog/productsreturned200with nothing in them after a silent 60 seconds, andsend-productreported404 Product not foundfor a product it had simply never heard about. The walk now spends one 30-second budget across every page and answers503, and a server that repeats a page cursor instead of advancing no longer spins the walk until the process exhausts its heap. -
A benign whatsapp-web.js rejection no longer reads like a crash — the same un-awaited
framenavigatedre-injection that already logs atWARNwhen the page goes away was logged atERRORin its other shape, where the navigation lands before WhatsApp Web has defined its module registry; it appears at the first boot after the pinned WhatsApp Web build moves under a warm profile, and the session reaches ready unaided. -
A slow whatsapp-web.js attach is no longer mistaken for a dead one and reloaded out from under itself — the event-bridge self-heal could fire on the first reconcile tick, two seconds after
authenticated, but whatsapp-web.js clearseventsAttachedin its constructor and sets it only onceattachEventListenersresolves, withLoadUtils, a poll of up to thirty seconds forwindow.WWebJS,ClientInfoandInterfaceControllerin between, so a false flag is the ordinary reading for most of a minute on a loaded host. The reload then navigated the page out from under the injection still running on it, whose re-entry died atgetWWebVersionbefore re-exposing any of the bridge, and the one-shot guard blocked a second attempt, so the session rode out the readiness deadline and was marked failed — credentials kept, but no way back without a restart. The reload now waits out the upstream attach budget first, which still leaves it half the deadline to reinject when one is genuinely warranted. -
A data export now reports the media it had to leave behind — an over-budget attachment is replaced with the same omitted marker that media skipped on the way in gets, so a truncated backup was indistinguishable from a complete one both on inspection and on restore; the response now carries
omittedInlineMediaalongsideskippedTables, and the dashboard warns after a download that dropped anything. -
A node that has observed the loss of its session lease no longer writes
FAILEDfor that session — engine callbacks were fenced only by a local liveness check, so between a heartbeat detecting the loss and the teardown it schedules, a dying generation could park a row a peer already owned inFAILED, which is excluded from both the boot reset and the takeover sweep by design and so left the session outside every automatic recovery path on every node until an operator restarted it by hand. The fence engages from the heartbeat tick that detects the loss, not from the moment the lease lapses, so a window remains. -
Thirteen settings that did nothing under Docker now take effect — Compose forwards environment explicitly and has no
env_file, soBAILEYS_MARK_ONLINE_ON_CONNECT,BAILEYS_SYNC_FULL_HISTORY,WEBHOOK_CONTACT_DETAILS,ALLOW_UNSIGNED_INGRESS,STORE_EPHEMERAL_MESSAGES,RESOLVE_LID_TO_PHONE,SIMULATE_TYPING,MCP_ENABLED,SEARCH_ENABLED,SERVE_DASHBOARD,CACHE_ENABLED,DATABASE_LOGGINGandMAIN_DATABASE_SYNCHRONIZEnever reached the container however the operator's.envwas written — the MCP server could not be enabled at all,SEARCH_ENABLED=falsedid not disable the search route it documents, andBAILEYS_MARK_ONLINE_ON_CONNECTleft the paired phone's notifications suppressed for as long as the gateway stayed connected.
Changed
- Twenty-eight routes now document the
503they gained in this release, and three document the501they always answer on one engine — every route that reaches an engine call with a request budget can time out, but only ten said so, so a client generated from the contract had no branch for it on the rest and would have reported a gateway fault as an unexpected error; the channel list and channel-message reads refuse on Baileys and subscribe-by-invite refuses on whatsapp-web.js, none of which was stated. The batch avatar lookup's description also said three concurrent engine lookups where the code runs five. - Every remaining module now publishes the shape of what it returns — health, profile, statistics, media, settings, audit, calls, metrics and search were the last nine with no response schema anywhere, so three quarters of the API had nothing for the contract gate to diff;
/api/metricsis now typed as the Prometheus text it serves rather than implying JSON, andPUT /api/settingskeeps no success schema because it always answers 501 by design. - The labels, channels and status endpoints now publish the shape of what they return — all twenty-four carried a status and a sentence but no schema, so the committed OpenAPI snapshot had nothing to diff for three whole modules; the status media route is now typed as the binary stream it is rather than implying JSON, and a status timestamp is documented as the ISO-8601 string it serialises to rather than the
Datethe engine hands over. - The catalog endpoints now publish the shape of what they return, including one that disagrees with the rest of the API —
POST /messages/send-productanswers{id, timestamp}while every send served by the message module answers{messageId, timestamp}for the same value, because this route passes the engine result through unmapped; the schema records the field the code actually sends rather than the one the neighbouring routes use.send-catalogis left with no success schema on purpose: no engine can send a catalog link, so a 2xx there would document a response that cannot occur. - The twelve infrastructure endpoints now publish the shape of what they return, and one of them no longer describes the wrong thing —
GET /infra/storage/exportwas documented as a "Tar.gz archive stream" but answers JSON pointing at an archive it wrote underdata/exports/, so a client following the contract would have waited for a binary body that never comes; the save and restart payloads also now state that a failed config write still answers200withsaved: false, which is the flag a caller has to read. - The ten contacts endpoints now publish the shape of what they return — they documented a status and a sentence but no schema, so the committed OpenAPI snapshot had nothing to diff for any of them and a change to a response body could reach clients without the contract gate noticing;
GET /contacts/{contactId}/phonereturningnullfor an unresolvable id, and the batch picture lookup answeringnullper id rather than failing, are now stated rather than discovered by calling. - The eighteen group endpoints now publish the shape of what they return — the whole module documented statuses and prose but not one response schema, so the committed OpenAPI snapshot had nothing to diff for any of it; the per-participant
resultsarray that an add/remove/promote/demote returns, and the fact that a partial refusal is reported inside a200rather than as an error, are now in the contract instead of only in a sentence. - Fourteen more boolean environment variables are now validated at boot — they are read with a bare
=== 'true'/!== 'false'comparison, so a spelling likeDATABASE_SSL=requiresilently configured the opposite of what was asked for;DATABASE_SSLin particular read as OFF and sent credentials in plaintext to a server the operator believed was TLS-protected. Onlytrue/false(or blank) are accepted now, so a deployment using another spelling will fail startup until it is corrected.MCP_READONLYandPUPPETEER_HEADLESSstay tolerant deliberately — both fail toward the safe state. - The API description now documents the two statuses middleware returns before routing —
415for a compressed request body and503withRetry-Afterwhen too much body data is already in flight, neither of which appeared anywhere in the OpenAPI document despite applying to every operation. - The Helm chart now states the reason
replicaCountmust stay 1 that actually applies today — the warning described two pods corrupting shared session auth, which a session lease and per-pod volumes already prevent, so an operator acting on it would mitigate the wrong thing.
Documentation
- The webhook troubleshooting runbook told operators there was no webhook-delivery log API and to grep container logs instead; that was true when written but
GET /api/webhooks/delivery-failuresshipped four days later, so the one page reached when a webhook is silent denied the endpoint exists. It now carries the real call, names the fields that gate dispatch (active,events,filters), and notes thatlastTriggeredAtis never set by the Test button. - The n8n trigger event table advertised
call.accepted/call.rejected/call.missedwith no engine caveat, so a whatsapp-web.js user could build a missed-call workflow on events that engine never emits; they are now marked Baileys only, and the troubleshooting section names n8n's test-versus-production webhook URL, which silently delivers a single event and then stops.