# OpenWA Plugins
**Official & community plugins for [OpenWA](https://github.com/rmyndharis/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.
[](LICENSE)
[](https://github.com/rmyndharis/OpenWA)
[](#plugin-contract)
[](#contributing)
[](https://buymeacoffee.com/rmyndharis)
[Install a plugin](#installing-a-plugin) · [Plugin catalog](#plugin-catalog) · [Write your own](#authoring-a-plugin) · [Contributing](#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`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.2.15 | stable |
| [`ai-responder`](./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.6 | beta |
| [`chat-flow`](./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.18 | stable |
| [`chatwoot-adapter`](./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.17 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.20 | stable |
| [`group-translate`](./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.17 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.18 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.18 | stable |
| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.16 | beta |
| [`typebot-connector`](./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.9 | stable |
| [`voice-transcription`](./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.7 | beta |
The table above is generated from each plugin's `manifest.json` + `CHANGELOG.md` by `npm run catalog`
(and mirrored in [`plugins.json`](./plugins.json)). See [PLUGIN-STANDARD.md](./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`](./after-hours) | ✅ Supported | All fields per session; takes effect on next message. |
| [`ai-responder`](./ai-responder) | ✅ Supported | All fields per session (different providers, models and prompts); rate limits are in memory and reset on restart. |
| [`chat-flow`](./chat-flow) | ✅ Supported | All fields per session; flow state is per `(session, chat)`. |
| [`chatwoot-adapter`](./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`](./faq-bot) | ✅ Supported | All fields per session (different rule sets per number). |
| [`group-translate`](./group-translate) | ⚠️ Supported, with caveat | Config-signature caching; multi-backend isolation needs one instance per session. |
| [`gsheets-logger`](./gsheets-logger) | ❌ Not supported | Single-buffer single-sink design; use one instance per session. |
| [`http-action`](./http-action) | ✅ Supported | All fields per session (different endpoints/action sets). |
| [`supabase-otp-hook`](./supabase-otp-hook) | ✅ Supported | All fields per session; applies to the instance's bound session. |
| [`typebot-connector`](./typebot-connector) | ✅ Supported | All fields per session; flow state is per `(session, chat)`. |
| [`voice-transcription`](./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](https://github.com/rmyndharis/OpenWA-plugins/issues) or [contribute one](#contributing).
## 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 `.
> Replace `https://your-openwa-host` with your gateway's base URL and `` with an admin-scoped key.
**1. Build the plugin package** (see [Building from source](#building-from-source)) to get `gsheets-logger.zip`, then upload it:
```bash
curl -X POST "https://your-openwa-host/api/plugins/install" \
-H "X-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; from OpenWA 0.26.0 sending `null` for one removes it):
```bash
curl -X PUT "https://your-openwa-host/api/plugins/gsheets-logger/config" \
-H "X-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.
```bash
curl -X POST "https://your-openwa-host/api/plugins/gsheets-logger/enable" \
-H "X-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
```jsonc
{
"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.
```ts
import type { IPlugin, PluginContext } from "../types/openwa";
export default class MyPlugin implements IPlugin {
async onEnable(ctx: PluginContext): Promise {
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 {
// 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) => Promise>`, 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.storage` was ungated until OpenWA moved it behind `storage: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.manifest` and no `ctx.hookManager` on the sandboxed context the worker builds, so
> reading `ctx.manifest.version` typechecks against a stale copy of the vendored types and throws at
> runtime. To know your own version, bake it in at build time (see `package.mjs`, which defines
> `__PLUGIN_VERSION__` from the manifest).
### Constraints to design around
- **Ship JavaScript, not TypeScript.** The loader `require()`s `main` directly with no transpile step. Author in TS and bundle to a single `dist/index.js`.
- **`type` must be `extension`.** 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 no `npm install` at install time.
- **No published SDK package.** Vendor the OpenWA types (see [`types/openwa.d.ts`](./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.fetch` is SSRF-guarded; every capability is checked against the declared `permissions`). The worker still has Node APIs within its thread, so install only plugins you trust.
- **Version floors are enforced since OpenWA v0.24.0.** The host rejects a malformed `minOpenWAVersion` or one newer than itself at install and boot load. Record the host version and test scope in the plugin's README.
## Authoring a plugin
1. **Scaffold** a new folder at the repo root (copy [`gsheets-logger`](./gsheets-logger) as a starting point).
2. **Write `manifest.json`** and an `index.ts` whose default export implements `IPlugin`.
3. **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.
4. **Validate config defensively** inside `onLoad`/`onEnable` — the host does not enforce your `configSchema` types, only redacts `secret` fields.
5. **Build and package**, then install the resulting `.zip` into a local OpenWA instance to verify.
### Building from source
```bash
# Bundle a plugin's TypeScript into a single dist/index.js and produce .zip
node package.mjs gsheets-logger
```
The build bundles the entry to a single CommonJS file (exposing `.default`) with [esbuild](https://esbuild.github.io/) — 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 ` → bundle + zip
└─ / # one folder per plugin
├─ manifest.json
├─ index.ts # default-exports an IPlugin class
├─ … # supporting modules + tests
└─ dist/index.js # built artifact (manifest.main)
```
## Compatibility
All 11 plugins are checked against OpenWA **v0.30.0** using its package parser, manifest validators and worker runtime with isolated service doubles. Verification covers lifecycle calls, declared hooks, representative capability calls and per-session configuration where supported. Live WhatsApp sessions, provider credentials and dashboard rendering are outside that check.
Each plugin declares its `minOpenWAVersion`, which remains its supported lower bound. OpenWA enforces this floor at install and boot load since v0.24.0; `testedOpenWAVersion` records the worker verification baseline and does not promise compatibility with every host configuration.
## Contributing
Contributions are welcome — new plugins, fixes, and documentation.
1. Fork and branch from `main`.
2. Add your plugin in its own folder following the [authoring guide](#authoring-a-plugin), including a per-plugin `README.md` with setup steps and a config reference.
3. Include at least one runnable test for non-trivial logic.
4. Ensure the plugin builds and packages cleanly with `node package.mjs `.
5. 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 `permissions` and `sessions` before 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](https://github.com/rmyndharis/OpenWA/security) rather than opening a public issue.
## Support
If these plugins are useful to you, you can support OpenWA development by buying me a coffee.
## License
[MIT](LICENSE) © Yudhi Armyndharis & OpenWA Contributors.
Built for OpenWA — the free, self-hosted WhatsApp API Gateway.