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.7.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.7.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 hosts in the manifest's net.allow list. 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.1 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.14.0) |
| 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. A malformed schedule or unknown timezone fails fast and shows as ERROR in the dashboard rather than misbehaving silently.
Configuration.
| Key | Required | Default | Description |
|---|---|---|---|
schedule | yes | — | JSON object mapping mon..sun to "HH:MM-HH:MM" (24-hour, open < close) or null for closed. An absent day is closed. |
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. |
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.2 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.14.0) |
| 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 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. 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 | no | — | The menu tree: an array of { key, text, options? } nodes that nest arbitrarily. |
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.1 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.7 (tested 0.14.0) |
| 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. 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 | Relay messages the account itself sends from the linked phone. |
backfillLimit | no | 0 | On start, backfill at most this many recent chats per session. |
backfillAllOnce | no | false | Backfill every chat once on first boot. |
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.
Do not mint from the dashboard: its instance form has no secret field and auto-generates a random one that can never match Chatwoot's — every agent reply then 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.1 |
| Status | stable |
| Requires OpenWA | ≥ 0.6.1 (tested 0.14.0) |
| 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. If nothing matches and fallbackReply is set, the fallback is sent, throttled per chat by fallbackCooldownSec. Group chats are ignored unless respondInGroups is set. An invalid regex rule is skipped with a warning; a structurally invalid rules value fails fast and shows as ERROR in the dashboard.
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.1 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.14.0) |
| 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: read-only commands are open to anyone; 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. |
/tr status | anyone | Show whether translation is on and per-participant languages. |
/tr on · /tr off | admin | Enable or disable translation in this group. |
/tr setlang <code> [@user] | admin | Pin a participant's language. |
/tr auto [@user] | admin | Resume auto-learning a participant's language. |
/tr ignore · /tr unignore [@user] | admin | Skip or resume translating a participant. |
/tr grant · /tr revoke [@user] | admin | Delegate or remove control to a non-admin participant. |
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.3 |
| Status | stable |
| Requires OpenWA | ≥ 0.7.0 (tested 0.14.0) |
| 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. net:fetch is the only permission it declares, 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. |
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.2 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.0 (tested 0.14.0) |
| 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). 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. 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 | Per-chat cooldown between runs of the same action. |
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.0 |
| Status | beta |
| Requires OpenWA | ≥ 0.8.16 (tested 0.14.0) |
| 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 200 on accept. The plugin parses { user: { phone }, sms: { otp } }, normalizes the phone to <digits>@c.us, and fires the WhatsApp send in the background (bounded to 5 s) so a timeout-induced retry can't duplicate the OTP. Events are ordered per user.id and deduped on webhook-id. 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.
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 and rely on the fallbackSessionId plugin config instead, and the preflight is skipped: a delivery whose fallback session is down is acked 200 and lost, with no provider retry and only a sendText failed (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.2 |
| Status | stable |
| Requires OpenWA | ≥ 0.8.2 (tested 0.14.0) |
| 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 | — | Host serving Typebot media bubbles (when separate from apiHost). 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.3 |
| Status | beta |
| Requires OpenWA | ≥ 0.7.0 (tested 0.14.0) |
| 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": "…@s.whatsapp.net",
"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 30000. |
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 30000. |
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). |
| 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). |
| 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.