Plugin Catalog
The ten official plugins maintained in the openwa-plugins marketplace repository. Each entry lists what the plugin does, the permissions it declares, its configuration keys, and the minimum OpenWA version it requires.
Every plugin here is an extension that runs sandboxed in a worker thread and hooks message:received (some hook more) — except supabase-otp-hook, which declares no hooks at all and is driven entirely by its ingress route. For the runtime model and the permission system, see Plugin Architecture. To install and configure a plugin, see Plugins Overview.
Summary
| Plugin | Does | Permissions | Status | Requires |
|---|---|---|---|---|
| after-hours | Away reply outside business hours | messages:send | stable | ≥ 0.7.0 |
| chat-flow | Stateful numbered-menu bot | messages:send, storage:use | stable | ≥ 0.7.0 |
| chatwoot-adapter | Two-way Chatwoot inbox sync + handover | net:fetch, conversation:send, webhook:ingress, engine:read, storage:use | stable | ≥ 0.8.7 |
| faq-bot | Keyword/regex auto-reply | messages:send | stable | ≥ 0.6.1 |
| group-translate | In-group auto-translation | messages:send, engine:read, net:fetch, storage:use | stable | ≥ 0.8.0 |
| gsheets-logger | Logs message events to a Google Sheet | net:fetch, storage:use | stable | ≥ 0.7.0 |
| http-action | Run REST API calls from chat commands | net:fetch, conversation:send, storage:use | stable | ≥ 0.8.0 |
| supabase-otp-hook | Deliver Supabase phone OTPs over WhatsApp | webhook:ingress, messages:send | beta | ≥ 0.8.16 |
| typebot-connector | Run a Typebot flow as the bot brain | net:fetch, conversation:send, storage:use | stable | ≥ 0.8.2 |
| voice-transcription | Voice notes → text webhook event | net:fetch, messages:send, storage:use | beta | ≥ 0.8.0 |
Requires is the minOpenWAVersion. All ten are documented at the versions published in the marketplace catalog; column values come from each plugin's manifest.json.
messages:send lets the plugin send and reply to messages. conversation:send is the integration-SDK capability that relays messages back into a chat (used by the adapters/connectors that own their own send path). engine:read lets a plugin read engine state such as group info. net:fetch lets it make outbound HTTP calls through the host's SSRF-guarded fetch, restricted to the hosts in the manifest's net.allow list plus, for each config key named in net.allowConfigHosts, the host of the URL the operator configured under it. That second half is how a plugin with an empty net.allow still reaches the backend you point it at. webhook:ingress lets a plugin expose a verified inbound URL (signed by the caller — for example Chatwoot HMAC or Standard Webhooks) that the host authenticates before the plugin runs. storage:use lets a plugin read and write its own key-value store through ctx.storage; it is required from OpenWA v0.17.0, and before that release the store was reachable with no permission declared at all. A plugin that declares no permissions can only read hook events.
The seven plugins that declare storage:use above need at least the versions listed in their tables below — 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, voice-transcription 1.2.3 — installed before the gateway moves to v0.17.0. The Requires column does not encode this: none of the seven raised its minOpenWAVersion, because the new versions run unchanged on an older gateway, which ignores a permission string it does not recognise. It is the older plugin versions that break on v0.17.0, and they break quietly: the denial lands at the plugin's next storage call rather than at load, and a plugin that stores during onEnable ends up in ERROR instead of merely losing the write. after-hours, faq-bot, and supabase-otp-hook never touch ctx.storage and need no upgrade.
after-hours
Replies with a configurable away message to messages received outside business hours.
| Field | Value |
|---|---|
| Identifier | after-hours |
| Version | 0.2.7 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.23.4) |
| Type | extension |
| Permissions | messages:send |
| Hooks | message:received |
| Repository | OpenWA-plugins/after-hours |
What it does. Holds a per-weekday business-hours schedule interpreted in a configurable IANA timezone. When a message arrives outside the open window for that day, it sends the configured away message as a quoted reply, throttled to at most once per chat per cooldownSec. Group chats are ignored unless respondInGroups is set, and a WhatsApp Channel (@newsletter) or broadcast-list (@broadcast) chat is never replied to at all, since the account cannot hold a conversation there. A malformed schedule or unknown timezone fails the enable, so the plugin shows as ERROR in the dashboard rather than misbehaving silently. A config edit saved after enable cannot fail that way: the host forwards it fire-and-forget, so the plugin stays ENABLED, skips every message with a warning in its log, and only its health check turns unhealthy.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
schedule | yes | — | JSON object mapping mon..sun to "HH:MM-HH:MM" (24-hour) or null for closed. An absent day is closed, and at least one day must be open. A window belongs to the day it opens on, so "22:00-06:00" under mon stays open into Tuesday morning. "00:00-00:00" means open all day; any other window whose open equals its close is rejected. |
timezone | no | UTC | IANA timezone the schedule is interpreted in, e.g. Asia/Jakarta. |
awayMessage | yes | — | Reply sent outside business hours. |
cooldownSec | no | 3600 | Minimum seconds between after-hours replies to the same chat. 0 replies every time. A reply that fails always installs a 60-second backoff on that chat whatever this value is, so a permanently blocked send cannot turn every inbound message into another attempt. |
respondInGroups | no | false | Whether to reply in group chats. |
Example schedule:
{ "mon": "09:00-17:00", "tue": "09:00-17:00", "wed": "09:00-17:00",
"thu": "09:00-17:00", "fri": "09:00-17:00", "sat": "09:00-13:00", "sun": null }
chat-flow
An interactive, stateful auto-reply: a trigger word opens a greeting plus a numbered menu, and replies walk a configurable menu tree.
| Field | Value |
|---|---|
| Identifier | chat-flow |
| Version | 1.1.8 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.23.4) |
| Type | extension |
| Permissions | messages:send, storage:use |
| Hooks | message:received |
| Repository | OpenWA-plugins/chat-flow |
What it does. A trigger word (or any message, if trigger is empty) sends a greeting and a numbered menu. The user's reply selects an option; selections traverse a menu tree of arbitrary depth, where leaf nodes end the flow. State is kept per (session, chat), and in a group it is scoped to the sender as well, so members walk the menu independently without clobbering each other's position. It expires after 15 minutes of inactivity; re-sending the trigger restarts an active flow. A reply that matches no option re-sends the current menu, but only twice: the third consecutive miss drops the flow silently, so a contact who has stopped following the menu, or another bot on the far end, cannot hold it open forever. A WhatsApp Channel or broadcast-list chat is ignored outright, whatever trigger is set to. The plugin is session-scoped (sessionScoped) and ships a visual flow editor (configUi) the dashboard opens in a sandboxed frame, so the tree can be designed without hand-editing JSON.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
greeting | yes | — | The greeting plus menu sent when the flow starts. |
options | yes | — | The menu tree: an array of { key, text, options? } nodes that nest arbitrarily. At least one option is required; a config without one is rejected. |
trigger | no | — | Word that starts the flow (case-insensitive). Empty means any message starts it. |
respondInGroups | no | false | Whether to run in group chats. |
Example options tree:
{
"trigger": "menu",
"greeting": "Hi! Reply with a number:\n1. Pricing\n2. Support",
"options": [
{ "key": "1", "text": "Plans start at Rp100.000/mo." },
{ "key": "2", "text": "Support — reply with a number:\n1. Billing\n2. Technical",
"options": [
{ "key": "1", "text": "Billing: billing@example.com" },
{ "key": "2", "text": "A ticket has been created — we'll reply shortly." }
] }
]
}
chatwoot-adapter
Two-way sync between a WhatsApp session and a Chatwoot inbox, with human handover — relay WhatsApp into Chatwoot, send agent replies back, and silence other OpenWA bots on that chat while a human agent is in control.
| Field | Value |
|---|---|
| Identifier | chatwoot-adapter |
| Version | 0.9.7 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.7 (tested 0.23.4) |
| Type | extension |
| Permissions | net:fetch, conversation:send, webhook:ingress, engine:read, storage:use |
| Hooks | message:received, message:sent |
| Repository | OpenWA-plugins/chatwoot-adapter |
What it does. Binds one Chatwoot account (apiToken + accountId + inboxId) to a WhatsApp session. Inbound WhatsApp messages (1:1 and groups, with media) are relayed into a Chatwoot API-channel inbox as incoming messages; agent replies (outgoing, non-private) are sent back to WhatsApp. Assigning a human agent in Chatwoot triggers handover — other OpenWA bots stop auto-replying on that chat until the conversation is unassigned. Resolving the conversation silences them too, by a separate closed state, and neither unassigning nor reopening hands it back: a status-only change writes no handover state at all, so closed stays until an assignee change lands on a conversation that is no longer resolved. Only an assignee change moves a conversation between human and bot, never the status alone. Chatwoot contacts are keyed on the WhatsApp JID, so matching is stable across WhatsApp's @lid migration, and a group maps to one synthetic contact with sender-prefixed messages. Inbound and outbound are serialized by a per-chat lock, so a cold-start burst can't create duplicate contacts or conversations; both directions are idempotent.
An agent's Reply to in Chatwoot is relayed as a real WhatsApp quote where the engine allows it. A text reply is quoted; if the quoted message has fallen outside the engine's retained window the engine refuses the quote and the reply is re-sent unquoted rather than failing. A reply carrying an attachment is never quoted — the engine's media path cannot quote at all.
Agent replies and conversation-status changes arrive on an account-level Chatwoot webhook that OpenWA verifies (HMAC) before the adapter sees it — agent-bot / inbox webhooks are unsigned and are not supported.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
baseUrl | yes | — | Public https origin of your Chatwoot, e.g. https://app.chatwoot.com. Its host is auto-allowed through the manifest's net.allowConfigHosts. Origin only — a value carrying a path is rejected when the settings are saved. |
apiToken | yes | — | Chatwoot agent-bot API token. Stored redacted. |
accountId | yes | — | Numeric Chatwoot account id. |
inboxId | yes | — | Numeric id of the API-channel inbox. |
relayGroups | no | true | Relay group chats as well as 1:1. |
relayMedia | no | true | Upload media from WhatsApp as Chatwoot attachments. |
relayOwnMessages | no | true | Mirror messages the account itself sends, whether from the linked phone, the WhatsApp app, or the OpenWA API, so the thread is not one-sided. Chatwoot agent replies are never duplicated. |
backfillLimit | no | 0 | When a chat first opens in Chatwoot, import this many recent messages from it, both directions, so agents see prior context. 0 disables it; the range is 0 to 100. Attachments are imported only at 25 or below, since a larger import would exceed the host's 30-second budget, so older media arrives as a placeholder line. |
backfillAllOnce | no | false | In addition to the per-conversation backfill, run a one-time sweep importing the history of every existing chat. Needs backfillLimit above 0 to do anything, and runs once per session; a large inbox takes a while. |
The adapter verifies the webhook HMAC signature against the instance's ingress secret, so that secret must equal the secret Chatwoot shows on the webhook's edit form — and Chatwoot generates the secret itself, so it can only be copied into OpenWA. You need Chatwoot v4.12.0 or newer, the first release whose account-level webhooks carry a per-webhook secret and a timestamped signature (X-Chatwoot-Timestamp). Create the webhook at Integrations → Webhooks, subscribed to message_created and conversation_updated, then mint the instance with POST /api/integration/plugins/chatwoot-adapter/instances, passing the Chatwoot webhook secret as secret.
The dashboard can mint it too: the Plugins page's per-plugin Instances tab carries a Secret field, so paste Chatwoot's value there. What must not happen is leaving that field blank, on either path: the server then generates a random secret that can never match Chatwoot's, and every agent reply fails HMAC verification with a 401. The secret cannot be edited after minting; to change it, delete the instance and re-mint.
faq-bot
Auto-replies to inbound messages from configurable keyword or regex rules.
| Field | Value |
|---|---|
| Identifier | faq-bot |
| Version | 0.2.10 |
| Status | stable |
| Requires OpenWA | ≥ 0.6.1 (tested 0.23.4) |
| Type | extension |
| Permissions | messages:send |
| Hooks | message:received |
| Repository | OpenWA-plugins/faq-bot |
What it does. Matches each inbound message against an ordered list of rules. Each rule is contains, exact (both case-insensitive), or regex (compiled with the i flag). The first matching rule wins, and its reply is sent as a quoted reply. Rules see only messages that carry text: a sticker, image or voice note arrives with an empty body and is passed over before matching, so it can never draw a fallback and a media-capable plugin behind faq-bot still sees it. Since 0.2.6 a shared contact card and a poll are dropped by type as well, because host v0.23.2 fills their body and a vCard is free text that readily matches a contains or regex rule; business button and list replies are real answers and are still matched. That type list stops at those two, and 0.2.10 is tested against 0.23.4: a v0.23.5 gateway types an inbound order and a shared product card as order and product on both engines, and on Baileys fills their body where it used to be empty: an order's from the customer's note, else the order title; a product card's from the text sent with the card, else the product title. So a placed order or a shared product card is matched like an ordinary typed message and can draw fallbackReply. If nothing matches and fallbackReply is set, the fallback is sent, throttled per chat by fallbackCooldownSec. A matched rule carries its own throttle, fixed in code rather than configurable: since 0.2.6 the same inbound text in the same chat is answered at most once every 10 seconds, which stops the plugin trading one canned line at full message rate with an autoresponder on the other end. The window keys on the text rather than on the rule, comparing it trimmed, lowercased and truncated to 200 characters, so two different questions that match the same rule are both answered; a message silenced by that window is still claimed, so a plugin behind faq-bot does not answer it either. Group chats are ignored unless respondInGroups is set, and a WhatsApp Channel or broadcast-list chat is never answered at all. An invalid regex rule is skipped with a warning; a structurally invalid rules value fails fast and shows as ERROR in the dashboard. Since 0.2.10 a regex rule sees only the first 150 characters of a message, which bounds the cost of a pattern whose blow-up the parse-time screen cannot recognise from its text alone. A pattern that needs to match further in than that will not fire; contains and exact cannot backtrack and still see the whole message, so contains stays the right mode for "does this message mention X anywhere".
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
rules | yes | — | JSON array of { mode, pattern, reply } rules, where mode is contains, exact, or regex. |
fallbackReply | no | "" | Reply sent when no rule matches. Empty stays silent. |
fallbackCooldownSec | no | 600 | Minimum seconds between fallback replies to the same chat. 0 replies every time. |
respondInGroups | no | false | Whether to reply in group chats. |
Example rules:
[
{ "mode": "contains", "pattern": "harga", "reply": "Harga mulai Rp100.000. Ketik 'menu' untuk detail." },
{ "mode": "exact", "pattern": "menu", "reply": "1) Harga 2) Jam buka 3) Lokasi" },
{ "mode": "regex", "pattern": "^/start", "reply": "Selamat datang! Ada yang bisa kami bantu?" }
]
group-translate
Auto-translates group messages between participants' languages via a LibreTranslate backend, controlled in-chat with
/trcommands.
| Field | Value |
|---|---|
| Identifier | group-translate |
| Version | 1.3.7 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.0 (tested 0.23.4) |
| Type | extension |
| Permissions | messages:send, engine:read, net:fetch, storage:use |
| Hooks | message:received |
| Repository | OpenWA-plugins/group-translate |
What it does. Learns each group member's language from what they type (or pins it with /tr setlang), then posts a combined reply translating each message into the other languages present. Everything is managed in-chat with /tr commands: only /tr help is open to anyone, and every other command, /tr status included, needs a group admin or a delegate they granted control to; grant and revoke themselves are admin-only. State-changing commands are admin-gated, resolved via ctx.engine.getGroupInfo (hence engine:read). Because WhatsApp delivers a group message's author under a privacy id (…@lid) while the participant list comes back under phone ids, an admin who fails the direct comparison is re-resolved to their phone identity through the host and compared again, so admin commands work across the @lid/phone split. Translation is disabled until an admin runs /tr on. Outbound translate calls go through the host's SSRF-guarded ctx.net.fetch. A per-call timeout plus a circuit breaker back off a slow or flaky backend instead of stalling the chat.
Since v1.3.0 the manifest ships net.allow for localhost and 127.0.0.1 — loopback on any port — and declares libretranslateUrl in net.allowConfigHosts, which admits an operator-configured host over https only. So a loopback backend and a public https backend both work as shipped. The one shape still needing a manifest edit and a re-package is a plain-http backend on a non-loopback host (for example http://libretranslate:5000 inside a Docker network) — add its host:port to net.allow, or put it behind https.
The host-level loopback guard is separate and applies regardless: a localhost/127.0.0.1 (or other private) address still needs SSRF_ALLOWED_HOSTS set on the OpenWA host, because the SSRF guard blocks those by default. The default libretranslateUrl is a loopback address, so out of the box the plugin cannot reach its backend until that is set.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
libretranslateUrl | yes | http://localhost:7001 | Base URL of your LibreTranslate instance. Loopback on any port works as shipped; any other host is admitted over https only. A loopback or private address also needs SSRF_ALLOWED_HOSTS on the gateway. |
libretranslateApiKey | no | — | Secret API key, if your instance requires one. Redacted on read. |
timeoutMs | no | 4000 | Per-call timeout. Keep at or below the host hook budget (5000 ms). |
commandPrefix | no | /tr | The in-chat command prefix. |
minLength | no | 2 | Minimum message length to translate. |
maxLength | no | 2000 | Maximum message length to translate. |
denyReply | no | false | Reply "admins only" when a non-admin runs a restricted command. |
announceInGroups | no | false | Post the bot's introduction the first time it sees a message in a group. Off by default — one enable would otherwise announce the bot into every group the account belongs to. /tr help prints the same text on demand. |
In-chat commands (default prefix /tr):
| Command | Who | Effect |
|---|---|---|
/tr help | anyone | Show the command list, at most once per minute per group. |
/tr status | admin or delegate | Show whether translation is on and per-participant languages. |
/tr on · /tr off | admin or delegate | Enable or disable translation in this group. |
/tr setlang <code> [@user] | admin or delegate | Pin a participant's language. |
/tr auto [@user] | admin or delegate | Resume auto-learning a participant's language. |
/tr ignore · /tr unignore [@user] | admin or delegate | Skip or resume translating a participant. |
/tr grant · /tr revoke [@user] | admin only | Delegate or remove control to a non-admin participant. |
A delegate is a non-admin the group's admins granted control with /tr grant. Only grant and revoke are restricted to real group admins; every other gated command accepts an admin or a delegate. A denied command answers nothing unless denyReply is on, so an unauthorized user cannot amplify replies into the group.
gsheets-logger
Logs every WhatsApp message event to a Google Sheet via a service account — an append-only audit trail across all sessions.
| Field | Value |
|---|---|
| Identifier | gsheets-logger |
| Version | 0.3.9 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.23.4) |
| Type | extension |
| Permissions | net:fetch, storage:use |
| Hooks | message:received, message:sent, message:failed, message:ack |
| Repository | OpenWA-plugins/gsheets-logger |
What it does. Writes one row per message event — across message:received, message:sent, message:failed, and message:ack — to a Google Sheet, using a fixed 14-column schema. It authenticates as a Google service account (JWT RS256) with no runtime dependencies. Writes are buffered and flushed in batches, with retain-on-failure (rows are kept and retried on a Sheets error), a 5000-row cap, and persistence to plugin storage so the buffer survives restarts. It declares two permissions, net:fetch and storage:use (the latter since 0.3.3, for the buffer that survives a restart), and its net.allow names just the two hosts it ever calls — oauth2.googleapis.com and sheets.googleapis.com. It never sends messages and never reads contacts.
The 14 columns, one row per event:
timestamp | sessionId | event | direction | chatId | from | to | senderName | isGroup | type | body | messageId | ackStatus | error
Enabling the Google Sheets API on the project and sharing the spreadsheet with the service account (as Editor) are independent steps. Skipping either fails with a different 403 in the logs. See the plugin's README for the full setup walkthrough.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
serviceAccountJson | yes | — | Full service-account key JSON. Stored as a secret. Share the sheet with this account's client_email as Editor. |
spreadsheetId | yes | — | The ID from the sheet URL, between /d/ and /edit. |
sheetTab | no | Logs | Target tab name. The tab must already exist, with a header row of your choosing — the plugin appends data rows only. |
flushIntervalSec | no | 5 | Seconds between flushes. |
flushBatchSize | no | 20 | Flush early once this many rows are buffered. Suppressed while a flush is failing, so during a Sheets outage the buffer keeps growing and retries stay on flushIntervalSec instead of hammering the API on every event. |
message:ack rowsThe message:ack rows fill the messageId and ackStatus columns and require OpenWA ≥ 0.6.1; older builds never emitted the hook.
http-action
Trigger safe REST API requests from WhatsApp commands and map JSON responses back to chat — connect WhatsApp to an existing HTTPS API without webhook middleware.
| Field | Value |
|---|---|
| Identifier | http-action |
| Version | 0.2.8 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.0 (tested 0.23.4) |
| Type | extension |
| Permissions | net:fetch, conversation:send, storage:use |
| Hooks | message:received |
| Repository | OpenWA-plugins/http-action |
What it does. Each inbound message is matched against an ordered list of actions; the first matching action wins, and its trailing arguments are parsed (a double-quoted run is kept as one argument). Polls and shared contact cards never match: since host v0.23.2 a poll arrives with its question as the body and a contact card with its full vCard (several joined by newlines when the card carries more than one), so a poll titled with a configured prefix would otherwise fire a request and claim the message. The guard names those two types and no others, and 0.2.8 is tested against 0.23.4: a v0.23.5 gateway types a placed order as order and a shared product card as product, and on Baileys fills the body those two used to arrive without (an order's from the customer's note, else the order title; a product card's from the text sent with the card, else the product title), so either one whose text starts with a configured prefix fires its request and claims the message. Pick prefixes an order note or a product title will not collide with. A matched action issues a fixed-origin GET or JSON POST to a single HTTPS baseUrl — the path is server-relative and every interpolated segment is URL-encoded, so a message can never change the host or inject a path segment. The JSON response is rendered into a reply via {{response.field}} templates, with separate notFoundTemplate and errorTemplate. Direct chats are the default; group chats are ignored unless respondInGroups is set, and a WhatsApp Channel or broadcast-list chat is never acted on. One request runs per message, idempotent across WhatsApp redelivery (storage-backed dedup), with a per-chat cooldown and an off-dispatch handler so a slow upstream never stalls the inbound hook.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
baseUrl | yes | — | Single HTTPS origin every request targets. Auto-added to net.allow via allowConfigHosts. |
actions | yes | — | JSON array of action objects (shape below). |
authType | no | none | none, bearer, or apikey. |
authToken | cond. | — | Bearer token or API key, per authType. Stored redacted. |
apiKeyHeader | cond. | X-API-Key | Header name when authType is apikey. |
respondInGroups | no | false | Run in group chats. |
timeoutMs | no | 3000 | Per-request timeout. Min 500. |
cooldownSeconds | no | 3 | Minimum seconds between replies in a chat, shared by every action rather than per action: the key is the session and chat alone. 0 disables it. |
Each action is { id, match: { type, value, caseSensitive? }, request: { method, path, query?, headers?, bodyTemplate? }, replyTemplate, notFoundTemplate?, errorTemplate? }. match.type is exact or prefix. Template variables: args.0, args.1, … plus message.id, message.body, chat.id, sender.id, sender.phone, sender.name, session.id, and response.<path> (the parsed JSON body).
supabase-otp-hook
Deliver Supabase Auth phone OTPs over WhatsApp. Supabase's Send SMS hook (Standard Webhooks-signed) is verified host-side, and the plugin sends the OTP via an OpenWA WhatsApp session — with synchronous feedback to Supabase.
| Field | Value |
|---|---|
| Identifier | supabase-otp-hook |
| Version | 0.3.7 |
| Status | beta |
| Requires OpenWA | ≥ 0.8.16 (tested 0.23.4) |
| Type | extension |
| Permissions | webhook:ingress, messages:send |
| Hooks | — (ingress-driven) |
| Repository | OpenWA-plugins/supabase-otp-hook |
What it does. Supabase calls the plugin's OpenWA ingress URL. The host verifies the Standard Webhooks signature against the instance secret (webhook-id / webhook-timestamp / webhook-signature, base64 HMAC-SHA256, constant-time, 5-minute replay window) — a bad signature returns 401 before the plugin runs. The host then runs a session-alive preflight that returns 503 on a dead WhatsApp session, and fast-acks Supabase with a bodiless 204 on accept (0.3.7; the ack used to carry a JSON body, which the host's text/plain ingress response made Supabase reject as invalid JSON, discarding a code the plugin had already sent). The plugin parses { user: { phone }, sms: { otp } }, normalizes the phone to <digits>@c.us, and fires the WhatsApp send against a 1.5-second fail-fast window: a send that fails inside it throws, so the host retries the delivery and eventually dead-letters it for redrive, while a send that is merely slow is left to finish in the background rather than burning the worker's 5-second dispatch budget and retrying into a duplicate OTP. A send that fails after that window has closed reaches nobody, since Supabase was already acked, and the plugin's health check is the only surface that reports it. Events are ordered per user.id and deduped on webhook-id, which catches a replay of one delivery rather than a provider retry: Supabase mints a fresh webhook-id per attempt, so a retried hook is a new delivery and sends a second code. Since v0.3.0 the plugin no longer declares engine:read: the canonicalChatId round-trip it was used for could only ever return the same <digits>@c.us string, so it was dropped along with its 2-second race on the OTP critical path.
This is the canonical example of a plugin that owns an inbound URL rather than a chat hook: the webhook:ingress capability exposes the verified endpoint, and verification happens in the host (not the plugin) so a signature failure never reaches plugin code.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
appName | yes | — | Application name interpolated into the OTP message via {appName}. |
messageTemplate | no | {appName} | Your verification code is {otp} | Message body. Supports {appName} and {otp} placeholders. |
fallbackSessionId | no | — | Session to send through if the request doesn't name one. |
debug | no | false | Verbose logging. |
The instance secret you set in OpenWA is the same secret Supabase generates when you register the webhook URL — the host uses it to verify every delivery. OpenWA v0.8.16+ is required for the standard-webhooks signature scheme and the ingress preflight/response contract. Since 0.3.7 the ack is a bodiless 204, which needs Supabase Auth v2.172.0 or newer; every Supabase Cloud project is well past it, a self-hosted Auth below it has to be upgraded.
The host's preflight probes the instance's sessionScope. Bind the instance to a logged-in session and a dead session is reported to Supabase as 503, so the provider retries. Leave sessionScope blank, or set it to the wildcard *, and rely on the fallbackSessionId plugin config instead, and the preflight is skipped. A delivery whose fallback session is down then depends on timing: a send that fails inside the 1.5-second fail-fast window still throws, so the host retries it and finally dead-letters it for redrive, but one that fails after the window is acked 204 and lost, with no provider retry and only a background log line. Bind the instance wherever the sending session is known.
typebot-connector
Run a Typebot flow as the brain of a WhatsApp bot — inbound messages drive a Typebot chat session, and the bot's text, media, and numbered-choice replies come back to WhatsApp.
| Field | Value |
|---|---|
| Identifier | typebot-connector |
| Version | 0.2.8 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.2 (tested 0.23.4) |
| Type | extension |
| Permissions | net:fetch, conversation:send, storage:use |
| Hooks | message:received |
| Repository | OpenWA-plugins/typebot-connector |
What it does. On each inbound message in scope, the plugin resumes (or starts) the contact's Typebot session over the live Chat API and renders the bot's reply bubbles back into WhatsApp. Text bubbles are converted to WhatsApp formatting; image, video, and audio bubbles are sent as media; a choice step is shown as a numbered list and the contact's numeric reply is mapped back to the option. Typed inputs (email, number, date, …) are re-asked on a bad value, and a file-input step accepts a photo or file the contact sends. The session resets when the flow ends or after the idle timeout, so the next message starts fresh. It runs sandboxed in the plugin worker and polls Typebot's Chat API over the host's SSRF-guarded ctx.net.fetch — no public URL or webhook is required. Typebot holds the session state; the plugin persists only the session id and the bot's expected next input.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
apiHost | yes | https://typebot.io | Typebot API host. https://typebot.io for Cloud, or your self-hosted URL. Auto-added to net.allow. |
publicId | yes | — | Typebot public ID from the bot's Share settings. |
apiToken | no | — | API token, only for a restricted/preview bot. Stored redacted. |
respondInGroups | no | true | Run in group chats. |
sessionTimeoutMinutes | no | 30 | Idle minutes before the session resets. |
passContactVariables | no | true | Pass waNumber, waName, waChatId as Typebot flow variables. |
mediaHost | no | — | Only for an older self-hosted Typebot that uploads files to a separate S3/MinIO host: the plugin sends the uploaded file there. Leave it empty for Typebot main, which proxies uploads on its own origin. Auto-added to net.allow. |
voice-transcription
Transcribes inbound voice notes to text via an OpenAI-compatible speech-to-text backend and delivers a
message.transcriptionevent to your webhook.
| Field | Value |
|---|---|
| Identifier | voice-transcription |
| Version | 1.2.9 |
| Status | beta |
| Requires OpenWA | ≥ 0.8.0 (tested 0.23.4) |
| Type | extension |
| Permissions | net:fetch, messages:send, storage:use |
| Hooks | message:received |
| Repository | OpenWA-plugins/voice-transcription |
What it does. On each inbound voice note, runs speech-to-text against any OpenAI-compatible /v1/audio/transcriptions endpoint (self-hosted Speaches/faster-whisper, or hosted Groq/OpenAI) and POSTs a message.transcription event to your delivery webhook — so a bot or AI can read and reply to audio. Transcription runs off the message-delivery path as an un-awaited task: it never touches the message.received payload, never blocks delivery, and is not bound by the 5-second hook budget. It delivers completed (with the transcript), failed, or skipped status, so a consumer always knows a voice note arrived even when it can't be read. The plugin is in beta and disabled until enabled.
transcription.text is attacker-controlled speech; the event marks it untrusted: true. A downstream LLM responder must place it in a user role, never a system or trusted context — a caller can speak injection instructions a typist never would.
Delivered event (POSTed to deliveryWebhookUrl):
{
"event": "message.transcription",
"sessionId": "…",
"messageId": "<waMessageId>",
"chatId": "…@c.us",
"status": "completed",
"source": "speech-to-text",
"untrusted": true,
"transcription": { "text": "…", "language": "es", "provider": "faster-whisper", "model": "small" }
}
Correlate it to the original voice note by messageId. It arrives shortly after message.received, out of order — do not assume ordering. When deliverySecret is set, the body is HMAC-SHA256 signed in X-OpenWA-Signature: sha256=<hex>, the same scheme as core webhooks.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
sttBaseUrl | yes | — | OpenAI-compatible STT base URL (/v1/audio/transcriptions is appended). An https host is admitted automatically; a localhost target also needs SSRF_ALLOWED_HOSTS on the host. |
sttApiKey | no | — | Bearer key for a hosted backend (Groq/OpenAI). Blank for a local Speaches instance. Stored redacted. |
model | no | small | Whisper model name, e.g. small, base, whisper-large-v3-turbo. |
language | no | (auto) | Optional BCP-47 hint. Blank auto-detects. |
provider | no | faster-whisper | Informational label recorded in the delivered event. |
timeoutMs | no | 20000 | Per-request STT timeout. Min 1000, max 25000, and clamped in code as well as in the form: 30000 is the host's own per-capability budget (PLUGIN_CAP_TIMEOUT_MS), so a value that high expired with it and reported an STT timeout as a capability timeout. |
enabledMessageTypes | no | ["voice"] | Message types to transcribe. Add audio to also transcribe non-PTT audio (more cost). |
maxSizeBytes | no | 16777216 | Skip audio larger than this. |
maxPerHour | no | 60 | Best-effort per-session hourly transcription cap. |
deliveryWebhookUrl | cond. | — | Endpoint receiving the event. An https host is admitted automatically. Optional if you only use chatDelivery. |
deliverySecret | no | — | HMAC-SHA256 signs the body in X-OpenWA-Signature. Stored redacted. |
deliveryTimeoutMs | no | 5000 | Delivery POST timeout. Min 1000, max 25000. |
chatDelivery | no | off | Also post the transcript into WhatsApp: off (webhook only), self (a note to your own number), or reply (quote-reply to the sender). |
This plugin ships net.allow for localhost, 127.0.0.1, api.groq.com:443, and api.openai.com:443, and since v1.1.0 also declares sttBaseUrl and deliveryWebhookUrl in net.allowConfigHosts — so any https STT or delivery URL you configure is admitted automatically, with no manifest edit and no re-package. The static entries are what still covers a plain-http local backend. For a deeper how-to, see Voice Transcription.
Per-session config support
A sessionScoped plugin (the default) can carry per-session config overrides — two WhatsApp sessions under one plugin instance can run different settings. A plugin honors an override only if it re-reads ctx.config inside its hook rather than using a snapshot cached at enable. The status per plugin:
| Plugin | Per-session config | Notes |
|---|---|---|
| after-hours | ✅ Supported | All fields per session; takes effect on next message. |
| chat-flow | ✅ Supported | All fields per session; flow state is per (session, chat), and per sender as well inside a group. |
| chatwoot-adapter | ✅ Supported | All fields per session — first-class multi-tenant shape. |
| 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), and per sender as well inside a group, so participants do not interleave into one Typebot session. |
| voice-transcription | ⚠️ Supported, with caveat | Config-signature caching; multi-backend isolation needs one instance per session. |
For how to write a plugin that honors overrides, see Building a Plugin.