Added
- Turkish (
tr) dashboard translation. Thanks @codedByCan. - ⚠️ Breaking (config).
AUDIT_RETENTION_DAYSis checked at boot, so a typo fails startup with a named error instead of silently reverting to the 90-day default. It is validated as an integer of any sign rather than a positive one, because both0and any negative value are documented switches that disable audit-log pruning entirely. Action required: a value that is not a plain integer now refuses to boot where it previously started.30dand90.5were accepted before and quietly became 30 and 90;+90became 90. Set a plain integer —90— or leave the knob unset. message.controller.spec.tsholds the two headers the stored-media download sends,X-Content-Type-Options: nosniffandContent-Disposition: attachment; deleting either had passed every suite in the repo.- That same spec now also holds the passthrough declaration and the returned bytes, so a media response carrying both security headers and no body at all no longer passes it.
- Optional
quotedMessageIdon the ninesend-*message endpoints that carry a single message — text, image, video, audio, document, sticker, location, contact and poll — and on their MCP tools, so a reply can carry media, a location, a contact card or a poll instead of only text.send-template,send-bulk,send-productandsend-catalogdo not accept it. An id the engine cannot resolve now fails the send rather than delivering the message unquoted. Thanks @nirizr for the report. - All five SDK clients expose
quotedMessageIdon their send request types, so a typed caller can reply with media, a location, a contact card or a poll without hand-building the body.
Changed
- ⚠️ Breaking (plugins). Plugins must declare a
storage:usepermission to reachctx.storage, and an installed plugin that persists state needs that one manifest line added before it can store again. Action required: upgrade the official plugins to the versions listed below BEFORE upgrading the gateway, or add the permission to any first-party plugin'smanifest.json. The four storage verbs dispatched with no permission check at all, so a plugin whose manifest declared nothing still wrote to the host disk and the operator reading that manifest could not see it. The per-plugin directory, key check and byte quota already bounded the access, so this puts a plugin's use of host storage in the manifest rather than closing an escape. - Seven official plugins declare it as of
chatwoot-adapter0.9.1,chat-flow1.1.2,group-translate1.3.1,gsheets-logger0.3.3,http-action0.2.2,typebot-connector0.2.2 andvoice-transcription1.2.3; upgrade to at least these before upgrading the gateway, since a plugin below them is denied at its next storage call rather than at load — and one that stores duringonEnableis left inERRORby that denial, not merely refused the write.after-hours,faq-botandsupabase-otp-hooknever touchctx.storageand need no upgrade.
Fixed
- A plugin denied a capability was told which permission it lacked but not where to declare it. Both refusals — the capability denial, which fires when a verb is called and so reaches the operator detached from whatever upgrade caused it, and the load-time ingress check — now name the
permissionsarray and the plugin'smanifest.json. A spec binds the quotation indocs/19to the string the code throws, so the two cannot drift apart again. - The dashboard bundled all thirteen locales into one 476 KB chunk the page preloaded, so every visitor downloaded twelve languages to read one; each is now fetched on demand.
- Fifteen return and parameter annotations in the Python SDK named
list[...]inside classes that define alistmethod, so each resolved to the method rather than the builtin. That package shipspy.typed, so the wrong types were what a consumer's own type checker read. Its CI now runs mypy, which nothing did before. - A locale chunk that failed to load left the dashboard right-to-left around English copy, because text direction followed the requested language rather than the catalogue that answered; it now follows what actually rendered.
check:sdk-routesscanned the JavaScript client for backtick-delimited paths only, while its PHP and Python rules already accepted every quote style, so the nine routes that client writes as single-quoted strings were never compared to the contract —/api/health/readyamong them, which is also the container healthcheck and Kubernetes readiness path. All nine are written by the PHP and Python clients too, and those rules did accept every quote style, so a server-side rename still reddened this gate through them andcheck:sdk-coverage— the JavaScript client simply had no cover of its own, and would have been the one shipping a stale path.- The JavaScript, Python, Go and Java SDKs omitted
contact,callandephemeralDurationfrom their chat-history message type, so a typed client had to cast to read three fields the endpoint returns; all four now mirror the engine payload. POST /api/infra/import-databound its body to an inline type, which erases at runtime, so on the replace-all restore the global ValidationPipe'swhitelistandforbidNonWhitelistednever ran: a body carrying notablesfailed as a 500 from inside the import, and a misspelled key was accepted in silence. It now takes a DTO — a missingtablesanswers 400 instead, naming the field outside production, where field-level detail is suppressed unlessVALIDATION_ERROR_DETAIL=true; an unknown key is refused, andforce/stopOrphansaccept only a real boolean or an exact'true'/'false', so an ambiguous spelling cannot open the orphan-engine escape. The export file still posts back verbatim: the five metadata fields it wrapstablesin are accepted and ignored, and a spec probes every field the export publishes so a new one cannot start failing the restore.- The engine parity gate took an adapter file's engine from its filename prefix and credited the rest to whatsapp-web.js, so a
501added to four of the five unprefixed modules would be blamed on the wrong engine — and the invariant would then demand that the engine which really refuses be markedsupported. Those files are now attributed explicitly, and one that is neither prefixed nor attributed fails the spec by name. The three modules both adapters import are marked shared rather than attributed, because no attribution is correct for them: crediting both engines demandsnot-availablefrom the one that never refuses, crediting neither demandssupportedfrom the one that does. A refusal added to a shared module is now refused by name, pointing at the engine delegate it belongs in. - Replying with an attachment in the dashboard composer silently dropped the quote: the media branch never carried the quoted id, the optimistic bubble's metadata took the same either/or, and the reply banner was cleared regardless — so it looked exactly like a quoted reply had been sent. Both payloads now carry the quote, and the two builders moved to a tested utility module.
- An unresolvable
quotedMessageIdanswered 500 on whatsapp-web.js where Baileys answered 404 for the identical request, and the bare page error also counted against the send breaker, which exempts a 4xx. Both engines now answer 404. - The Java SDK omitted
quotedMessageIdfrom its send-audio request, the one send whose model is a separate record rather than the shared media one, so send-audio was the single quotable route a Java caller could not quote on. - The spec binding
docs/19to the plugin denial message covered only the sentence that names the fix, leaving the sentence that names the fault free to be reworded with the document still asserting the old text. - The chat-media backlog test timed out on every full-suite run while passing whenever its file ran alone, reporting itself as a product failure; it now carries a timeout matching the work it does.
Documentation
.env.examplewas missing six knobs the app reads at runtime —DOCKER_HOST,BAILEYS_MESSAGE_STORE_LIMIT,STORAGE_LIST_MAX_FILES,PLUGIN_CATALOG_URL,BOOTSTRAP_KEY_FILEandVALIDATION_ERROR_DETAIL— none of which any of the three key lists the completeness gate reads happens to enumerate, so the gate could not have caught them. All six are commented out, so copying the file still pins nothing.docs/06-api-specification.mdrestated the published contract by hand with nothing binding the two, so operations shipped without ever reaching it — the integration redrive route was documented nowhere at all. It now has a section, and a new spec compares the document's route headings toopenapi.jsonin both directions; an operation deliberately documented elsewhere carries an allowlist entry naming that file, and the gate reads the file to confirm the route is really there..env.examplecalls itself the single source of truth for configuration, and the Helm chart anddocs/10both defer to it, but seven live operator knobs were missing from it — includingSERVE_DASHBOARD, which the bundled compose forwards explicitly, so an operator wanting an API-only deployment was told the capability did not exist. A new spec binds the file to the key listsenv.validation.ts,env-precedence.tsand compose already maintain, so the next one cannot go missing quietly.RESOLVE_LID_TO_PHONEwas documented nowhere outside.env.example, so an operator receiving@lidsenders had no path to the flag that resolves them; the event catalog now carries thesenderPhoneopt-in callout, the contact-phone endpoint points back to it, and the troubleshooting FAQ covers the symptom.- The chat-history response example advertised a
senderPhonefield that endpoint has never returned, and showed it on a plain@c.ussender that not even the inbound path would resolve; the example now matches what the route emits, and points at the contact-phone endpoint instead. WEBHOOK_CONTACT_DETAILSwas absent fromdocs/; the event catalog now names the twelve fields it adds tocontactonmessage.received, notes that opting in costs no extra WhatsApp lookup, and records that only the whatsapp-web.js path reads the flag — which.env.examplenow says too.- The chat-history field list omitted
ephemeralDurationand itstypeunion omittedpoll, both of which that route emits;[Unreleased]also carried two### Fixedheadings after two branches added one each without conflicting. - The webhook
secretandheaderswere described as never returned by any API response in six places acrossdocs/04,docs/06,docs/07, the response DTO and the webhook e2e suite, whileGET /api/infra/export-datadumps the rows verbatim and returns both in cleartext — whichdocs/06also said, two sections further down. Every one of them now bounds the claim to the webhook routes and names the export route as the exception, the way the neighbouring proxy-credential row already scopes its claim to the session read DTOs.