Send Better Auth phone codes through TextKit

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 and copy one from the console. Make it a restricted key with only the messages:send scope (scopes): 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). 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 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. 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).
  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).

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:

More in integrations