Added
- Prometheus counter for terminally-failed webhook deliveries.
/api/metricsnow exposesopenwa_webhook_delivery_failures_total, incremented once per delivery that exhausts all its retries (mirroring the durablewebhook_delivery_failuresdead-letter record, on both the queued and the queue-disabled direct path). It is an in-process monotonic counter — cheap, real-time, and resetting only on restart, which Prometheusrate()/increase()treat as a normal counter reset — so aCOUNT(*)over the retention-pruned failure table isn't queried per scrape. Operators can now alert on webhook failure rate from the scrape instead of tailing the structured log or querying the dead-letter endpoint.
Changed
- Runtime feature flags are centralized in the config layer.
AUTO_START_SESSIONS,STORE_EPHEMERAL_MESSAGES,RESOLVE_LID_TO_PHONE,SIMULATE_TYPING, andSIMULATE_TYPING_MAX_MSnow resolve through a single discoverablefeatures.*namespace onConfigService(backed by onecomputeFeatureFlags()source of truth) instead of ad-hocprocess.envreads scattered across the session and message services. Runtime behavior and defaults are unchanged. The four boolean flags are now validated at boot alongside the existingQUEUE_ENABLED/MCP_ENABLED/SERVE_DASHBOARDchecks — ⚠️ behavior change: a deployment booting one of them with a non-canonical value (e.g.SIMULATE_TYPING=1,AUTO_START_SESSIONS=yes) will now fail fast naming the offending key until corrected totrue/false/unset, instead of silently falling back to the default. - Coverage floors added for the session, webhook, and hook-manager modules. Per-directory Jest
coverageThresholdentries now guardsrc/modules/session,src/modules/webhook, andsrc/core/hooks(set just below measured, matching the ratcheted floors already in place for the security, auth, engine-adapter, and integration modules), so a large deletion of these modules' tests fails CI instead of passing under the softer global gate.
Fixed
- Inbound integration (ingress) deliveries now retry with backoff instead of failing on the first error. A queued ingress job was enqueued with no retry policy, so BullMQ ran a single attempt and a transient plugin-handler error (e.g. a 5xx from the sandbox) went straight to the dead-letter table — asymmetric with the webhook queue, which already retries. Ingress jobs now use bounded exponential-backoff retries (default 3 attempts, 5s base delay; configurable via
INGRESS_MAX_ATTEMPTSandINGRESS_RETRY_DELAY_MS), and the dead-letter write still fires exactly once, only after the retries are exhausted. ⚠️ Ordering caveat: the per-conversation ordering lock guarantees no two same-conversation dispatches run concurrently, but a retried delivery re-enters the lock after its backoff and can therefore overtake a same-conversation successor that dispatched during the backoff window. Ingress order is best-effort regardless (the provider delivers over unordered HTTP); order-strict plugins must not assume a retried event still arrives in sequence. This trades strict order for throughput — BullMQ releases the worker slot during backoff, where retrying inside the lock would hold it. /infra/statusnow actively probes the databases (SELECT 1) instead of trustingisInitialized. The Infrastructure panel's database tile read onlyDataSource.isInitialized, which staystrueafter a PostgreSQL backend dies (until an explicit.destroy()), so the tile showed the database healthy while it was actually down. The endpoint now runs a short, timeout-boundedSELECT 1on both connections — the same probe/health/readyuses — so a post-init outage is reflected. SQLite is effectively always up, so this only changes behavior for a genuinely-down external PostgreSQL.- The settings panel reports the real docs and base-URL configuration.
GET /settingshardcodedenableDocs: true(ignoringENABLE_SWAGGER, so it claimed the API docs were enabled in production where they are disabled by default) and anhttp://localhost:<port>apiBaseUrl(ignoring the operator's configuredBASE_URL). Both now reflect the real values, andautoReconnectreports the engine's actual default (on) rather than a hardcodedfalsefor a config key that never existed. - A reaction no longer clobbers a message's delivery status.
applyReactionread the full message row, mutated itsreactionsmetadata, then wrote the whole row back — so a delivery ack (SENT→DELIVERED/READ) that committed in the window between that read and write was overwritten with the stale status, permanently regressing the message's state until another ack happened to arrive. The reaction now writes only themetadatacolumn via a scopedUPDATEkeyed on(sessionId, waMessageId), so it can never touchstatus(acks and reactions update disjoint columns). The existing per-message serialization for concurrent reactions is unchanged. PUT /infra/configreturns the real HTTP status for a rejected configuration. A validation failure (an unknown engine type, or a value carrying a newline that would inject an extra env var) was caught and returned as HTTP 200 with{ saved: false }, so a client branching on the status code alone treated a rejected save as success. Such validation errors now surface as their real 4xx (BadRequestException); a genuine persistence fault (e.g. a disk/permission error while writing the env file) still returns{ saved: false }with 200, preserving the dashboard'sbody.savedhandling.- Deleting a session no longer orphans its webhooks, templates, or stored Baileys messages on SQLite.
webhooks,templates, andbaileys_stored_messagesdeclare anON DELETE CASCADEforeign key tosessions, but the defaultdataengine (SQLite) runs withforeign_keysOFF, so that cascade never fired — a session delete removed only the session,messages, andmessage_batchesrows and left the rest behind indefinitely (orphanedwebhooksrows in particular retain their signing secret and any custom headers).delete()now removes all CASCADE-FK child rows explicitly inside the same transaction (children before the parent), which is engine-agnostic — redundant-but-harmless on PostgreSQL, where the real cascade already handles it — and mirrors the ordering the data-restore path already uses. - The
message:sendingmoderation gate andmessage:failednotification now cover every outbound path. Both hooks were wired only into the textsendTextmethod, so a plugin registered onmessage:sending(the canonical pre-send moderation/compliance gate) saw no image/video/audio/document/sticker/location/contact/poll/reply/forward send and no bulk send at all, and amessage:failedplugin saw only text-send failures. Every single sender now passes through a shared pre-send gate (a plugin can block or rewrite the payload) and a shared failure emitter, andBulkMessageServiceruns the same per-message gate (a block fails just that message, honoringstopOnError) and emitsmessage:failedon a failed batch item. ⚠️ Behavior change: amessage:sendingplugin now receives — and can block/modify — media, extended, and bulk sends it previously never saw; the hook payload carries atypediscriminator (image/video/poll/reply/… ) so a handler can scope its logic per send type, and itserrorfield is sanitized so an SSRF-blocked media fetch does not expose the resolved internal address to plugins. A moderation block on a bulk item fails just that item (honoringstopOnError) without emittingmessage:failed, matching single-send where a block is a client error, not a delivery failure.message:sentis unchanged (still emitted once from the enginemessage_createpath). - Sibling webhooks subscribed to the same event now get distinct idempotency keys. The per-event idempotency key was derived only from the event and its payload, so two different webhook endpoints registered for the same event on the same session received an identical
X-OpenWA-Idempotency-Key. A receiver sitting behind both (or a shared dedup store) could drop the second endpoint's delivery as a replay of the first. The key is now salted with the destination webhook's id, so each endpoint is dedup'd independently while retries of the same delivery (including the queue-add→direct fallback) keep their stable key. - A session with auto-reconnect turned off no longer reports "reconnection failed after 0 attempts". When a disconnect fired with the reconnect budget set to
0(auto-reconnect disabled), the session was markedFAILEDwith the exhausted-retries message and a count of0— implying a retry loop had run and failed rather than a feature that is simply off. That case now records an explicit "Auto-reconnect is disabled" reason; the genuine exhausted-retries message (with its real attempt count) is unchanged. - A failed inbound integration (ingress) delivery is no longer silently dropped on the inline path. With the queue disabled (or Redis unreachable), an ingress delivery is dispatched inline; if the plugin handler threw, the error was swallowed and the event was stranded — the provider had already received its
202, and the redrive tooling only scans the dead-letter table, which never got a row. The inline path now persists a dead-letter record (the same shape the queued path writes on its final failed attempt), so the event is redrivable. The write is best-effort: a failure to persist it is logged but never turns the202into a5xx(the delivery is already dedup-persisted, so the provider won't re-send). - Plugin instance session bindings are re-derived on startup, so a binding lost while the plugin was momentarily unloaded self-heals. Provisioning a plugin instance mirrors its config into the plugin runtime (per-session config + activation) so an ingress handler resolves the right
ctx.config; if the plugin happened to be unloaded at that moment the bridge was skipped (only an INFO audit), leaving the instance marked enabled but resolving base config only — previously recoverable only by re-saving the instance. A boot-time reconciliation now re-applies every enabled instance's binding from the persistedplugin_instancesrows (honoring each row's realenabledflag), so a restart restores it. The binding logic moved into a dedicated service shared by provisioning and the reconciler. - Deleting a session now purges its on-disk engine auth directory.
DELETE /sessions/:idtore down the running engine and removed the database rows but left the engine's persistent auth store behind (data/baileys/<name>for the Baileys engine,data/sessions/session-<name>for whatsapp-web.js) — these are keyed by session name and live independently of any engine instance, so recreating a session under the same name reloaded stale credentials. The delete now removes that directory too (best-effort, keyed by name, guarded against path traversal, and correctly a no-op when the session was already stopped and had no live engine). Thanks @m7fz7.