# Send Clerk SMS codes through TextKit

Source: https://jointextkit.com/integrations/clerk
Index: https://jointextkit.com/llms.txt

Clerk can stop sending a text template itself and hand it to you. Turn **Delivered by Clerk** off for a template and, instead of sending that text, Clerk fires an `sms.created` webhook with the number and the finished message. A route in your app checks the webhook's signature and asks TextKit to send the text. Clerk still creates and checks every code.

## Before you start

- A Clerk application with phone sign-in or SMS multi-factor turned on. Clerk requires a paid plan for these in production; they are free in development instances.
- A public route in your app for the webhook. This guide uses Next.js and `app/api/webhooks/clerk-sms/route.ts`. By default `clerkMiddleware()` protects no routes; if you added auth checks to your middleware, exclude this one.
- 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. Turn off Delivered by Clerk

In the Clerk Dashboard, open the **SMS** page under Customization. Open each code template you want TextKit to send and toggle **Delivered by Clerk** off. The setting is per template: a template you leave on is still sent by Clerk, and if its event reaches your route it arrives with `delivered_by_clerk: true`, which the route skips.

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. Add the webhook endpoint

On the **Webhooks** page, select **Add Endpoint**, enter `https://<your-app>/api/webhooks/clerk-sms` and subscribe it to `sms.created`. Copy the endpoint's **Signing Secret** into `CLERK_WEBHOOK_SIGNING_SECRET`, and your test key into `TEXTKIT_API_KEY`.

## 3. The route

```typescript
import { verifyWebhook } from '@clerk/nextjs/webhooks';
import { TextKit, TextKitError } from 'jointextkit';

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

export async function POST(req: Request) {
  let evt;
  try {
    evt = await verifyWebhook(req); // signing secret from CLERK_WEBHOOK_SIGNING_SECRET
  } catch {
    return new Response('Webhook verification failed', { status: 400 });
  }
  if (evt.type !== 'sms.created' || evt.data.delivered_by_clerk) {
    return new Response('Ignored', { status: 200 });
  }

  try {
    await textkit.messages.send(
      { to: evt.data.to_phone_number, body: evt.data.message, category: 'otp' },
      // Every retry of one event carries the same svix-id: one key, one text.
      { idempotencyKey: req.headers.get('svix-id') ?? evt.data.id },
    );
  } catch (err) {
    // A refusal (opted out, a number outside the US and Canada) will not change on a retry.
    if (err instanceof TextKitError && err.status < 500 && err.status !== 429) {
      console.error('TextKit refused the code', err.code, err.message);
      return new Response('Refused', { status: 200 });
    }
    throw err; // anything else answers 500, and Clerk retries
  }
  return new Response('Sent', { status: 200 });
}
```

The event's `data` carries the number in `to_phone_number` and the rendered text, code included, in `message`. Clerk retries an event until your endpoint answers with a 2xx, and every attempt carries the same `svix-id` header. The route passes that id to TextKit as the idempotency key, which TextKit keeps for 24 hours, so a retried event never texts a second code. A refusal answers 200 so that Clerk stops retrying something that cannot succeed, and the reason goes to your logs.

## 4. Test in the sandbox

With the test key in place, sign in to your development instance with a phone number 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.

## One caveat from Clerk's docs

Clerk calls its webhooks asynchronous: an event is "not guaranteed to be delivered immediately or at all", and Clerk advises against relying on one in a flow that waits for it. Sign-in by code is such a flow. Keep the route fast, answer quickly, and give users a way to ask for a new code.

Clerk's SMS allowlist enables only the US and Canada by default, which matches where TextKit sends.

## Go live

1. **Swap in a live key** (`ssms_sk_live_...`) in your production environment's `TEXTKIT_API_KEY`, and add the endpoint on your production instance's Webhooks page. 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:

- [Clerk: Delivered by Clerk (email and SMS templates)](https://clerk.com/docs/guides/customizing-clerk/email-sms-templates)
- [Clerk: Webhooks overview](https://clerk.com/docs/guides/development/webhooks/overview)
- [Clerk: Sync data with webhooks (Next.js route, public route)](https://clerk.com/docs/guides/development/webhooks/syncing)
- [Clerk: verifyWebhook()](https://clerk.com/docs/reference/backend/verify-webhook)
- [Clerk: Sign-up and sign-in options (phone, allowlist, plan)](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options)
- [Clerk backend SDK: SMSMessageJSON type](https://github.com/clerk/javascript/blob/main/packages/backend/src/api/resources/JSON.ts)
