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 (401 on a bad signature, 503 on a dead session, 200 on accept).
| Field | Value |
|---|---|
| Identifier | supabase-otp-hook |
| Version | 0.3.2 |
| Released | 2026-08-16 |
| Status | beta |
| Author | maplerichie |
| License | MIT |
| Type | extension |
| Requires OpenWA | ≥ 0.8.16 (tested 0.20.0) |
| Keywords | supabase, auth, otp, sms, whatsapp, verification, standard-webhooks, openwa |
| Repository | OpenWA-plugins/supabase-otp-hook |
- OTP over WhatsApp — delivers Supabase phone OTPs as WhatsApp messages.
- Standard Webhooks verification (host-side) —
signature.scheme: "standard-webhooks"; the host verifieswebhook-id/webhook-timestamp/webhook-signature(base64 HMAC-SHA256 over${webhook-id}.${webhook-timestamp}.${rawBody}, constant-time, 5-min replay window) using the instance secret before the plugin ever runs. - Configurable message —
{appName}and{otp}placeholders. - Synchronous feedback — the host verifies the signature (→ 401 on failure) and runs a
session-alivepreflight (→ 503 on a dead WhatsApp session) before accepting, returning 200application/jsonon success. Supabase learns immediately whether the OTP could be handed off; a dead session no longer gets swallowed as a silent 202. - Fire-and-forget WhatsApp send — the ingress worker dispatch is bounded to 5 s, so the send runs in the background to avoid a timeout-induced retry that would duplicate the OTP.
- Per-user ordering + dedup — ordered per
user.id, deduped onwebhook-id. Ordering and the retry/DLQ path needQUEUE_ENABLED=trueon the host; with the queue off, ingress runs inline, takes no ordering lock, and makes a single attempt.
Supabase calls the OpenWA ingress URL. The host verifies the Standard Webhooks signature against the
instance secret (→ 401 on a mismatch), runs the session-alive preflight (→ 503 on a dead session),
persists the event for dedup, fast-acks Supabase with 200 application/json, then dispatches the
sandboxed handler async from the ingress worker (retry + DLQ). The handler parses { user: { phone }, sms: { otp } }, normalizes the phone to <digits>@c.us, and fires the WhatsApp send in the background
to stay within the worker's 5 s dispatch budget.
Requires OpenWA v0.8.16+ (the standard-webhooks signature scheme and the response/preflight
ingress contract) with a logged-in WhatsApp session, and a Supabase project with phone auth.
Install the plugin, then set its base config, then enable it (see Install below) — in
that order. Enabling validates the plugin's base (*) config and refuses to start without
appName, while the instance you mint in step 1 carries a per-instance config. Setting appName
only on the instance therefore leaves enable failing:
curl -X PUT "$OPENWA/api/plugins/supabase-otp-hook/config" \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "config": { "appName": "Acme" } }'Then wire the connection:
Wiring order: OpenWA mints the ingress URL → paste into Supabase → Supabase generates the webhook secret → paste it back into OpenWA as the instance secret (the host uses it to verify the Standard Webhooks signature).
1. Mint an OpenWA instance. Creates the ingress URL and binds the sending session.
- Option A — bind to a session. Set
sessionScopeto a logged-in WhatsApp session id. Simplest. - Option B — use a fallback. Leave
sessionScopeblank, setfallbackSessionIdin plugin config.⚠️ This gives up the dead-session 503. The host preflight probes the instance scope, and a blank scope has no single session to probe —fallbackSessionIdis plugin config the host never sees. So a delivery whose fallback session is down is answered200 {"ok":true}, Supabase treats it as delivered and does not retry, and the OTP is lost. The only trace is the plugin'ssendText failed (background)log line. Prefer Option A wherever the sending session is known. - Option C — misconfiguration. Both blank → the handler drops the delivery (no session to send from).
The plugin's Sessions tab controls activity, not which session sends. Sending session =
sessionScopeorfallbackSessionId, in that order.
curl -X POST "$OPENWA/api/integration/plugins/supabase-otp-hook/instances" \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"instanceId": "default",
"sessionScope": "<whatsapp-session-id>",
"config": { "appName": "Acme" }
}'Copy the ingress URL from the response: https://<host>/api/ingress/supabase-otp-hook/default/send-sms.
2. Create the Supabase Send SMS hook. Dashboard → Authentication → Auth Hooks → Add hook → Send SMS hook → HTTPS. Paste the ingress URL.
3. Generate the webhook secret. On the Supabase hook form, click Generate secret. Copy the full v1,whsec_<base64> (include the prefix). Save the hook.
4. Set the secret as the OpenWA instance secret. This is what the host verifies the signature against — it is not plugin config. Paste v1,whsec_... into the instance secret. The secret is re-read per delivery — no restart.
curl -X PATCH "$OPENWA/api/integration/plugins/supabase-otp-hook/instances/default" \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"secret":"v1,whsec_..."}'5. Enable phone auth. Supabase → Authentication → Providers → Phone → enable, set SMS provider to the hook.
6. Test. Trigger a phone-OTP sign-in. Supabase receives 200 and the OTP arrives in WhatsApp. A dead WhatsApp session yields 503 (visible to Supabase); a bad signature yields 401.
Option A — dashboard. Download supabase-otp-hook.zip from the Releases page, then dashboard → Plugins → Install → upload.
Option B — CLI.
node package.mjs supabase-otp-hook # build the zip
curl -X POST "$OPENWA/api/plugins/install" \
-H "Authorization: Bearer $ADMIN_KEY" \
-F "file=@supabase-otp-hook.zip"
# Base config BEFORE enable — enabling validates it and refuses to start without appName.
curl -X PUT "$OPENWA/api/plugins/supabase-otp-hook/config" \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "config": { "appName": "Acme" } }'
curl -X POST "$OPENWA/api/plugins/supabase-otp-hook/enable" \
-H "Authorization: Bearer $ADMIN_KEY"| Key | Type | Required | Description |
|---|---|---|---|
appName |
string | yes | Inserted into the message template's {appName} placeholder. |
messageTemplate |
textarea | no (default {appName} | Your verification code is {otp}) |
WhatsApp message body. {appName} and {otp} are replaced. |
fallbackSessionId |
string | yes if sessionScope blank |
Sending session when the instance isn't bound. Ignored when sessionScope is set. |
debug |
boolean | no (default false) |
Log inbound deliveries (resolved session/chatId), sends, and send failures. |
The Supabase webhook secret is set as the instance secret (at mint time or via PATCH), not in
this config — the host uses it to verify the Standard Webhooks signature before the handler runs.
Session scope (which session sends) is also set at instance mint time, not in this config.
- OpenWA ≥ 0.8.16 — Integration SDK v1 with the
standard-webhooksingress signature scheme and theresponse/preflight contract (ctx.registerWebhook,webhook:ingresspermission). - Supabase — HTTP Send SMS hook with Standard Webhooks signing. SQL (Postgres function) hook variant not supported.
- WhatsApp — may rate-limit or require an approved business template for business-initiated messages. Adjust
messageTemplateto match your approved wording.
Supported. Every config field (appName, messageTemplate, fallbackSessionId, debug) may be
overridden per WhatsApp session via the dashboard; an override takes effect on the next delivery (config
is re-read per delivery). For example, two sessions can brand the OTP message differently. Note: this
plugin's inbound channel is the Supabase webhook (not a WhatsApp message), so a per-session override
applies to the session the instance is bound to (sessionScope) — bind each instance to a session, or
set fallbackSessionId.
- Webhook secret stored as the instance secret (masked on dashboard reads after mint).
- Signature verified host-side, before any send or plugin code runs, constant-time compare, 5-min replay window.
- A bad signature returns 401 synchronously. A dead session returns 503 synchronously only under
Option A — the host-side preflight probes the instance's
sessionScope, so a blank scope (Option B) skips the check and the delivery is acked200before the plugin runs. Neither case reaches plugin code. - Permissions:
webhook:ingress+messages:sendonly (liveness is checked host-side, so noengine:read).
See CHANGELOG.md.
MIT.