# Check phone numbers in the Supabase Send SMS Hook with Spaw

A Send SMS Hook on an Edge Function that asks Spaw about the number, refuses codes your SMS policy would not send, then sends the rest itself.

Updated: 2026-09-27

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](/docs/phone-intelligence#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`](/docs/signals/phone/numbers_per_client) to all of them, while a private or reserved address is counted but never adds the signal.

## Before you start

1. 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.
2. 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:

```bash
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.

3. Set the secrets: `supabase secrets set SPAW_KEY=sk_live_… SPAW_FEEDBACK_KEY=fb_… TWILIO_ACCOUNT_SID=… TWILIO_AUTH_TOKEN=… TWILIO_PHONE_NUMBER=…`, and `SEND_SMS_HOOK_SECRET` once the hook has generated one.
4. 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.
5. 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.

```javascript
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

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](/guides/gate-sms-verification-codes-with-the-sms-policy) 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](/docs/api/sms-status-webhook). 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):

```bash
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](/docs/api/phone-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.

Reference: https://spaw.co/integrations/supabase-auth
