From 1aedc57fd25e8e5e73dbcd04cee150b62517f035 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 07:42:58 +0000 Subject: [PATCH] SMS codes via Telnyx Verify (carriers block our long code: 40010) 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 --- src/app/api/auth/hooks/send-sms/route.js | 6 ++-- src/lib/auth/sms-hook.js | 35 ++++++++++++++++++++++-- tests/auth/sms-hook.test.js | 21 ++++++++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/api/auth/hooks/send-sms/route.js b/src/app/api/auth/hooks/send-sms/route.js index 251e8f92..02ccce9a 100644 --- a/src/app/api/auth/hooks/send-sms/route.js +++ b/src/app/api/auth/hooks/send-sms/route.js @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server'; -import { e164, sendViaTelnyx, smsText, verifyHook } from '@/lib/auth/sms-hook.js'; +import { e164, sendCode, verifyHook } from '@/lib/auth/sms-hook.js'; /** * POST /api/auth/hooks/send-sms: Supabase Auth's Send SMS hook. * GoTrue calls this with { user, sms: { otp } } for every phone code it issues; - * we deliver it through Telnyx. Errors use the hook error shape so GoTrue + * we deliver it through Telnyx Verify (see sms-hook.js for why not a plain SMS). Errors use the hook error shape so GoTrue * passes a useful message back to the sign-in form. */ const fail = (status, message) => NextResponse.json({ error: { http_code: status, message } }, { status }); @@ -36,7 +36,7 @@ export async function POST(request) { if (!to || !otp) return fail(400, 'Missing phone or code'); try { - await sendViaTelnyx({ to, text: smsText(otp) }); + await sendCode({ to, otp }); return NextResponse.json({}); } catch (err) { console.error('[send-sms hook]', err?.message); diff --git a/src/lib/auth/sms-hook.js b/src/lib/auth/sms-hook.js index a43f1f55..d4fda581 100644 --- a/src/lib/auth/sms-hook.js +++ b/src/lib/auth/sms-hook.js @@ -3,7 +3,13 @@ * * Supabase Auth on dev2 has no working SMS provider (the Twilio account it * was set up with now answers 401), so GoTrue hands each code to - * POST /api/auth/hooks/send-sms instead and we send it. The request is signed + * POST /api/auth/hooks/send-sms instead and we send it. + * + * Delivery is Telnyx **Verify** with `custom_code` (GoTrue's own code), sent + * from Telnyx's registered verification senders. A plain message from one of + * our long codes is refused by US carriers (error 40010, "not 10DLC + * registered"): it reaches a Telnyx number but never an AT&T phone. The plain + * message path stays only as a fallback when no Verify profile is configured. The request is signed * with the Standard Webhooks scheme: `webhook-id`, `webhook-timestamp` and * `webhook-signature: v1,`, keyed * by the secret GoTrue holds as `v1,whsec_`. @@ -44,7 +50,32 @@ export function smsText(otp) { return `Your qrypt.chat code is ${otp}. It expires in 5 minutes. Never share it.`; } -/** Send one SMS through Telnyx. Throws with the provider's message on failure. */ +/** + * Send a sign-in code. Uses Telnyx Verify when TELNYX_VERIFY_PROFILE_ID is set + * (the path that reaches real phones), else a plain message. + */ +export async function sendCode({ to, otp }, { profileId = process.env.TELNYX_VERIFY_PROFILE_ID, ...opts } = {}) { + if (profileId) return sendViaTelnyxVerify({ to, otp }, { profileId, ...opts }); + return sendViaTelnyx({ to, text: smsText(otp) }, opts); +} + +/** Telnyx Verify with our own code: Telnyx picks a registered sender and the template. */ +export async function sendViaTelnyxVerify({ to, otp }, { apiKey = process.env.TELNYX_API_KEY, profileId, fetchImpl = fetch } = {}) { + if (!apiKey) throw new Error('TELNYX_API_KEY is not set'); + const res = await fetchImpl('https://api.telnyx.com/v2/verifications/sms', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone_number: to, verify_profile_id: profileId, custom_code: String(otp) }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = data?.errors?.[0]?.detail || data?.errors?.[0]?.title || `HTTP ${res.status}`; + throw new Error(`Telnyx Verify: ${detail}`); + } + return data?.data?.id ?? null; +} + +/** Send one plain SMS through Telnyx. Throws with the provider's message on failure. */ 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 } = {}) { if (!apiKey) throw new Error('TELNYX_API_KEY is not set'); if (!from && !profile) throw new Error('TELNYX_SMS_FROM is not set'); diff --git a/tests/auth/sms-hook.test.js b/tests/auth/sms-hook.test.js index 306f4bbe..9b1ed9ac 100644 --- a/tests/auth/sms-hook.test.js +++ b/tests/auth/sms-hook.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createHmac, randomBytes } from 'node:crypto'; -import { verifyHook, e164, sendViaTelnyx, smsText } from '../../src/lib/auth/sms-hook.js'; +import { verifyHook, e164, sendViaTelnyx, sendCode, smsText } from '../../src/lib/auth/sms-hook.js'; const key = randomBytes(32); const SECRET = `v1,whsec_${key.toString('base64')}`; @@ -46,6 +46,21 @@ describe('Telnyx delivery', () => { expect(JSON.parse(init.body)).toEqual({ to: '+15550001111', from: '+14084269127', text: smsText('424242') }); }); + it('sends through Telnyx Verify with our own code when a profile is set', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'v1' } }), { status: 200 })); + const id = await sendCode({ to: '+15550001111', otp: '424242' }, { apiKey: 'k', profileId: 'prof', fetchImpl }); + expect(id).toBe('v1'); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/verifications/sms'); + expect(JSON.parse(init.body)).toEqual({ phone_number: '+15550001111', verify_profile_id: 'prof', custom_code: '424242' }); + }); + + it('falls back to a plain message only without a Verify profile', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'm' } }), { status: 200 })); + await sendCode({ to: '+15550001111', otp: '1' }, { apiKey: 'k', profileId: '', from: '+14084269127', fetchImpl }); + expect(fetchImpl.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/messages'); + }); + it("surfaces Telnyx's error detail", async () => { const fetchImpl = async () => new Response(JSON.stringify({ errors: [{ detail: 'Invalid destination' }] }), { status: 422 }); 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', () => { process.env.SEND_SMS_HOOK_SECRET = SECRET; process.env.TELNYX_API_KEY = 'k'; process.env.TELNYX_SMS_FROM = '+14084269127'; + process.env.TELNYX_VERIFY_PROFILE_ID = 'prof'; }); afterEach(() => { process.env = { ...env }; @@ -84,7 +100,8 @@ describe('POST /api/auth/hooks/send-sms', () => { const s = sign(body); const res = await call(body, { 'webhook-id': s.id, 'webhook-timestamp': s.timestamp, 'webhook-signature': s.signature }); expect(res.status).toBe(200); - expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ to: '+15550001111', text: smsText('654321') }); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/verifications/sms'); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ phone_number: '+15550001111', custom_code: '654321' }); }); it('reports a Telnyx failure in the hook error shape', async () => {