Supabase's Send SMS Hook replaces Supabase's built-in SMS sending: once it is on, Supabase Auth hands each SMS one-time code to your endpoint and sends nothing itself. That makes the hook the place to ask Spaw about the number first. The code goes to your SMS provider only when your account's SMS policy answers ok_to_send: true, and a refused number never costs you a message. Because the hook now owns delivery, the recipe below checks the number and then sends the SMS; there is no setting that checks and hands the code back to Supabase.
What the hook receives
From Supabase's auth-hooks documentation and the source of Supabase Auth, both read on 27 September 2026:
| Item | What Supabase provides |
|---|---|
| The payload | user (the Supabase user record) and sms.otp, signed under the Standard Webhooks specification |
| The number | sms.phone, the number the code is for, and user.phone; both come without the leading + |
| The visitor's address | metadata.ip_address, added to every hook payload in Supabase Auth 2.187.0 (23 February 2026); the Send SMS Hook page does not list it yet |
| Refusing | An error object with http_code and message in the JSON answer is returned to your app with that status, except on a multi-factor challenge, where Supabase answers a generic 500 |
| Time limit | "We have a time budget of 5s for the entire webhook invocation, including retry requests." |
Two consequences follow. The number needs its + back before it goes to Spaw: 12025550103 without it answers valid: false with reason missing_country, and so would every other number sent that way, unless the key has a default country, which reads the digits as a national number of that country and so misreads numbers from anywhere else. And the whole hook, the Spaw call and the SMS provider's call together, has five seconds, so the sample gives Spaw two.
metadata.ip_address is the address Supabase Auth saw, the same one its rate limits use. When a server-side framework calls Supabase Auth for the visitor, that is your server unless you forward the visitor's address in the Sb-Forwarded-For header. Supabase's rate-limits page says the header is honoured only on requests made with a secret API key, and that a new project has to switch IP Address Forwarding on under Authentication, Rate Limits first. If you do not forward it, pass nothing to checkNumber() as the address rather than your server's: a public server address puts every visitor in one count and would soon add numbers_per_client to all of them, while a private or reserved address is counted but never adds the signal.
Before you start
- Create a secret API key on the API keys page of your Spaw dashboard, scoped to phone, with a daily credit cap above a normal day's sign-ups. Generate a feedback key on the same page; the sample puts it in the delivery-receipt URL.
- Switch the SMS policy's opt-out rule off, so a person who once replied STOP to a marketing message can still receive a sign-in code:
curl -X PUT https://spaw.co/api/v1/phone/policy \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"block_opted_out": false}'
An account has one policy, applied to every lookup it makes. With the rule off, an opted-out number is decided like any other and the answer still says meta.opted_out: true, so a marketing flow on the same account reads that field itself.
- Set the secrets:
supabase secrets set SPAW_KEY=sk_live_… SPAW_FEEDBACK_KEY=fb_… TWILIO_ACCOUNT_SID=… TWILIO_AUTH_TOKEN=… TWILIO_PHONE_NUMBER=…, andSEND_SMS_HOOK_SECRETonce the hook has generated one. - Deploy the function below with
supabase functions deploy send-sms --no-verify-jwt. Supabase asks for the flag because the hook runs before any JWT exists; the Standard Webhooks signature protects the payload instead. - In the dashboard, under Authentication, Hooks, add the Send SMS hook with the HTTP type, point it at the function's URL and generate the secret.
The function
The sample sends through Twilio's Messages API, the provider Supabase's own Send SMS Hook example uses; any provider's HTTP call fits in the same place.
import { Webhook } from "https://esm.sh/[email protected]";
const hook = new Webhook(Deno.env.get("SEND_SMS_HOOK_SECRET").replace("v1,whsec_", ""));
const spawKey = Deno.env.get("SPAW_KEY");
const spawFeedbackKey = Deno.env.get("SPAW_FEEDBACK_KEY");
const twilioSid = Deno.env.get("TWILIO_ACCOUNT_SID");
const twilioToken = Deno.env.get("TWILIO_AUTH_TOKEN");
const twilioFrom = Deno.env.get("TWILIO_PHONE_NUMBER");
// Supabase Auth hands every answer back as JSON, errors included.
const reply = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
// Supabase stores numbers without the leading plus; Spaw needs it.
const e164 = (value) => "+" + String(value ?? "").replace(/^\+/, "");
async function checkNumber(phone, clientIp) {
try {
const response = await fetch("https://spaw.co/api/v1/phone", {
method: "POST",
headers: {
Authorization: `Bearer ${spawKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(clientIp ? { phone, client_ip: clientIp } : { phone }),
signal: AbortSignal.timeout(2000),
});
return response.ok ? (await response.json()).data : null;
} catch {
return null;
}
}
Deno.serve(async (req) => {
let event;
try {
event = hook.verify(await req.text(), Object.fromEntries(req.headers));
} catch {
return reply({ error: { http_code: 401, message: "Invalid hook signature" } }, 401);
}
const { user, sms, metadata } = event;
const to = e164(sms.phone ?? user.phone);
// Never refuse a multi-factor code, or a confirmed number signing in again.
const isNewNumber = sms.sms_type !== "mfa" && (!user.phone_confirmed_at || e164(user.phone) !== to);
const answer = await checkNumber(to, metadata?.ip_address);
// null means Spaw did not answer in time: the code goes out unchecked.
if (answer && !answer.ok_to_send && answer.blocked_by !== "opted_out") {
console.log(JSON.stringify({ spaw_blocked_by: answer.blocked_by, new_number: isNewNumber }));
if (isNewNumber) {
return reply({ error: { http_code: 400, message: "This number cannot receive a verification code." } });
}
}
const sent = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${twilioSid}/Messages.json`, {
method: "POST",
headers: {
Authorization: "Basic " + btoa(`${twilioSid}:${twilioToken}`),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
To: to,
From: twilioFrom,
Body: `Your code is ${sms.otp}`,
StatusCallback: `https://spaw.co/api/v1/phone/feedback/twilio/${spawFeedbackKey}`,
}),
});
if (!sent.ok) {
return reply({ error: { http_code: 502, message: "The SMS provider did not accept the message." } });
}
return reply({});
});
The request to Spaw carries the number and the visitor's address and nothing else: never the code, the message or anything from the user record. The code goes only to your SMS provider. The sample follows Supabase's documented payload; run it in your own project before you rely on it.
The refusal is returned with status 200 on purpose. Supabase Auth reads the error object in a 200 answer and returns its http_code and message to your app, while a bare 400 or 403 from an HTTP hook reaches the app as a 500, as Supabase's hooks page says. Your app's signInWithOtp() or updateUser() call receives the error, and the screen can say that this number cannot receive a code and offer another way in.
Which codes it refuses, and which it only logs
The payload names one flow: sms.sms_type is "mfa" for a multi-factor code, at enrolment and at every later sign-in alike, and is absent on the phone sign-up, sign-in, phone-change and reauthentication codes (Supabase Auth source, read 27 September 2026). For those the sample decides from the user record. A number with no phone_confirmed_at, or one that differs from the confirmed user.phone (a phone change sends the code to the new number), is being attached to the account: those are refused. A confirmed number signing in again is only logged, because refusing it would lock an existing customer out. A multi-factor code is only logged too, enrolment included, since the payload cannot tell the two apart: user.phone says nothing about an enrolled factor, and Supabase answers a refused MFA challenge with a bare 500 rather than your message.
A refusal names the rule in blocked_by; the gate-SMS guide explains each rule and the order they apply in. With the visitor's address, five distinct numbers from one address within 60 minutes add numbers_per_client (+30). An address Spaw's IP data places on a mobile carrier or a corporate proxy, where many people share one address, still answers the count but never adds the signal. Five distinct numbers in one 1,000-number range within 60 minutes add range_burst (+40). Either alone stays under the default ceiling of 60; the two together score 70 and answer blocked_by: "risk_score".
Supabase's own controls, CAPTCHA protection and the SMS rate limits under Authentication, Rate Limits, act on the request before the hook is called. Keep them on; the Spaw check acts on the number.
Why it fails open
When Spaw does not answer within two seconds, or answers with an error (402 INSUFFICIENT_CREDITS on an empty balance, 429 when the key's daily cap is spent or your phone requests exceed 50 a second, a 5xx), checkNumber() returns null and the code is sent unchecked. Failing closed would turn a Spaw outage into an outage of your sign-in. The price is that an attack arriving during one of those failures is not filtered; log the nulls if you want to alert on them.
Delivery receipts and verified codes
The StatusCallback in the sample points Twilio's delivery receipts at your SMS delivery-receipt URL. Spaw reads delivered, undelivered and failed, classifies the error code, and records an opt-out code such as Twilio 21610 as opted_out; receipts are free and feed only your own later lookups.
The outcome that separates real people from pumped traffic is verified: the person entered the code. Report it from server code that runs after supabase.auth.verifyOtp() returns a session, with the number in E.164 form (the + again: a number without it is skipped, not recorded):
curl -X POST https://spaw.co/api/v1/phone/feedback \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"items": [{"phone": "+12025550100", "outcome": "verified"}]}'
The feedback summary then reads verified_share, the share of numbers you reported delivered or verified that were verified, overall and for each of the ten blocks with the most failed deliveries. Pumped traffic is delivered and never verified, so a block whose share sits far under yours is the one to look at.
Try the branches without Supabase
The documented test numbers answer a fixed result each, cost nothing and are never counted in your traffic. Under the default policy +1 202 555 0103 answers ok_to_send: false with blocked_by: "disposable", +1 202 555 0105 answers blocked_by: "fictional", and +1 202 555 0102 (the test number that answers the virtual signal, score 40) and +1 202 555 0100 answer ok_to_send: true. Post them to https://spaw.co/api/v1/phone with curl to see each branch before the hook is live.
What to leave out
Leave hlr out of the request. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. Leave the policy's require_reachable rule off for the same reason: while the check is not enabled, no lookup answers ok_to_send: true under it: every number that passes the other rules answers blocked_by: "unconfirmed".
ok_to_send is a fraud and deliverability decision. It is not consent and not a do-not-call scrub, and whether the person agreed to hear from you stays yours to record.
Cost
A lookup costs 1 credit when the number is valid. Invalid numbers, the test numbers and a repeat of the same number within seven days are free, so a resent code costs nothing. Delivery receipts and outcome reports are free. The SMS itself is billed by your provider. Spaw is not affiliated with Supabase.