Skip to main content
The plugin system lets you extend WaClient without forking it. A plugin can hook into the incoming stanza pipeline, expose its own coordinator at client[exposeAs], contribute typed events to client.on, and clean up on disconnect — all with full TypeScript inference, no global module augmentation. @zapo-js/voip is the reference implementation; see the VoIP guide for what a finished plugin looks like.

What a plugin is

Every plugin is a WaClientPluginDefinition value passed via the plugins option:
There are two flavors:
  • Behavior plugins run side effects (register stanza handlers, listen to events, schedule work) and don’t expose anything on the client.
  • Expose plugins also publish a value at client[exposeAs] — typically a coordinator object, like client.voip.
Both flavors can contribute typed events that surface on client.on / client.once / client.off only when the plugin is in the plugins array. The inference is value-derived: if the plugin is not installed, the event name doesn’t exist on the client type.

Authoring a plugin

Use defineWaClientPlugin for type-safe authoring. It has two overloads.

Behavior plugin

Expose plugin

The expose overload takes three type parameters: the exposeAs key as a string literal, the type of the value returned by setup, and the event-map type. Inference flows from the value back into client.on and client[exposeAs]:
Once you wire metricsPlugin() into plugins, client.metrics is typed as Metrics, and client.on('metrics_tick', ...) is type-checked against MetricsEvents.
The exposeAs parameter is constrained to K extends keyof WaClient ? never : K. Trying to expose a name that already exists on the client — 'message', 'group', 'voip' if voip is also installed — is a TypeScript error at the call site.

Plugin context

setup receives a WaClientPluginContext:
ctx.deps is an advanced API for plugin authors — new coordinators may appear in minor releases. Some nested coordinators reach key material, so do not log or persist deps wholesale.

Incoming handlers

registerIncomingHandler({ tag, prepend, handler }) lets you intercept a specific stanza tag ('message', 'iq', 'call', 'ack', 'receipt', …). Return true from handler to signal you handled the stanza (the core client won’t ack again); return false to let the rest of the pipeline run.
registerIncomingStanzaFilter runs even earlier and can drop stanzas entirely.

Typed event extension

Plugins thread their events into the host client through a phantom __pluginEvents marker carried by the return value of defineWaClientPlugin. The third generic parameter is the event map:
WaClient’s event type is derived from the plugins tuple via WaClientPluginEventsFromPlugins. The practical effect: a voip_* event exists on client.on only when voipPlugin() is in plugins.
There is no global module augmentation — installing a different WaClient in the same project doesn’t inherit foreign plugin events.

Lifecycle

Plugins install during new WaClient(...):
  1. The client is constructed and its coordinators wired.
  2. installWaClientPlugins iterates the plugins array in order. For each plugin:
    • The id is checked against previously seen ids.
    • If exposeAs is set, the name is checked against other plugins and against existing members of WaClient.
    • setup(ctx) runs. The return value (for expose plugins) is published as a non-configurable, non-writable getter on the client.
  3. On client.disconnect(), after incoming handlers drain, the registered dispose callbacks run in reverse (LIFO) order. A throwing dispose is logged and the rest still run.
  4. On the next client.connect(), plugins are reinstalled from scratch — the previous disconnect() disposed them, so connect() runs setup(ctx) again. client[exposeAs] accessors keep working across a reconnect (they resolve against the freshly-installed coordinator instance), and setup sees a clean context every time.

Error conditions

installWaClientPlugins throws synchronously during client construction in these cases:
  • Duplicate id — "duplicate wa client plugin id: <id>". Each plugin must have a unique id across the plugins array.
  • Duplicate exposeAs — "duplicate wa client plugin exposeAs: <name>". Two expose plugins cannot publish the same name.
  • Collision with a built-in — "wa client plugin exposeAs \"<name>\" collides with a reserved client member". Trying exposeAs: 'message', 'group', 'on', etc. fails at runtime (and at compile time via the keyof WaClient guard).

See also

VoIP guide

Place and receive WhatsApp calls via @zapo-js/voip, the reference plugin.

Configuration

Where plugins sits on WaClientOptions.
Last modified on July 12, 2026