SMSTunnel for Clerk

SMS verification codes from your own phone and number

npm package · sms.created webhook
@narcisbodea/smstunnel-otp

Clerk writes the text, your phone sends it

When you turn Delivered by Clerk off for an SMS template, Clerk stops sending that text and sends you an sms.created webhook instead. This package gives you the endpoint, with the Svix (Standard Webhooks) signature check: it hands the code to your SMSTunnel account and your own Android phone sends it from its own number.

Before you start

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick Sign-in codes, name it (for example "Clerk") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and Clerk can't send texts anymore.

Get the steps with your key in them

It opens Add an app with Sign-in codes picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

Keep the key in your platform's secrets, never in code that runs in the browser. Codes must be 4 to 10 digits: the default 6-digit codes are fine; codes with letters aren't accepted.

1. Add the webhook route

Next.js: app/api/webhooks/clerk-sms/route.ts
import { createClerkSmsWebhook } from '@narcisbodea/smstunnel-otp';

export const POST = createClerkSmsWebhook({
  apiKey: process.env.SMSTUNNEL_API_KEY!,
  signingSecret: process.env.CLERK_SMS_WEBHOOK_SIGNING_SECRET!, // whsec_...
});

2. Connect it in Clerk

  1. 1. Clerk Dashboard → Webhooks → Add Endpoint → your URL, subscribe to sms.created → copy the Signing Secret.
  2. 2. Customization → SMS → open each template you want sent from your phone (Verification code, Reset password code…) → turn Delivered by Clerk off. The setting is per template.
  3. 3. Keep the route public in your Clerk middleware: webhooks carry no session.

It sends data.to_phone_number the text Clerk rendered (data.message) through the code endpoint, making sure data.data.otp_code is in it. Events with delivered_by_clerk: true and other event types get a 200 and nothing is sent. Templates without a code are sent as regular texts; pass nonCodeMessages: 'skip' to ignore them. A bad signature answers 400.

Other frameworks

handler.process({ method, headers, body }) returns { status, headers, body }. It needs the raw body, because the signature covers the exact bytes Clerk sent. With Express:

import express from 'express';
import { createClerkSmsWebhook } from '@narcisbodea/smstunnel-otp';

const clerkSms = createClerkSmsWebhook({
  apiKey: process.env.SMSTUNNEL_API_KEY,
  signingSecret: process.env.CLERK_SMS_WEBHOOK_SIGNING_SECRET,
});

const app = express();
app.post('/api/webhooks/clerk-sms', express.raw({ type: 'application/json' }), async (req, res) => {
  const out = await clerkSms.process({ method: req.method, headers: req.headers, body: req.body });
  res.status(out.status).set(out.headers).send(out.body);
});
app.listen(3000);

Worth knowing

  • Codes have their own allowance. They go through SMSTunnel's code endpoint, POST /api/v1/sms/send-2fa, which counts them apart from your other texts. See Pricing.
  • What counts as a code: 4 to 10 digits standing on their own, like 482913, or two groups of 3 digits, like 123-456. A text without one is refused. Details
  • One number per text, up to 320 characters. The package refuses a longer text before calling SMSTunnel.
  • 30 sends a minute. Past that, SMSTunnel answers 429 and asks to retry a little later.
  • Phone offline? The code waits and goes out as soon as the phone is back.
  • Nothing in the package logs codes or keys.

Build it with an AI assistant

Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.

Help me send the SMS verification codes of my Clerk app from my own phone, with SMSTunnel.

About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.

What I want: [describe what should send a text, to whom, and when]

How it works:
- When "Delivered by Clerk" is turned off for an SMS template, Clerk stops sending that text and sends an sms.created webhook instead. The npm package @narcisbodea/smstunnel-otp gives the endpoint, with the Svix (Standard Webhooks) signature check.
- Next.js, app/api/webhooks/clerk-sms/route.ts:
import { createClerkSmsWebhook } from '@narcisbodea/smstunnel-otp';

export const POST = createClerkSmsWebhook({
  apiKey: process.env.SMSTUNNEL_API_KEY!,
  signingSecret: process.env.CLERK_SMS_WEBHOOK_SIGNING_SECRET!, // whsec_...
});
- Other frameworks: handler.process({ method, headers, body }) returns { status, headers, body } and needs the raw body (in Express: express.raw({ type: 'application/json' })).
- Clerk Dashboard → Webhooks → Add Endpoint → my URL, subscribe to sms.created → copy the Signing Secret. Then Customization → SMS → open each template I want sent from my phone (Verification code, Reset password code...) → turn "Delivered by Clerk" off. The setting is per template.
- Keep the webhook route public in the Clerk middleware: webhooks carry no session.
- It sends data.to_phone_number the text Clerk rendered (data.message), making sure data.data.otp_code is in it. Events with delivered_by_clerk: true and other event types get a 200 and nothing is sent. Templates without a code are sent as regular texts (nonCodeMessages: 'skip' ignores them). A bad signature answers 400.
- Codes go through SMSTunnel's POST https://smstunnel.io/api/v1/sms/send-2fa: the text must contain the code as 4 to 10 digits and can be up to 320 characters. Codes have their own daily allowance. If the phone is offline, the code waits and goes out when it is back.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.

Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts.

Clerk is a trademark of its owner. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Clerk.

Sign-in codes for other platforms

Another platform? sendCode() from the same package sends any code: see its README.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play