Supabase phone codes over WhatsApp
Supabase keeps creating and checking the code. whatsapi delivers it on WhatsApp, from your own number, instead of by SMS. It works on Supabase Cloud and on a Supabase you host yourself.
1 · Create a key and pair a number
If you haven't yet, the quickstart shows both. Codes leave from your connected number, and they count under the API key that saves the hook.
2 · Create the hook
Do it on the Webhooks page of the dashboard, or with one call. template is optional. Put {code} where the code goes.
import { Whatsapi } from "@whatsapi.sh/sdk";
const wa = new Whatsapi({ apiKey: "wa_your_key" });
const data = await wa.setSupabaseHook({ template: "Your Acme code is {code}" });{
"data": {
"secret": "v1,whsec_…", // shown once: copy it now
"record": {
"url": "https://api.whatsapi.sh/webhooks/supabase/send-sms/7d3e…",
"statusUrl": "https://api.whatsapi.sh/webhooks/supabase/send-sms/7d3e…/status",
"numberId": null,
"template": "Your Acme code is {code}",
"apiKey": { "id": "…", "name": "…", "prefix": "…", "revoked": false },
"createdAt": "…",
"updatedAt": "…"
}
}
}secret only comes back the first time you save and when you rotate it. Copy it now. Pass numberId to choose which number sends the codes. If you leave it out, codes go from your connected number, as long as you have exactly one. With none connected, or more than one, every code fails until you choose. Removing the chosen number counts as not choosing.
3 · Point Supabase at the hook
On Supabase Cloud
- In your project, open Authentication → Auth Hooks and add a Send SMS hook. Choose HTTPS as the hook type.
- Paste the
urlfrom the answer into the URL field and thesecretinto the secret field, as they are. The secret already starts withv1,whsec_. Click Create hook. - With the hook saved, open Authentication → Sign In / Providers → Phone. Turn on Enable Phone provider and leave Enable phone confirmations on, so every sign-up confirms with a code. Set SMS OTP Expiry to
300and save. You don't need an SMS provider: the hook takes its place.
On a Supabase you host yourself
Add these four lines to the environment of the auth service (GoTrue) in your docker-compose.yml. Paste the url and secret from the answer as they are. The secret already starts with v1,whsec_.
services:
auth:
environment:
GOTRUE_HOOK_SEND_SMS_ENABLED: "true"
GOTRUE_HOOK_SEND_SMS_URI: "https://api.whatsapi.sh/webhooks/supabase/send-sms/<hook id>"
GOTRUE_HOOK_SEND_SMS_SECRETS: "<secret from the answer>"
GOTRUE_SMS_OTP_EXP: "300"Supabase's compose file already has these variables as comments, like # GOTRUE_HOOK_SEND_SMS_URI. If yours does too, replace those lines instead of adding new ones next to them. Compose refuses a file that lists the same variable twice.
Turn on phone sign-in in .env, next to the compose file. Supabase's compose file already passes ENABLE_PHONE_SIGNUP to GoTrue as GOTRUE_EXTERNAL_PHONE_ENABLED, and ENABLE_PHONE_AUTOCONFIRM as GOTRUE_SMS_AUTOCONFIRM. Don't repeat those two in the compose file.
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=falseWithout ENABLE_PHONE_SIGNUP=true, phone sign-up and sign-in stay off. Supabase's example env file comes with ENABLE_PHONE_AUTOCONFIRM=true, which confirms sign-ups without a code. Set it to false. Once the hook is on, GoTrue ignores GOTRUE_SMS_PROVIDER and its own SMS template. The message is the template you saved here.
Then recreate the auth service. docker compose restart keeps the old values, so run this instead:
docker compose up -d auth4 · Try it
const phone = "+5511988887777";
// GoTrue creates a code, and whatsapi sends it to that WhatsApp.
await supabase.auth.signInWithOtp({ phone });
// Then check the code the person typed.
const { data, error } = await supabase.auth.verifyOtp({ phone, token: "482913", type: "sms" });The message comes from your number, with your template. Keep type: "sms" as it is: to Supabase, this is still a phone code.
5 · Offer phone sign-in only when it works
Supabase can't turn phone sign-in off by itself. If your number disconnects, every code fails and the person is stuck at the form. So before you show the phone option, ask the hook's statusUrl. It needs no key, so you can call it from the browser.
// Your hook's statusUrl. It needs no key, so it's safe in the browser.
const statusUrl = "https://api.whatsapi.sh/webhooks/supabase/send-sms/7d3e…/status";
async function phoneSignInWorks() {
try {
const res = await fetch(statusUrl);
return res.ok && (await res.json()).data.ready === true;
} catch {
return false; // Can't tell, so offer email instead.
}
}{ "data": { "ready": true } }ready is true when your account and the hook's API key are active and the number that sends is connected. The answer can be up to 30 seconds old. If the call fails or doesn't answer 200, treat it as not ready and offer email instead. A hook that doesn't exist answers 404.
It can't tell whether the number has new conversations left today, because that depends on who asks for the code. That refusal still reaches the person, with the reason.
Timeouts and expiry
GoTrue gives the whole hook call 5 seconds. After that it throws the code away, and that code can never verify, even if the message arrives later. We answer within 4 seconds.
GOTRUE_SMS_OTP_EXP is 60 seconds by default. That is too short for WhatsApp: the person has to see the message, open it and type the code. Set it to 300, which is five minutes. On Supabase Cloud it's SMS OTP Expiry, in the Phone provider settings.
When a code can't be sent
We tell GoTrue why, in words for the person at the form, like “We can't send more codes to new numbers today. Try again tomorrow.” GoTrue v2.189.0 and later shows that message. Older versions show a generic server error instead, so use v2.189.0 or later.
GoTrue's own limits apply before it calls us. GOTRUE_SMS_MAX_FREQUENCY allows one code per phone every 60 seconds by default. GOTRUE_RATE_LIMIT_SMS_SENT allows 30 codes an hour for your whole Supabase by default. You can raise it. On Supabase Cloud, that hourly limit is Rate limit for sending SMS messages, in Authentication → Rate Limits. The number's new-conversation budget, below, still applies.
While no number is connected, every code is refused this way. Supabase can't turn phone sign-in off by itself, so hide the phone option until the hook is ready again, as step 5 shows.
Each code is a WhatsApp message
Each code comes out of your account's quota, like any message. The first code to someone your number has never talked to also uses one of that number's new conversations for the day. A number can start 5 to 20 new conversations a day, depending on how long the line has been in use, unless you set a different limit for it. The hook sends from one number, so once that day's new conversations run out, codes to new people are refused until the next day. People your number has already talked to don't count.
Codes never wait in the send queue and never show “typing…”, because someone is at the form right now. Avoiding bans explains the new-conversation budget.
Rotating the secret
Save the hook with rotateSecret: true. The answer carries a new secret, and the old one keeps working for 24 hours.
import { Whatsapi } from "@whatsapi.sh/sdk";
const wa = new Whatsapi({ apiKey: "wa_your_key" });
const data = await wa.setSupabaseHook({ rotateSecret: true });Within those 24 hours, put the new secret in Supabase. On Supabase Cloud, open the Send SMS hook in Authentication → Auth Hooks and paste the new secret over the old one. If you host Supabase yourself, put it in GOTRUE_HOOK_SEND_SMS_SECRETS and recreate the auth service. While you switch, you can list both there, joined by |. GoTrue signs with each one.
GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_new…|v1,whsec_old…"Good to know
- The template takes
{code}and nothing else. Supabase decides when a code expires, so if you want the time in the message, write it in yourself and keep it matching the expiry you set in step 3. - When someone changes their phone number, the code goes to the new number.
- GoTrue only calls
httpsaddresses, except on localhost. The URI always starts withhttps://api.whatsapi.sh, even if Supabase runs on the same server as whatsapi.
When something goes wrong
On the left is the error your app gets back from Supabase.
| Hook requires authorization token | We couldn't verify the signature. The hook's secret in Supabase doesn't match ours, or the server's clock is more than 5 minutes off. Paste the secret again, or rotate it, and check the clock. |
| Unexpected status code returned from hook: 404 | There is no hook at that address. The URI has a typo, or the hook was deleted. A new hook comes with a new URL and a new secret, so update both. |
| Failed to reach hook within maximum time of 5.000000 seconds | GoTrue couldn't reach us in 5 seconds, and that code will never verify. If you host Supabase yourself, check that the auth container can open https://api.whatsapi.sh: DNS, firewall, proxy. Then ask for a new code. |
| Service currently unavailable due to hook / Invalid payload sent to hook | GoTrue older than v2.189.0 shows these instead of our reason when a code couldn't be sent. Upgrade to see the reason, or check the dashboard. |
If a code doesn't arrive, open the dashboard. Check that the number is still connected, and that neither your quota nor the number's new conversations for the day have run out. Revoking the API key that saved the hook also stops codes: save the hook again with a live key.