Skip to main content
Version: v0.23.1

Install and enable a plugin

Plugins extend a running OpenWA gateway in-process: they react to WhatsApp activity through typed lifecycle hooks and act through a narrow, permission-gated capability API — no fork, no core changes. This page shows you how to install a plugin, configure it, and enable it on a live gateway, then points you to the rest of the section.

A plugin is a self-contained folder — a manifest.json plus a compiled entry file — packaged as a .zip. It is installed disabled and runs only after an administrator explicitly enables it. Management is admin-only, over the REST API or the dashboard's Plugins page.

Prerequisites

How plugins fit together

A plugin never touches the gateway's internals. It registers handlers for hook events and calls back into the host through capability namespaces, each gated by a permission it declared in its manifest.

The permissions a plugin can declare:

PermissionGrantsUsed for
messages:sendctx.messages.sendText / ctx.messages.replyAuto-replies, notifications
engine:readRead-only engine queries (group info, contacts, chats, number check, chat history, canonical-id resolution)Enriching events
net:fetchSSRF-guarded outbound HTTP, scoped to the manifest's net.allow host list (plus net.allowConfigHosts for operator-opened hosts)Calling external APIs
webhook:ingressctx.registerWebhook — claim an inbound routeIntegration Fabric (receive external webhooks)
conversation:sendctx.conversations.send, ctx.handover, ctx.mappingsIntegration Fabric (normalized send, handover, identity map)
search:providectx.registerSearchProvider — serve the gateway's GET /api/search queriesSearch plugins (required since v0.12.2)
storage:usectx.storage.get / set / delete / list — the per-plugin key-value storePersisting state across restarts (required since v0.17.0)

The first three are the base outbound surface every plugin can use. The next two are the Integration Fabric — for bidirectional external integrations (helpdesk/CRM/chatbot handover) running as sandboxed plugins without a separate server. See Integration Fabric. The last two gate the search-provider capability and the per-plugin key-value store, so a search plugin and a plugin that persists state must each declare theirs explicitly.

A plugin that calls a capability it did not declare fails with a PluginCapabilityError. Capability calls are also confined to the sessions an operator activated the plugin for, not just the manifest's static scope. For the full hook list, capability surface, and lifecycle, see Plugin architecture.

Upgrading to v0.17.0 — upgrade plugins before the gateway

storage:use became required in v0.17.0: a plugin reaches ctx.storage only if that string is in its manifest permissions array. Seven official plugins now declare it — chatwoot-adapter 0.9.1, chat-flow 1.1.2, group-translate 1.3.1, gsheets-logger 0.3.3, http-action 0.2.2, typebot-connector 0.2.2, and voice-transcription 1.2.3. Upgrade to at least these versions before upgrading the gateway; the new versions run on an older gateway, which ignores the unrecognised permission string, while the old versions break on v0.17.0. after-hours, faq-bot, and supabase-otp-hook never touch ctx.storage and need no upgrade.

A plugin below its floor is not refused at load. It is denied at its next storage call, so an upgraded gateway looks correct until the plugin tries to store — and a plugin that stores during onEnable lands in ERROR and stays disabled rather than merely losing the write. The fix is one manifest line, "storage:use", then a reload. Version floors per plugin are in the plugin catalog.

Plugins run with full Node privileges

Disk-loaded plugins run in a worker_thread (V8-context isolation), but that is not an OS-level sandbox — a plugin can reach Node built-ins like fs and process. Install only plugins you trust, and review a plugin's declared permissions and sessions before enabling it.

The install flow

Installing a plugin from a .zip is a three-step sequence — install, configure, enable — because OpenWA never auto-runs freshly uploaded code.

All routes sit under the /api prefix and require an ADMIN key in the X-API-Key header. The examples below use the faq-bot plugin, which auto-replies from configurable keyword rules.

1. Install the package

Upload the .zip as multipart form field file:

curl -X POST "http://localhost:2785/api/plugins/install" \
-H "X-API-Key: YOUR_API_KEY" \
-F "file=@faq-bot.zip"

The plugin is now installed but disabled — it is not running yet:

{
"id": "faq-bot",
"name": "FAQ / Auto-Reply Bot",
"version": "0.2.1",
"type": "extension",
"status": "installed",
"builtIn": false
}
Install straight from the catalog

If your gateway is configured with the official catalog, you can skip the build-and-upload step and install by URL instead: POST /api/plugins/install-url with body { "url": "<release-zip-url>" }. The download is SSRF-guarded and restricted to an allowlisted host. An https:// URL installs as-is; since v0.19.0 a plain http:// URL is also accepted, but only when it pins its expected bytes with a #sha256=<64-hex> fragment — verified fail-closed against the downloaded bytes before anything is installed — and is rejected before any fetch otherwise. Since v0.20.0 an https:// URL needs the pin too under NODE_ENV=production (the compose default): https authenticates the channel, not the bytes as reviewed, so a production catalog install without a pin fragment now fails until you pin the URL or set PLUGIN_INSTALL_REQUIRE_PIN=false. The fragment is never sent to the server, so a catalog download link can carry it. A malformed marker or a digest mismatch fails the install closed. See the catalog.

2. Configure it

Each plugin declares a configSchema in its manifest. Send the settings as a config object. For faq-bot, that means the reply rules and an optional fallback:

curl -X PUT "http://localhost:2785/api/plugins/faq-bot/config" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"config": {
"rules": "[{\"mode\":\"contains\",\"pattern\":\"hours\",\"reply\":\"We are open 9-5, Mon-Fri.\"}]",
"fallbackReply": "Thanks! Someone will reply shortly.",
"respondInGroups": false
}
}'
{ "id": "faq-bot", "status": "installed", "config": { "rules": "[...]", "respondInGroups": false } }

