OpenWA Plugins
September 24, 2026 · View on GitHub
OpenWA Plugins
Official & community plugins for OpenWA — the open-source WhatsApp API Gateway.
Extend your WhatsApp gateway with drop-in capabilities: log conversations to a spreadsheet, auto-reply to customers, greet new leads, and more — installed in seconds, no fork required.
Install a plugin · Plugin catalog · Write your own · Contributing
Status: Active — a growing catalog. This repository is the home for installable OpenWA plugins and the conventions for building them. The catalog grows as plugins reach release.
Overview
OpenWA ships with a small, security-first plugin runtime. A plugin is a self-contained folder — a manifest.json plus a compiled entry file — that reacts to WhatsApp activity through a typed hook system and acts through a narrow, permission-gated capability API. Plugins are uploaded as a .zip, loaded in a disabled state, and only run after an administrator explicitly enables them.
This repository provides:
- A curated catalog of ready-to-install plugins, each packaged for the OpenWA dashboard.
- A reference authoring workflow — vendored OpenWA types, a one-command build-and-package script, and a worked example you can copy.
- Accurate, code-verified documentation of the plugin contract (the upstream design docs describe several features that are not yet implemented; everything here reflects the shipped runtime).
Plugin catalog
| Plugin | Description | Version | Status |
|---|---|---|---|
after-hours | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.2.9 | stable |
ai-responder | Auto-replies to inbound WhatsApp messages with an OpenAI-compatible Chat Completions API. Single-turn, rate-limited per chat and per session, and last in the responder chain. | 0.1.0 | beta |
chat-flow | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.1.11 | stable |
chatwoot-adapter | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.9.10 | stable |
faq-bot | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.13 | stable |
group-translate | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.3.10 | stable |
gsheets-logger | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.12 | stable |
http-action | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.11 | stable |
supabase-otp-hook | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.10 | beta |
typebot-connector | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.3.2 | stable |
voice-transcription | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a message.transcription event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.3.1 | beta |
The table above is generated from each plugin's manifest.json + CHANGELOG.md by npm run catalog
(and mirrored in plugins.json). See PLUGIN-STANDARD.md for the
metadata standard every plugin follows.
Per-session config support
OpenWA's sessionScoped plugins (the default) may carry per-session config overrides set via the
dashboard or PUT /api/plugins/:id/config/:sessionId — so two WhatsApp sessions under one plugin
instance can run different settings. A plugin
honors overrides only if it re-reads ctx.config inside its hook (not a cached snapshot from enable).
The table below is the status for each plugin in this repo. See each plugin's README Compatibility →
Per-session config for details and caveats.
| Plugin | Per-session config | Notes |
|---|---|---|
after-hours | ✅ Supported | All fields per session; takes effect on next message. |
ai-responder | ✅ Supported | All fields per session (different providers, models and prompts); rate limits are in memory and reset on restart. |
chat-flow | ✅ Supported | All fields per session; flow state is per (session, chat). |
chatwoot-adapter | ⚠️ Supported, with caveat | All fields per session, the first-class multi-tenant shape; the failed-relay retry queue drains only once that session dispatches an event again. |
faq-bot | ✅ Supported | All fields per session (different rule sets per number). |
group-translate | ⚠️ Supported, with caveat | Config-signature caching; multi-backend isolation needs one instance per session. |
gsheets-logger | ❌ Not supported | Single-buffer single-sink design; use one instance per session. |
http-action | ✅ Supported | All fields per session (different endpoints/action sets). |
supabase-otp-hook | ✅ Supported | All fields per session; applies to the instance's bound session. |
typebot-connector | ✅ Supported | All fields per session; flow state is per (session, chat). |
voice-transcription | ⚠️ Supported, with caveat | Config-signature caching; multi-backend isolation needs one instance per session. |
On the roadmap: an automatic closing-greeting plugin for new leads. Want something else? Open an issue or contribute one.
Installing a plugin
Plugins are managed by an ADMIN API key, either through the OpenWA dashboard (Plugins section) or directly over the REST API. Authenticate with either the X-API-Key header or Authorization: Bearer <key>.
Replace
https://your-openwa-hostwith your gateway's base URL and<ADMIN_API_KEY>with an admin-scoped key.
1. Build the plugin package (see Building from source) to get gsheets-logger.zip, then upload it:
curl -X POST "https://your-openwa-host/api/plugins/install" \
-H "X-API-Key: <ADMIN_API_KEY>" \
-F "file=@gsheets-logger.zip"
Installing from a URL instead of an upload works the same way (POST /api/plugins/install-url with {"url": ...}). On a production host (OpenWA ≥ 0.20.0, where NODE_ENV=production), a URL install additionally requires a #sha256=<64 hex> integrity pin on the URL: catalog entries already carry it, and for a hand-pasted release URL the digest is the SHA-256 printed in that plugin's GitHub Release notes (PLUGIN_INSTALL_REQUIRE_PIN=false opts out).
2. Configure it (secrets are masked on read and preserved on write):
curl -X PUT "https://your-openwa-host/api/plugins/gsheets-logger/config" \
-H "X-API-Key: <ADMIN_API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "config": { "spreadsheetId": "1AbC...defG", "serviceAccountJson": "{...}", "sheetTab": "Logs" } }'
3. Enable it — a freshly installed plugin is disabled and never enables itself. Once you have enabled it, that decision is remembered: OpenWA ≥ 0.10.5 re-enables it automatically on every host restart. An ERROR status does not exclude it — the host replays your enable decision, not the last runtime status. What is not automatic is a worker that crashed mid-run: nothing respawns it until the next restart or an explicit re-enable.
curl -X POST "https://your-openwa-host/api/plugins/gsheets-logger/enable" \
-H "X-API-Key: <ADMIN_API_KEY>"
Management endpoints
All routes require an ADMIN role. Every one of them also rejects a session-scoped API key outright,
except PUT /api/plugins/:id/config/:sessionId, which a scoped key may call.
| Method & path | Purpose |
|---|---|
GET /api/plugins | List installed plugins and their status |
GET /api/plugins/catalog | The remote catalog, annotated with what is already installed |
GET /api/plugins/:id | Inspect one plugin (config secrets redacted) |
POST /api/plugins/install | Upload and install a .zip (multipart field file) |
POST /api/plugins/install-url | Install by downloading a .zip from a URL (SSRF-guarded, #sha256= pinned) |
POST /api/plugins/:id/enable | Run the plugin (onLoad → onEnable) |
POST /api/plugins/:id/disable | Stop the plugin and unregister its hooks |
POST /api/plugins/:id/update | Replace an installed plugin in place from a URL, keeping its config and enabled state |
PUT /api/plugins/:id/config | Update the base config ({ "config": { ... } }); fires onConfigChange if enabled |
PUT /api/plugins/:id/config/:sessionId | Set one session's config override, shallow-merged over the base; an empty object clears it |
PUT /api/plugins/:id/sessions | Replace the whole set of sessions a session-scoped plugin is active for |
GET /api/plugins/:id/config-ui | The plugin's sandboxed-iframe config editor, when it ships one |
DELETE /api/plugins/:id | Uninstall and remove files (built-ins are protected) |
GET /api/plugins/:id/health | Plugin-reported health check |
The gateway mounts every route under a global /api prefix, so the paths above are the complete ones — dropping it answers 404.
Plugin contract
Everything below is verified against the OpenWA runtime, not the aspirational design docs.
A plugin is a directory containing:
my-plugin/
├─ manifest.json # metadata, declared hooks, permissions, config schema
└─ dist/index.js # compiled entry; default-exports a class implementing IPlugin
Manifest
{
"id": "my-plugin", // /^[a-z0-9][a-z0-9._-]*$/i, unique, not reserved
"name": "My Plugin",
"version": "1.0.0", // semver
"type": "extension", // only "extension" is user-installable
"main": "dist/index.js", // require()-able file inside the package
"description": "…",
"permissions": ["messages:send"], // enforced gates: messages:send · engine:read · net:fetch · webhook:ingress · conversation:send · storage:use · search:provide
"sessions": ["*"], // session-id scope; omit ⇒ all sessions
"hooks": ["message:received"], // declared interest (informational)
"configSchema": { // drives the dashboard settings form
"type": "object",
"properties": {
"apiKey": { "type": "string", "secret": true, "required": true }
}
}
}
Required fields: id, name, version, type, main. Reserved ids (cannot be shadowed): whatsapp-web.js, baileys, auto-reply, translation.
Entry class — the IPlugin lifecycle
The entry file must export default a class with a no-argument constructor. Every lifecycle method is optional; the loader instantiates the class and calls onLoad then onEnable when an admin enables the plugin.
import type { IPlugin, PluginContext } from "../types/openwa";
export default class MyPlugin implements IPlugin {
async onEnable(ctx: PluginContext): Promise<void> {
ctx.registerHook("message:received", async (hook) => {
ctx.logger.log(`Inbound message on session ${hook.sessionId}`);
return { continue: true }; // never blocks normal processing
});
}
async onDisable(_ctx: PluginContext): Promise<void> {
// release timers/connections; hooks are auto-unregistered for you
}
async healthCheck() {
return { healthy: true };
}
}
| Method | When it runs |
|---|---|
onLoad(ctx) | At enable, before onEnable — one-time setup |
onEnable(ctx) | When enabled — register hooks and start work here |
onDisable(ctx) | When disabled — tear down; hooks auto-unregister |
onUnload(ctx) | Before removal from memory |
onConfigChange(ctx, newConfig) | When config is updated (only while enabled) |
healthCheck() | On GET /api/plugins/:id/health |
Hooks
React to activity with ctx.registerHook(event, handler, priority?). Handlers are async (ctx: HookContext<T>) => Promise<HookResult<T>>, run in ascending priority order (lower runs first; default 100), and receive { event, data, sessionId?, timestamp, source }. Return { continue: true } to pass through, { continue: false } to stop the chain, or { continue: true, data } to transform the payload for downstream handlers.
| Group | Events |
|---|---|
| Session | session:created · session:starting · session:ready · session:qr · session:disconnected · session:error · session:deleted |
| Message | message:received · message:sending · message:sent · message:failed · message:ack · message:persisted · message:deleted |
| Webhook | webhook:before · webhook:queued · webhook:delivered · webhook:after · webhook:error |
| Ingress | ingress:error |
Capabilities
The PluginContext exposes a deliberately small surface. Every permission in the last column except webhook:ingress is checked at the call: invoking a capability you didn't declare throws PluginCapabilityError mid-run, not at install. webhook:ingress is the exception, in both directions. Calling ctx.registerWebhook without it never throws and never logs: the host drops the subscription and the route simply never fires. But declaring ingress in the manifest without it is a hard load failure for the whole plugin, not just that route: install answers HTTP 400, and at boot the directory is skipped and the plugin comes up ERROR.
| Capability | Methods | Permission |
|---|---|---|
ctx.messages | sendText(session, chat, text) · reply(session, chat, quotedId, text) | messages:send |
ctx.engine (read-only) | getGroupInfo · getContacts · getContactById · checkNumberExists · getChats · getChatHistory (0.8.6+) · canonicalChatId (0.8.7+) | engine:read |
ctx.net | fetch(url, init) — host-proxied, SSRF-guarded outbound HTTP (0.7+) | net:fetch + manifest net.allow |
ctx.conversations | send(envelope) — normalized outbound (text/image/file/audio/video/voice/location) (v1) | conversation:send |
ctx.handover | set(…, state) — bot/human/closed handover for a mapped chat (v1) | conversation:send |
ctx.mappings | upsert · get · getByProvider — WA-chat ↔ provider-conversation mapping (v1) | conversation:send |
ctx.registerWebhook | claim an inbound ingress route (v1) | webhook:ingress |
ctx.storage | get · set · delete · list (namespaced per plugin) | storage:use |
ctx.logger | log · debug · warn · error | — |
Also available: ctx.config, ctx.pluginId, ctx.registerHook.
ctx.storagewas ungated until OpenWA moved it behindstorage:use. A plugin that persists anything should declare it now rather than when its host is upgraded: an older host ignores an unrecognized permission, so the declaration is free, and a newer one denies the call — mid-conversation, because permissions are checked at the call and not at load.There is no
ctx.manifestand noctx.hookManageron the sandboxed context the worker builds, so readingctx.manifest.versiontypechecks against a stale copy of the vendored types and throws at runtime. To know your own version, bake it in at build time (seepackage.mjs, which defines__PLUGIN_VERSION__from the manifest).
Constraints to design around
- Ship JavaScript, not TypeScript. The loader
require()smaindirectly with no transpile step. Author in TS and bundle to a singledist/index.js. typemust beextension. Engine, storage, queue, and auth plugins are first-party built-ins and cannot be installed at runtime.- Package limits:
.zip≤ 5 MB compressed, ≤ 200 files, ≤ 20 MB uncompressed. Bundle your dependencies — there is nonpm installat install time. - No published SDK package. Vendor the OpenWA types (see
types/openwa.d.ts); they are the de-facto contract. - Sandboxed worker. Since OpenWA v0.6.0 each plugin runs in its own worker thread; capabilities and outbound HTTP are host-gated (
ctx.net.fetchis SSRF-guarded; every capability is checked against the declaredpermissions). The worker still has Node APIs within its thread, so install only plugins you trust. - Compatibility is unmanaged. There is no host-version negotiation yet. Pin the OpenWA version you tested against in your plugin's README.
Authoring a plugin
- Scaffold a new folder at the repo root (copy
gsheets-loggeras a starting point). - Write
manifest.jsonand anindex.tswhose default export implementsIPlugin. - Keep host-free logic separate from host adapters (see the example's split between pure mapping and the I/O client) so it stays unit-testable without OpenWA.
- Validate config defensively inside
onLoad/onEnable— the host does not enforce yourconfigSchematypes, only redactssecretfields. - Build and package, then install the resulting
.zipinto a local OpenWA instance to verify.
Building from source
# Bundle a plugin's TypeScript into a single dist/index.js and produce <plugin>.zip
node package.mjs gsheets-logger
The build bundles the entry to a single CommonJS file (exposing .default) with esbuild — a dev-only dependency — and zips it together with manifest.json. The shipped bundle uses only Node built-ins, so the package stays well within the install limits.
Repository structure
OpenWA-plugins/
├─ README.md
├─ LICENSE
├─ types/
│ └─ openwa.d.ts # vendored OpenWA plugin interfaces (the contract)
├─ package.mjs # `node package.mjs <plugin>` → bundle + zip
└─ <plugin>/ # one folder per plugin
├─ manifest.json
├─ index.ts # default-exports an IPlugin class
├─ … # supporting modules + tests
└─ dist/index.js # built artifact (manifest.main)
Compatibility
Each plugin declares its minOpenWAVersion (ranging from 0.6.x to 0.8.x in this repo — see the plugin's README Details). OpenWA does not yet hard-enforce host-version compatibility, so always test a plugin against your specific OpenWA version before enabling it in production.
Contributing
Contributions are welcome — new plugins, fixes, and documentation.
- Fork and branch from
main. - Add your plugin in its own folder following the authoring guide, including a per-plugin
README.mdwith setup steps and a config reference. - Include at least one runnable test for non-trivial logic.
- Ensure the plugin builds and packages cleanly with
node package.mjs <plugin>. - Open a pull request describing what the plugin does and the OpenWA version you tested against.
Please keep plugins focused, dependency-light, and least-privilege — declare only the permissions and session scope you actually need.
Security
Plugins run in a sandboxed worker thread (since OpenWA v0.6.0): capabilities and outbound HTTP are host-gated (ctx.net.fetch is SSRF-guarded; every capability is checked against the declared permissions). The worker still has Node APIs within its thread, so treat every plugin as trusted code:
- Only install plugins from sources you trust.
- Review the manifest's
permissionsandsessionsbefore enabling. - Store secrets via the dashboard's
secret-flagged config fields, never in source.
Found a security issue in a plugin here? Please report it privately via the OpenWA security policy rather than opening a public issue.
License
MIT © Yudhi Armyndharis & OpenWA Contributors.