# Send Better Auth phone codes through TextKit

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

Better Auth's `phoneNumber` plugin creates, stores and checks one-time codes, and leaves the sending to a function you write: `sendOTP`. Fill it with one TextKit call and your app has phone sign-in and phone verification. There is no webhook and no signature to check: the function runs inside your server, next to the rest of your Better Auth config.

## Before you start

- A Better Auth server. Add the plugin, then run `npx auth migrate` to add the phone number columns to your user table.
- The SDK: `npm install jointextkit`.
- 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. Store it as `TEXTKIT_API_KEY`.

## 1. The server

```typescript
import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';
import { TextKit } from 'jointextkit';

const textkit = new TextKit(process.env.TEXTKIT_API_KEY);

export const auth = betterAuth({
  plugins: [
    phoneNumber({
      sendOTP: ({ phoneNumber, code }) => {
        // Not awaited, as Better Auth recommends: waiting slows the request
        // and can open a timing attack.
        textkit.messages
          .send({ to: phoneNumber, body: `Your Acme code is ${code}`, category: 'otp' })
          .catch((err) => console.error('TextKit send failed', err));
      },
    }),
  ],
});
```

`sendOTP` receives `phoneNumber` and `code`. The code is 6 digits and lasts 300 seconds unless you set `otpLength` or `expiresIn`, and a user gets 3 tries (`allowedAttempts`). Better Auth's docs describe no reformatting of the number, so expect it as your form sent it. TextKit accepts a US or Canadian number with or without the `+1`; to refuse anything else before a code is made, pass a `phoneNumberValidator`.

The send is not awaited, so a failure never reaches the user's request. The `.catch` logs it instead, and TextKit's console logs every refusal with its reason. On a serverless platform the function can stop before the text goes out; Better Auth's docs suggest passing the promise to your platform's `waitUntil` there.

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. The client

```typescript
import { createAuthClient } from 'better-auth/client';
import { phoneNumberClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({ plugins: [phoneNumberClient()] });

await authClient.phoneNumber.sendOtp({ phoneNumber: '+14155550132' });
await authClient.phoneNumber.verify({ phoneNumber: '+14155550132', code: '123456' });
```

`sendOtp` makes Better Auth create a code and call your `sendOTP`. `verify` checks what the user typed. That is the whole flow.

## 3. Test in the sandbox

With the test key in place, call `authClient.phoneNumber.sendOtp` and check the code in TextKit's Logs.

- **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.

Because the send is not awaited, a refused send does not show up in your app's response. Watch your server log or TextKit's Logs while you test.

## Go live

1. **Swap in a live key** (`ssms_sk_live_...`) in your production environment's `TEXTKIT_API_KEY`. 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:

- [Better Auth: Phone Number plugin](https://www.better-auth.com/docs/plugins/phone-number)
