Added
- Official Go SDK (
sdk/go). Hand-written, stdlib-only (no third-party dependencies) Go client
covering the user-facing API surface, joining the JavaScript/Python/PHP/Java clients. Entry point is
openwa.New(baseURL, apiKey, opts...), which returns a concurrency-safe*Clientwhose exported
fields group the API by domain (Sessions,Messages,Contacts,Groups,Webhooks,Chats,
Status,Labels,Channels,Catalog,Templates,Health,Search,Auth). Every network
method is context-first; configuration and dependency injection go through functional options
(WithHTTPClient,WithTransport,WithLogger,WithRetry,WithMiddleware,WithTimeout,
WithUserAgent,WithHeader,WithInsecureHTTP). Errors are typed: match the sentinels with
errors.Is(ErrBadRequest,ErrUnauthorized,ErrForbidden,ErrNotFound,ErrConflict,
ErrRateLimited,ErrNotImplemented) or unwrap the concrete*APIErrorwitherrors.As. Retries
are opt-in (WithRetry), honourRetry-After, and rewind request bodies viaGetBody. Because the
API has no idempotency key, aPOSTis never replayed after a network error, and on a retryable
status only for429/503— which prove the gateway declined the request before acting on it —
since a500/502/504can arrive after the message was already sent. Redirects
are never followed, so the bearer-equivalentX-API-Keyis never re-sent to a redirect target. A
TestRoutingtable asserts the exact method and path of every service call, and the suite runs in CI
(gofmt/go vet/go test -race) on both SDK and server-contract changes, so route drift fails at
test time. Requires Go 1.22+. Thanks @Revelts.
Changed
-
v0.8.18's whatsapp-web.js id-rename fix also restored the Chats page (docs only). The v0.8.18 entry
credits that fix with repairing inbound media downloads, message ids, acks, reply quoting, and reactions,
but never mentionsGET /sessions/{id}/chats— which the same patch repaired as well. The rename broke the
injected read of each chat's last-received message key, and because every chat resolves through a single
Promise.all, one unreadable key rejected the entire request, so the dashboard's Chats page returned
500 Internal server erroron every load while the rest of the dashboard kept working from stored data.
Operators on v0.8.17 hitting that symptom found nothing in the release notes matching it and so had no
reason to upgrade. No behavior change, and no change to which release carries the fix — it is still
v0.8.18. The chat-list site was first reported here by @SkywardLab in #748.
Refs #748, #753, #757. -
Typed SDK response models now match the status, label, and channel payloads the server actually
returns. The status, label, and channel routes hand back the engine-neutral shape from
whatsapp-engine.interface.tsverbatim — no DTO, no remap — but the record types the typed SDKs
declare for them were hand-written and never bound to that contract, so they advertised fields the
server never sends while omitting most of the ones it does.StatusRecordgains
contact/caption/expiresAt(plus the declared-but-not-yet-populated
mediaUrl/backgroundColor/font) and drops the never-sentstatusId/body;LabelRecord
replacescolor/colorHexwith the realhexColor;ChannelRecordgains
inviteCode/picture/verified/createdAtand drops the never-sentpictureUrl/role; and
channel messages get a dedicatedChannelMessageRecord(id/body/timestamp/hasMedia/mediaUrl)
instead of being typed as the persistedMessageRecord, which that endpoint never returns — it reads
WhatsApp live. This mirrors howChatHistoryMessagealready models the livemessages.history()
payload. Status timestamps are typed as the ISO 8601 strings they serialize to rather than
Date/Object. JavaScript, Python, and Java needed all four corrections; Go needed the two channel
ones (its status and label models were already right). The PHP SDK is array-based and unaffected.
⚠️ Breaking (typed SDK consumers): every field named here was one the server has never sent (always
undefined/null), so the code reading it was already broken at runtime; it now fails to compile.
label.color/label.colorHex→label.hexColor;channel.pictureUrl→channel.picture;
status.statusId→status.id;status.body→status.caption.channel.rolehas no successor —
the server has no such field, so drop the read. Conversely the
real fields (status.contact,channel.inviteCode,message.hasMedia, …) were previously compile
errors and now resolve. Nothing caught this before: these controllers declare no@ApiResponsetype,
soopenapi.jsoncarries no response schema for them andopenapi:checkhad nothing to diff, while
the SDK suites mocked the transport and asserted URLs only. A type-level wire contract
(sdk/javascript/test/wire-contract.test-d.ts, gated bytscin the JavaScript SDK'snpm test) plus
per-model decode guards in the Java and Go suites now pin the models to the engine shapes. Refs #754. -
Swagger now agrees with the engine capability matrix on status and catalog (docs only). Eight
operations were describing something other than what they do, and the drift ran in both directions.
The three status-post routes were labelled "(Baileys only)" — stale since #714 wired them on
whatsapp-web.js, and contradicted by the matrix, which has listed both engines assupportedever
since; the label told whatsapp-web.js users a working endpoint was unavailable to them. Their201
also promised the status was "posted to the specified recipients", which is true only on Baileys:
whatsapp-web.js ignores therecipientsallow-list and broadcasts to the account's status-privacy
audience, as the adapter already warns at runtime. Dropping the stale label without that caveat would
have replaced a wrong label with a worse silence, so the responses and therecipientsfield now say
which engine honors it. In the other direction, the three catalog reads documented a plain200as
though they returned data, and the two catalog sends documented a201they can never return —
the matrix marks all fivenot-availableon both engines. whatsapp-web.js stubs the reads (null/an
empty page with a warn log) and Baileys raises501; the sends are501on both. Every real response
is now documented and the summaries name the gap. The matrix's own header claimed five whatsapp-web.js
entries werenot-availablewhile two of the five it named saidsupportedtwo lines below; it is
three, and the stale adapter line references in the catalog evidence now cite the symbols instead, so they cannot drift again. No
behavior change;openapi.jsonregenerated. -
The send-response Swagger text now matches the prose it was corrected alongside (docs only). The
messageIddescription still asserted that a message to a number not on WhatsApp "never delivers" —
the same unevidenced claim about WhatsApp's behavior that was retracted fromdocs/06in the same
change that edited this string. It now says what the prose says: the outcome reaches you
asynchronously, if at all.openapi.jsonregenerated. -
The Message Tester's status code renders in monospace. It was emitted as
<span class="mono">,
but every monospace rule in the dashboard hangs off a compound selector (.detail-value.mono), which
a bare span never matches — so the class contributed only the RTL direction isolation it was chosen
for, and nothing else. A plain<code>earns both from the existing global rules with no new CSS. -
Corrected the send-response documentation (docs only). The guidance added in #739 overstated what a
stalled send tells you: it said a message resting atsentfor a recipient you have never reached is
"almost certainly a number that is not on WhatsApp." That inference does not hold in the other
direction — a registered recipient whose device has not come online since the send stays atsent
indefinitely too, by design, so the state is not diagnostic on its own. The unevidenced claim that an
unregistered recipient is "the most common cause" of a send that never arrives is gone, as is the
description of what the message looks like in a WhatsApp client, which is not ours to assert. The
section also claimed every send route returns201with{ messageId, timestamp };POST send-bulk
returns202with a batch envelope, and thestatus/send-*routes return astatusIdand an ISO
timestamp rather than amessageIdand epoch seconds. Both exceptions are now stated where the rule
is. Finally, the documentedstatuslifecycle omitted its terminal error state: WhatsApp reporting an
error for a message advances it tofailedand dispatches amessage.failedwebhook, on both engines
— that signal existed all along and is now written down. No behavior change. Refs #738.
Fixed
-
A deleted message is cleared again on a WhatsApp Web build that renamed the id field. The rename
sweep reached the send, ack, status and inbound paths but notmessage_revoke_everyone, which read
both ids unguarded.revokedIdneeded the fallback even on a patched install: whatsapp-web.js
overwrites the normalized id with a raw spread of the revocation'sprotocolMessageKey, and that key
is normalized by neither the structure constructor nor the injected serializer — so it is the one
place a fully patched build still hands back a raw key. Without it the id arrived undefined, the
update fell back to the notification's own id, matched no row, and the deleted message's text stayed
in the database and on the dashboard while WhatsApp showed it as deleted. The status listing
(collectStatuses) and channel-message reads had the same gap: a status whose id was lost could not
be revoked bydeleteStatus, and a channel message reported the literal string"undefined"as its
id rather than the empty sentinel. The channel-message type declared the id in a shape that made the
renamed field unreadable without a cast, the same defect corrected on the inbound path. -
Documentation corrected where it contradicted the code.
docs/06told whatsapp-web.js operators —
the default engine — that posting a status returns501, which has been untrue since #714 wired it;
the shipped OpenAPI schema and the adapter both say otherwise. In the other direction it promised that
therecipientsallow-list restricts who sees a status, which holds on Baileys but not on
whatsapp-web.js, where the list is ignored and the status reaches the account's whole status-privacy
audience with no error — the one drift here that could surprise someone about who read their status.
The catalog routes documented success bodies no engine can return (both sends always501; the reads
are stubs that returnnull/empty unconditionally).docs/03marked Phone Link unavailable on
whatsapp-web.js though the adapter has implemented it since #552.docs/18andsdk/README.mdstill
described three or four SDKs, listed three of five in their tables, omitted Maven Central entirely, and
pinned a gateway version last true at 0.7.3. The capability-matrix summary counts were stale
(recomputed from the matrix itself: 123 supported, 19 not-available across 14 methods), and its catalog
evidence now cites symbols rather than line numbers, which had drifted twice in two releases. -
PLUGINS_ENABLEDremoved from.env.exampleand the compose file. It was documented, plumbed into
the container, and read by nothing — an operator setting it tofalsestill got the full plugin
surface. The flag is gone rather than wired: the plugins module is@Global()and eight non-plugin
files inject its providers, so a genuine opt-out is a change of its own, not a release-eve edit. All
plugin routes remain ADMIN-only. -
Inbound messages keep their id on a WhatsApp Web build that renamed the field. The id-rename
sweep (#762/#765/#773) taught the send, ack and status paths to read$1when_serializedis
absent, but missed the busiest path of all:buildIncomingMessageBase, which runs on every message
that arrives (onMessage) and every message the account sends from a linked phone
(onMessage_create). It readmsg.id._serializedunguarded — and its parameter type declared the
id as{ _serialized: string }, so the renamed field was not merely unread but unreachable
without a cast, and theid: stringit produced wasundefinedat runtime. On an affected build
without the build-time backport applied, every inbound message reached the webhook, the WebSocket
and the database with no id: nothing to dedup on, nothing to quote in a reply, nothing for an ack
to match. The id now falls back to$1, and an id readable by neither name reports the same empty
sentinel the send path uses — normalized to NULL at the persist chokepoint, mirroring
saveOutgoingMessage, because the non-partial(sessionId, waMessageId)unique index exempts NULL
but would collide the second''. -
Logs CSV export truncated at 200 rows. The export loop requested 500-row pages and treated any
short page as the last one, butGET /auditclampslimittoMAX_AUDIT_PAGE_SIZE(200) — so the
first page always looked short and every export stopped at 200 rows regardless of how much history
matched. Pagination now terminates on the server-reportedtotal(and on empty pages) instead of on
a guessed page size, so the export can no longer be truncated by a server-side clamp. Thanks
@kabir74705 for the report and the original fix. -
Dashboard overstated connected sessions and showed a fabricated trend. The "Active Sessions" KPI
readstats.active, which counts running engine instances — includinginitializing,qr_ready,
andconnecting— so it reported sessions that could not yet send or receive as active. The green
"+N" trend arrow beneath it was not a delta at all: it rendered the current READY count as though it
were a period-over-period gain, so a steady deployment appeared to be permanently growing. The card
now reports the READY count (relabelled "Connected Sessions") with a plain{running} running · {total} totalbreakdown, and the fake trend indicator is gone. Thanks @kabir74705 for spotting both. -
The whatsapp-web.js backport can no longer latch in a half-patched dependency. The patcher proves a
tree is whole before standing down, and #759 added that check precisely so a run that died mid-apply
could not be mistaken for an upstream fix. The proof was incomplete in the one place it mattered most.
REQUIRED_SITESasserted the eight structure constructors but notsrc/util/Injected/Utils.js— the
browser-side normalizer every inbound message crosses on its way to Node, and the last of the
twelve filespatchwrites, one after theMessage.jsthe stand-down check keys on. A run that died in
that window left a tree where every assertion passed, so each later run stood down as healthy while the
primary normalizer was permanently absent — the exact latch the check exists to prevent.Client.jsand
GroupChat.jsnormalize ids too and were likewise unasserted; all three are now covered. The Docker
image build runs the patcher directly, so it now fails the build on such a tree instead of shipping it.
Separately, the half-patched error was the only one of the four not marked as leaving a partial tree, so
--best-effortdowngraded it to a warning; it now exits non-zero like the other three. Note this makes
the tree reported on thenpm installpath rather than rejected there — thepostinstallhook
discards the patcher's exit code, so the install still succeeds; that is pre-existing and deliberately
untouched, since failingnpm installoutright is the trade the flag exists to avoid. Both fixes are
regression-tested, including the--best-effortpath. -
A status post no longer claims success it cannot prove, and no longer throws away a readable id.
#762 established thatwhatsapp-web.jscan resolveundefinedinstead of throwing, and that reporting
that as success is unrecoverable — so a send with no message back now fails loudly. Status posts were
left on the old behavior: they returned 201 with an emptystatusIdand anew Date()invented on
the spot, for a status that may never have been published. Their case is in fact simpler than a send's —
the engine returns the status model before reaching the lookup that makes a send's empty result
ambiguous, so no message back means nothing was posted, full stop — and it now surfaces as a500
carrying that reason rather than a fabricated success. Separately, the id was read only as_serialized,
so on a build that renamed the field to$1(#747) a status that posted perfectly well came back with
statusId: ""— and sincedeleteStatustakes that id as its revoke handle, the status could never be
taken down. The rename fallback the send path has since #762 is now applied here too. The same fallback
is added to the ack listener, where an unreadable id previously stranded a message atsentforever —
including thefailedack that is the only signal a send was rejected. Baileys is unaffected: its send
and status paths already agree with each other. -
The Message Tester no longer invents HTTP status codes. Its result banner rendered one of two
hardcoded strings —200 OK - Successor400 - Failed— for every outcome, in all eleven locales.
Neither number was ever read from the response. Send routes return 201, not 200, so the success
banner was wrong on every successful send; and any failure displayed400, including a server 500
and the recipient pre-check that short-circuits in the browser without issuing a request at all. The
banner now states the outcome and, when a request actually reached the gateway, the real status the
gateway returned — whichservices/api.tsalready attaches to the error for exactly this purpose.
Where no request was made, no code is shown rather than a fabricated one. This is what made a plain
500get reported as a mystery400in #750. Fixes #750. -
A production boot that serves the dashboard over plain HTTP now warns about the CSP upgrade that
blanks it. In production OpenWA emitsupgrade-insecure-requests, which is correct behind a
TLS-terminating reverse proxy — the shippeddocker-compose.ymltopology — but silently breaks a
direct-HTTP deployment: the browser upgrades the dashboard's own script fetches tohttps://, the
non-TLS server cannot answer them, no JavaScript runs, and the UI renders a blank white screen. The
failure happens entirely in the browser, so the server log stayed clean and the operator had nothing
to go on; the existingCSP_UPGRADE_INSECURE_REQUESTS=falseopt-out was documented only under
.env.example's "Developer settings" heading, where a production operator had no reason to look.
Boot now names the setting when the trap is possible,.env.exampledocuments it under Security with
the symptom spelled out, anddocs/12-troubleshooting-faq.mdgains a "Dashboard renders a blank white
screen" entry. The warning cannot distinguish direct HTTP from a TLS proxy at boot (Express
trust proxyis off), so it fires for both and tells a proxied operator to ignore it. The CSP default
is unchanged. (#731) -
The startup banner now advertises
BASE_URLinstead of a hardcodedlocalhost. The
🚀 running on,📚 Swagger docs, and🖥️ Dashboardlines printedhttp://localhost:${PORT}as a
literal, regardless of where the instance was actually reachable — contradicting theAuthService
banner directly above them, which already honouredBASE_URL. Two adjacent log lines could therefore
report different URLs for the same server, which read as "the UI is pinned to localhost" and sent #731
chasingBASE_URL/BIND_HOST/API_PORTrather than the actual cause. (#731) -
Saving Infrastructure no longer persists a guessed engine when the running engine is unknown. The
engine radio falls back to itswhatsapp-web.jsdefault until/infra/engines/currentresolves; if that
request failed, saving wrote that default asENGINE_TYPE, silently switching a Baileys deployment on
the next restart. The save payload now omitsengine.typeunless the radio actually seeded from the
running engine or the operator picked one — the backend leaves a savedENGINE_TYPEuntouched when the
field is absent, so an unrelated save can no longer flip the engine. -
The dashboard now clears a message deleted for everyone while the thread is open (whatsapp-web.js).
message.revokedcarriesrevokedId— the id of the original deleted message, which whatsapp-web.js
resolves separately from the event's ownid— but the dashboard's WebSocket projection dropped the
field and matched its message cache onidalone. Persistence was always correct (the backend keys
itsUPDATEonrevokedId), and webhook/API consumers already received the field; only the live
dashboard view was affected, wherestaleTime: Infinitymeant an already-open thread kept rendering
the deleted message's original text until a reload, reconnect, or cache eviction. The projection now
forwardsrevokedId, and the cache lookup matches on either candidate id (against both the row id
andwaMessageId), which keeps the Baileys path — where the two ids are identical — unchanged. When
whatsapp-web.js cannot resolve the original (it is no longer in its local store)revokedIdis
absent and the lookup falls back toid, as before. Refs #755. -
A failed group creation now reports why it failed.
whatsapp-web.jssignals a failed
createGroupby resolving with a plain string ('CreateGroupError: …') instead of throwing, and
its typings say so (Promise<CreateGroupResult | string>) — but the adapter cast that union away and
read.gidoff the string, so the reason upstream gave us was replaced by an opaque
TypeError. The union is handled, and an unreadable group id now fails loudly rather than being
coerced throughString()into the literal id"undefined". The status is unchanged (the group
genuinely wasn't created, so it was always a 500); the error text is now the real one. -
An ack whose message id can't be read is dropped instead of silently advancing nothing. On a
WhatsApp Web build that renames the id field (#747), the id reached the statusUPDATEas
undefined, which TypeORM sends aswaMessageId = NULL— matching no row, sincex = NULLis never
true. The ack advanced nothing, burned its one-shot retry, and left only a misleading "no status row
advanced" line behind. It is now dropped at the adapter boundary, where the reason is still visible. -
A send whose message can't be read back no longer crashes, and never claims a delivery it can't
prove.whatsapp-web.js'sClient.sendMessage()can resolve withundefinedinstead of
throwing, while its typings declarePromise<Message>— so the adapter'smsg.id._serializedreads
surfaced as an opaqueTypeError: Cannot read properties of undefined (reading 'id')and a 500, at
seven send sites. All of them now route through one helper that distinguishes the two cases the
dependency collapses into that singleundefined: no message at all is reported as a failed send
(it is genuinely ambiguous whether anything was dispatched, and a retryable false negative beats
claiming a delivery that never happened), whereas a message whose id is merely unreadable reports
the empty no-id sentinelforwardMessagealready used — never a synthesised id. An empty id is now
normalized to NULL insidesaveOutgoingMessage, so it can't collide on the non-partial
(sessionId, waMessageId)unique index and silently drop a bulk row. Refs #757.