# Send Supabase Auth SMS codes through TextKit

Source: https://jointextkit.com/integrations/supabase-auth
Index: https://jointextkit.com/llms.txt

Supabase Auth can hand every text it would send to a hook of yours. Point its Send SMS Hook at an Edge Function and Supabase still creates and checks the one-time code; your function receives the phone number and the code, checks that the request really came from Supabase, and asks TextKit to text it. Your app keeps calling `supabase.auth` as before, and every code appears in your TextKit logs with its delivery status.

## Before you start

- A Supabase project that uses phone sign-in, and the Supabase CLI linked to it. The Send SMS Hook is available on the Free and Pro plans.
- A TextKit test key (`ssms_sk_test_...`). [Sign up](/signup) and copy one from the console. Make it a restricted key with only the `messages:send` scope ([scopes](/docs/authentication)): the key sits in another service's settings, and a key that leaks can then only send texts.

## 1. Write the function

Create it with `supabase functions new send-sms`, then replace `supabase/functions/send-sms/index.ts`:

```typescript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
import { TextKit } from 'npm:jointextkit';

// Supabase gives the whole hook 5 seconds, its own retries included:
// no SDK retries and a short timeout keep the function inside that budget.
const textkit = new TextKit(Deno.env.get('TEXTKIT_API_KEY'), { maxRetries: 0, timeout: 3000 });
const hookSecret = Deno.env.get('SEND_SMS_HOOK_SECRETS')!.replace('v1,whsec_', '');

// Every response, errors included, must be JSON, or Supabase Auth answers 500.
const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

type Hook = { user: { phone: string }; sms: { otp: string } };

Deno.serve(async (req) => {
  const payload = await req.text();
  let hook: Hook;
  try {
    hook = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)) as Hook;
  } catch {
    return json({ error: { http_code: 401, message: 'Invalid signature' } }, 401);
  }

  try {
    await textkit.messages.send({
      to: hook.user.phone,
      body: `Your Acme code is ${hook.sms.otp}`,
      category: 'otp',
    });
    return json({});
  } catch (err) {
    console.error('TextKit send failed', err);
    return json({ error: { http_code: 500, message: 'Could not send the code. Try again.' } }, 500);
  }
});
```

The hook's payload carries the number in `user.phone` and the code in `sms.otp`. Supabase sends no message text, so the function writes it. Supabase's own samples are not consistent about a leading `+` on the number; TextKit accepts a US or Canadian number either way, so the function passes it through. Supabase signs each request with the Standard Webhooks scheme, which the `standardwebhooks` package checks. The secret has the form `v1,whsec_<base64>`, and the function strips the prefix before use.

TextKit sends one-time codes at any hour, and it decides what is a code from the text's wording, not from `category`. Digits within a few words of a word that names them ("Your Acme code is 482913") count. A text that does not read as a code is held overnight like any other message ([quiet hours](/docs/compliance-checks#quiet-hours)). If you edit the template, keep the word "code" next to the digits.

## 2. Turn on the hook

In the Supabase dashboard, open **Authentication > Hooks**, add a **Send SMS** hook of type **HTTP**, and enter the function's URL, `https://<project-ref>.supabase.co/functions/v1/send-sms`. Copy the hook's secret, which starts `v1,whsec_`. Then store both secrets and deploy:

```bash
supabase secrets set TEXTKIT_API_KEY=ssms_sk_test_...
supabase secrets set SEND_SMS_HOOK_SECRETS='v1,whsec_...'
supabase functions deploy send-sms --no-verify-jwt
```

`--no-verify-jwt` is needed because the hook runs before the user has a session: the webhook signature, not a Supabase JWT, proves who is calling. To run everything on your machine instead, add the hook to `supabase/config.toml`, put the secret in `supabase/functions/.env`, and serve the function with `supabase functions serve send-sms --no-verify-jwt`:

```toml
[auth.hook.send_sms]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/send-sms"
secrets = "env(SEND_SMS_HOOK_SECRETS)"
```

## 3. Test in the sandbox

With the test key in place, call `supabase.auth.signInWithOtp({ phone })` from your app and watch the code arrive in TextKit's Logs. Finish the sign-in with `supabase.auth.verifyOtp({ phone, token, type: 'sms' })`, as with any SMS provider.

- **Your own phone.** Verify your cell in the console's settings and use it as the number. The text arrives for real and ends with "- via TextKit sandbox". Real sandbox texts are capped at 10 a day.
- **`+15005550006`.** Always ends `delivered`, and nothing reaches a phone. Any other real-looking number is simulated too: the message shows in the console's Logs as `Test · simulated`.
- **`+15005550015`.** A recipient for whom it is always night. Only a text that reads as a one-time code is delivered, so this is how you check your template's wording.
- **`+15005550011`.** Answers `403 recipient_opted_out`, which shows you what your users see when a send is refused.

See [sandbox and test numbers](/docs/sandbox) for every magic number.

## What Supabase expects back

- **5 seconds in all.** Supabase allows the whole hook invocation 5 seconds, its retries included. That is why the function sets `maxRetries: 0` and a 3 second timeout on the SDK.
- **JSON every time.** A response without `Content-Type: application/json`, an error included, becomes a 500 for your user.
- **Success** is a 200, 202 or 204. An error is `{ "error": { "http_code": ..., "message": ... } }`, and Supabase treats a 400 or 403 as a 500.

## Go live

1. **Swap in a live key** (`ssms_sk_live_...`) with `supabase secrets set TEXTKIT_API_KEY=ssms_sk_live_...`. Keep it restricted to `messages:send`.
2. **Have a number that can text anyone:** a local number on an approved 10DLC registration, or a verified toll-free number. We file the registration for you: see [compliance and registration](/docs/compliance). A registration for two-factor codes fits a sign-in flow best. With one live number, leave `from` out and TextKit sends from it. Once you have a second, add `from`.
3. **Add credits.** Free accounts text only numbers they have verified, and your first credit purchase lifts that ([credits](/docs/credits)).
4. **Send your code template early.** In your first 30 days of live sending, our team reviews each new kind of message before it goes out. Codes from a two-factor registration are exempt. Otherwise, send one code to a real person before launch so the template is approved by the time your users sign in ([review of new kinds of message](/docs/compliance-checks#review-of-new-kinds-of-message)).

TextKit sends to US and Canadian numbers only. A number from anywhere else answers `400 invalid_request`, so limit phone sign-up to those two countries.

## Sources

Provider steps were checked against these pages on 2026-10-28:

- [Supabase: Send SMS Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook)
- [Supabase: Auth Hooks (setup, limits, responses)](https://supabase.com/docs/guides/auth/auth-hooks)