Config fields marked secret: true (an API key, a service-account JSON) are masked on read and preserved on an unchanged write — so you never have to re-paste a secret to edit an unrelated field. A list of secret values (for example, a rotating set of API keys) also survives an append or removal: surviving entries keep their stored secret, while a genuinely new or edited row is never grafted with a stored value.

The host does not validate your config types

OpenWA renders configSchema into the dashboard form and redacts secret fields, but it does not enforce the schema's types on write. A well-built plugin validates its own config defensively when it starts.

3. Enable it

Enabling runs the plugin's onLoad then onEnable lifecycle and registers its hooks:

curl -X POST "http://localhost:2785/api/plugins/faq-bot/enable" \
-H "X-API-Key: YOUR_API_KEY"
{ "id": "faq-bot", "status": "enabled", "enabledAt": "2026-06-26T10:15:00.000Z" }

The plugin is now live. Send a WhatsApp message containing "hours" to a connected session and it auto-replies. Confirm the running state any time:

curl "http://localhost:2785/api/plugins/faq-bot" \
-H "X-API-Key: YOUR_API_KEY"
Enabling is always explicit

A plugin never auto-enables — not after install, and not after a gateway restart. A previously enabled plugin comes back as installed on restart and must be re-enabled. This keeps untrusted code from running silently.

Enabling with unset required fields

On OpenWA ≥ 0.10.0 the dashboard intercepts an enable attempt when required config fields are unset and opens the plugin's config dialog with a warning, instead of the plugin landing in ERROR on a raw sandbox failure. Fields that declare a schema default are already seeded into the stored config at load, so only required fields without a default trigger the intercept.

Two runtime guarantees apply behind the scenes:

  • Lifecycle ops are serialized per plugin id. Two concurrent enable calls for the same plugin can't double-run onEnable or register hooks twice; a racing caller is rejected.
  • Integration instance bindings self-heal on restart. A boot-time reconciler re-derives every enabled Integration Fabric instance's session binding from the persisted instance rows, so a binding lost while a plugin was momentarily unloaded is restored on the next boot without an operator re-PATCH. See Integration Fabric.

Scope a plugin to specific sessions

By default a plugin that declares sessions: ["*"] acts on every session. To restrict a session-scoped plugin to a subset, set its active sessions:

curl -X PUT "http://localhost:2785/api/plugins/faq-bot/sessions" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessions": ["sales", "support"] }'

Use ["*"] for all sessions or [] for none. The session scope a plugin may act on is fixed by its manifest — activation can narrow it but never widen it past what the manifest's sessions allows.

To give one session different settings, set a per-session config override with PUT /api/plugins/{id}/config/{sessionId} (send an empty config to clear it). The override is shallow-merged over the base config for that session's events. Override support is per-plugin — a plugin honors it only if it re-reads its config per event — and each official plugin's status is listed in the plugin catalog.

Management endpoints

Every route below sits under /api and requires the ADMIN role.

Method & pathPurpose
GET /api/pluginsList installed plugins and their status
GET /api/plugins/catalogList the remote catalog, annotated with install state
GET /api/plugins/{id}Inspect one plugin (config secrets redacted)
POST /api/plugins/installInstall from an uploaded .zip (multipart field file)
POST /api/plugins/install-urlInstall by downloading a .zip from an allowlisted https:// URL, or an http:// URL carrying a #sha256= integrity pin (in production every URL install needs the pin, v0.20.0+)
POST /api/plugins/{id}/updateUpdate in place from a URL (keeps config + enabled state)
POST /api/plugins/{id}/enableEnable (runs onLoadonEnable)
POST /api/plugins/{id}/disableDisable and unregister its hooks
PUT /api/plugins/{id}/configUpdate base config (body { "config": { … } })
PUT /api/plugins/{id}/config/{sessionId}Set or clear a per-session config override
PUT /api/plugins/{id}/sessionsSet which sessions a scoped plugin is active for
GET /api/plugins/{id}/healthRun the plugin's healthCheck
DELETE /api/plugins/{id}Uninstall and delete files (built-ins are protected)

A plugin's status is one of installed, enabled, disabled, or error. A plugin whose load fails at boot is reconciled to error rather than left reporting installed or enabled — its config is preserved, and a later successful load restores its status. The full request and response shapes are in the API reference.

In the dashboard's Plugins page, an installed plugin whose catalog entry lists a strictly newer version carries an update chip on its card, and the Install button shows a pending-update count (v0.19.0+) — the chip opens the Install drawer's catalog tab pre-filtered to that plugin, where the update flow lives.

Disable or uninstall

Disable a plugin to stop it without losing its config:

curl -X POST "http://localhost:2785/api/plugins/faq-bot/disable" \
-H "X-API-Key: YOUR_API_KEY"

Uninstall to remove it and its files entirely:

curl -X DELETE "http://localhost:2785/api/plugins/faq-bot" \
-H "X-API-Key: YOUR_API_KEY"

Troubleshooting

SymptomCauseFix
401 UnauthorizedKey missing or not ADMIN-scopedUse an ADMIN-role key in X-API-Key. See Authentication.
404 Not Found on a plugin routePlugin id not installedConfirm the id with GET /api/plugins; ids are case-sensitive.
Plugin shows installed after a restartPlugins never auto-enableRe-enable it with POST /api/plugins/{id}/enable.
Plugin is enabled but does nothingEmpty/invalid config, or wrong session scopeRe-check the config object and the plugin's active sessions.
error status after enablePlugin threw during onLoad/onEnableInspect the error field from GET /api/plugins/{id} and the gateway logs.
Capability call fails with PluginCapabilityErrorPlugin used a permission it did not declare, or an out-of-scope sessionThis is the plugin author's fix — see Building a plugin.

Next steps