Skip to content

Commit 8926ffd

Browse files
ralyodioclaude
andauthored
SMS codes via Telnyx Verify (carriers block our long code: 40010) (#275)
A plain SMS from +14084269127 reaches a Telnyx number but AT&T refuses it (40010, not 10DLC registered), so real users got nothing. The send-sms hook now sends GoTrue's code through Telnyx Verify (custom_code) from Telnyx's registered senders when TELNYX_VERIFY_PROFILE_ID is set; the plain message stays only as a fallback. Verified to an AT&T phone 2026-10-06 07:41. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1a69e83 commit 8926ffd

3 files changed

Lines changed: 55 additions & 7 deletions

File tree

‎src/app/api/auth/hooks/send-sms/route.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { NextResponse } from 'next/server';
2-
import { e164, sendViaTelnyx, smsText, verifyHook } from '@/lib/auth/sms-hook.js';
2+
import { e164, sendCode, verifyHook } from '@/lib/auth/sms-hook.js';
33

44
/**
55
* POST /api/auth/hooks/send-sms: Supabase Auth's Send SMS hook.
66
* GoTrue calls this with { user, sms: { otp } } for every phone code it issues;
7-
* we deliver it through Telnyx. Errors use the hook error shape so GoTrue
7+
* we deliver it through Telnyx Verify (see sms-hook.js for why not a plain SMS). Errors use the hook error shape so GoTrue
88
* passes a useful message back to the sign-in form.
99
*/
1010
const fail = (status, message) => NextResponse.json({ error: { http_code: status, message } }, { status });
@@ -36,7 +36,7 @@ export async function POST(request) {
3636
if (!to || !otp) return fail(400, 'Missing phone or code');
3737

3838
try {
39-
await sendViaTelnyx({ to, text: smsText(otp) });
39+
await sendCode({ to, otp });
4040
return NextResponse.json({});
4141
} catch (err) {
4242
console.error('[send-sms hook]', err?.message);

‎src/lib/auth/sms-hook.js‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
*
44
* Supabase Auth on dev2 has no working SMS provider (the Twilio account it
55
* was set up with now answers 401), so GoTrue hands each code to
6-
* POST /api/auth/hooks/send-sms instead and we send it. The request is signed
6+
* POST /api/auth/hooks/send-sms instead and we send it.
7+
*
8+
* Delivery is Telnyx **Verify** with `custom_code` (GoTrue's own code), sent
9+
* from Telnyx's registered verification senders. A plain message from one of
10+
* our long codes is refused by US carriers (error 40010, "not 10DLC
11+
* registered"): it reaches a Telnyx number but never an AT&T phone. The plain
12+
* message path stays only as a fallback when no Verify profile is configured. The request is signed
713
* with the Standard Webhooks scheme: `webhook-id`, `webhook-timestamp` and
814
* `webhook-signature: v1,<base64 HMAC-SHA256 of "id.timestamp.body">`, keyed
915
* by the secret GoTrue holds as `v1,whsec_<base64>`.
@@ -44,7 +50,32 @@ export function smsText(otp) {
4450
return `Your qrypt.chat code is ${otp}. It expires in 5 minutes. Never share it.`;
4551
}
4652

47-
/** Send one SMS through Telnyx. Throws with the provider's message on failure. */
53+
/**
54+
* Send a sign-in code. Uses Telnyx Verify when TELNYX_VERIFY_PROFILE_ID is set
55+
* (the path that reaches real phones), else a plain message.
56+
*/
57+
export async function sendCode({ to, otp }, { profileId = process.env.TELNYX_VERIFY_PROFILE_ID, ...opts } = {}) {
58+
if (profileId) return sendViaTelnyxVerify({ to, otp }, { profileId, ...opts });
59+
return sendViaTelnyx({ to, text: smsText(otp) }, opts);
60+
}
61+
62+
/** Telnyx Verify with our own code: Telnyx picks a registered sender and the template. */
63+
export async function sendViaTelnyxVerify({ to, otp }, { apiKey = process.env.TELNYX_API_KEY, profileId, fetchImpl = fetch } = {}) {
64+
if (!apiKey) throw new Error('TELNYX_API_KEY is not set');
65+
const res = await fetchImpl('https://api.telnyx.com/v2/verifications/sms', {
66+
method: 'POST',
67+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
68+
body: JSON.stringify({ phone_number: to, verify_profile_id: profileId, custom_code: String(otp) }),
69+
});
70+
const data = await res.json().catch(() => ({}));
71+
if (!res.ok) {
72+
const detail = data?.errors?.[0]?.detail || data?.errors?.[0]?.title || `HTTP ${res.status}`;
73+
throw new Error(`Telnyx Verify: ${detail}`);
74+
}
75+
return data?.data?.id ?? null;
76+
}
77+
78+
/** Send one plain SMS through Telnyx. Throws with the provider's message on failure. */
4879
export async function sendViaTelnyx({ to, text }, { apiKey = process.env.TELNYX_API_KEY, from = process.env.TELNYX_SMS_FROM, profile = process.env.TELNYX_MESSAGING_PROFILE_ID, fetchImpl = fetch } = {}) {
4980
if (!apiKey) throw new Error('TELNYX_API_KEY is not set');
5081
if (!from && !profile) throw new Error('TELNYX_SMS_FROM is not set');

‎tests/auth/sms-hook.test.js‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { createHmac, randomBytes } from 'node:crypto';
3-
import { verifyHook, e164, sendViaTelnyx, smsText } from '../../src/lib/auth/sms-hook.js';
3+
import { verifyHook, e164, sendViaTelnyx, sendCode, smsText } from '../../src/lib/auth/sms-hook.js';
44

55
const key = randomBytes(32);
66
const SECRET = `v1,whsec_${key.toString('base64')}`;
@@ -46,6 +46,21 @@ describe('Telnyx delivery', () => {
4646
expect(JSON.parse(init.body)).toEqual({ to: '+15550001111', from: '+14084269127', text: smsText('424242') });
4747
});
4848

49+
it('sends through Telnyx Verify with our own code when a profile is set', async () => {
50+
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'v1' } }), { status: 200 }));
51+
const id = await sendCode({ to: '+15550001111', otp: '424242' }, { apiKey: 'k', profileId: 'prof', fetchImpl });
52+
expect(id).toBe('v1');
53+
const [url, init] = fetchImpl.mock.calls[0];
54+
expect(url).toBe('https://api.telnyx.com/v2/verifications/sms');
55+
expect(JSON.parse(init.body)).toEqual({ phone_number: '+15550001111', verify_profile_id: 'prof', custom_code: '424242' });
56+
});
57+
58+
it('falls back to a plain message only without a Verify profile', async () => {
59+
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'm' } }), { status: 200 }));
60+
await sendCode({ to: '+15550001111', otp: '1' }, { apiKey: 'k', profileId: '', from: '+14084269127', fetchImpl });
61+
expect(fetchImpl.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/messages');
62+
});
63+
4964
it("surfaces Telnyx's error detail", async () => {
5065
const fetchImpl = async () => new Response(JSON.stringify({ errors: [{ detail: 'Invalid destination' }] }), { status: 422 });
5166
await expect(sendViaTelnyx({ to: '+1', text: 'x' }, { apiKey: 'k', from: '+1', fetchImpl })).rejects.toThrow('Telnyx: Invalid destination');
@@ -58,6 +73,7 @@ describe('POST /api/auth/hooks/send-sms', () => {
5873
process.env.SEND_SMS_HOOK_SECRET = SECRET;
5974
process.env.TELNYX_API_KEY = 'k';
6075
process.env.TELNYX_SMS_FROM = '+14084269127';
76+
process.env.TELNYX_VERIFY_PROFILE_ID = 'prof';
6177
});
6278
afterEach(() => {
6379
process.env = { ...env };
@@ -84,7 +100,8 @@ describe('POST /api/auth/hooks/send-sms', () => {
84100
const s = sign(body);
85101
const res = await call(body, { 'webhook-id': s.id, 'webhook-timestamp': s.timestamp, 'webhook-signature': s.signature });
86102
expect(res.status).toBe(200);
87-
expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ to: '+15550001111', text: smsText('654321') });
103+
expect(fetchSpy.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/verifications/sms');
104+
expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ phone_number: '+15550001111', custom_code: '654321' });
88105
});
89106

90107
it('reports a Telnyx failure in the hook error shape', async () => {

0 commit comments

Comments
 (0)