Send Clerk SMS codes through TextKit
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 defaultclerkMiddleware()protects no routes; if you added auth checks to your middleware, exclude this one. - A TextKit test key (
ssms_sk_test_...). Sign up and copy one from the console. Make it a restricted key with only themessages:sendscope (scopes): 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). 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
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 endsdelivered, and nothing reaches a phone. Any other real-looking number is simulated too: the message shows in the console's Logs asTest · 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. Answers403 recipient_opted_out, which shows you what your users see when a send is refused.
See sandbox and test numbers 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
- Swap in a live key (
ssms_sk_live_...) in your production environment'sTEXTKIT_API_KEY, and add the endpoint on your production instance's Webhooks page. Keep it restricted tomessages:send. - 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
fromout and TextKit sends from it. Once you have a second, addfrom. - Add credits. Free accounts text only numbers they have verified, and your first credit purchase lifts that (credits).
- 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: