Telnyx Integration
SMS and Voice communication via Telnyx APIs
Telnyx Integration
SavvySolve uses Telnyx for all SMS and voice communication with customers. Telnyx provides reliable, programmable APIs for messaging and voice, including WebRTC support for browser-based calling.
Overview
Telnyx handles two key communication channels:
- SMS - Notifications, session links, payment reminders
- Voice - Browser-based WebRTC calling and phone bridge calling
Calling Modes
Solvers can choose their preferred calling mode in settings:
| Mode | Description | How It Works |
|---|---|---|
| In-App (WebRTC) | Browser-based calling | Solver talks through computer mic/speakers |
| Bridge | Phone-to-phone | System calls solver's phone, then bridges to customer |
In-app calling is the default and recommended mode. It doesn't require solvers to provide their personal phone number and keeps all communication within the platform.
Setup
Create a Telnyx Account
Go to portal.telnyx.com and create an account. Complete the verification process.
Get Your API Key
- Navigate to API Keys in the portal
- Create a new API key
- Copy the key (starts with
KEY...)
TELNYX_API_KEY="KEYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Get a Phone Number
- Go to Numbers → Search & Buy
- Purchase a number with SMS and Voice capabilities
- Note the number in E.164 format
TELNYX_PHONE_NUMBER="+15551234567"Create a Call Control Application
- Go to Voice → Programmable Voice
- Click Create Voice App
- Fill in the app name and webhook URL:
https://your-domain.com/api/webhooks/telnyx/voice - After creation, copy the Application ID from the app settings
- Create an Outbound Voice Profile (Voice → Outbound Voice Profiles) and add your app to it
TELNYX_APP_ID="1234567890123456789"Create an Outbound Voice Profile
Required for making outbound calls:
- Go to Voice → Outbound Voice Profiles
- Click Create new profile
- Name it (e.g., "SavvySolve Outbound")
- Service Plan: Metered
- Traffic Type: Conversational
- Enable destinations you need (US/Canada by default)
- Save and note the Profile ID
Configure WebRTC (Optional)
For browser-based calling, you need a Credential Connection. This uses "Credentials" authentication type (not IP), which allows dynamic credential creation via API.
- Go to Voice → SIP Trunking (left sidebar)
- You'll see the SIP Connections page
- Click Create SIP Connection (top right)
- Select Type: Credentials (NOT IP Authentication)
- Name: "SavvySolve" (or whatever you prefer)
- Outbound: Select your Outbound Voice Profile
- Save and copy the Connection ID from the connection details
TELNYX_CREDENTIAL_CONNECTION_ID="1234567890123456789"Individual solver credentials are created automatically via the Telnyx API when needed. You only set up the Credential Connection once - the system handles the rest.
Architecture
Client Configuration
The Telnyx client is configured as a singleton in lib/telnyx/client.ts:
import Telnyx from "telnyx";
let telnyxClient: Telnyx | null = null;
export function getTelnyxClient(): Telnyx {
if (!telnyxClient) {
const apiKey = process.env.TELNYX_API_KEY;
if (!apiKey) {
throw new Error("TELNYX_API_KEY is required");
}
telnyxClient = new Telnyx({ apiKey });
}
return telnyxClient;
}SMS
SMS messages are sent using the Messages API:
export async function sendSms(
to: string,
body: string,
sessionId?: string
): Promise<SendSmsResult> {
const telnyx = getTelnyxClient();
const from = getTelnyxPhoneNumber();
const response = await telnyx.messages.send({
from,
to: normalizePhoneNumber(to),
text: body,
});
// Log to database...
return { success: true, messageId: response.data?.id };
}Convenience functions are provided for common messages:
sendSessionLink(to, sessionId, customerName)- Send session join linksendPaymentLink(to, sessionId, amount, paymentUrl)- Send payment linksendMeetLink(to, sessionId, meetLink, solverName)- Send video meeting link
Voice - In-App (WebRTC)
For browser-based calling, solvers use the @telnyx/webrtc SDK on the frontend. Credentials are created dynamically per-solver via the Telnyx Telephony Credentials API.
// lib/telnyx/voice.ts
export async function getWebRTCCredentialsForSolver(
solverId: string,
solverName: string,
): Promise<WebRTCCredentials | null> {
const connectionId = getTelnyxCredentialConnectionId();
if (!connectionId) return null;
// Create a telephony credential for this solver
const credential = await createTelephonyCredential(`solver-${solverId}`);
if (!credential) return null;
// Generate a JWT token for authentication
const token = await createWebRTCToken(credential.id);
return {
token: token || undefined,
sipUsername: credential.sipUsername,
sipPassword: credential.sipPassword,
credentialId: credential.id,
callerIdNumber: getTelnyxPhoneNumber(),
callerIdName: solverName,
};
}On the frontend, the solver uses these credentials with the Telnyx WebRTC SDK:
import { TelnyxRTC } from '@telnyx/webrtc';
// Get credentials from backend
const credentials = await trpc.calls.getWebRTCCredentials.query();
// Initialize client with JWT token (preferred) or SIP credentials (fallback)
const client = new TelnyxRTC({
login_token: credentials.token, // JWT token - more secure
// Or use SIP credentials as fallback:
// login: credentials.sipUsername,
// password: credentials.sipPassword,
});
client.connect();
// Make a call
const call = client.newCall({
destinationNumber: '+15551234567',
callerNumber: credentials.callerIdNumber,
callerName: credentials.callerIdName,
});
// Handle events
call.on('active', () => console.log('Call connected'));
call.on('hangup', () => console.log('Call ended'));WebRTC Call Logging
WebRTC calls are initiated directly from the browser, but we still need to log them to the database for session records. The logWebRTCCall procedure handles this without triggering any Telnyx API calls:
logWebRTCCall: protectedProcedure
.input(z.object({
sessionId: z.string().uuid(),
to: z.string().min(10),
callControlId: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
// Logs call to database WITHOUT dialing via Telnyx API
// The browser's WebRTC SDK handles the actual call
await db.insert(callLogs).values({
callId: input.callControlId || `webrtc-${Date.now()}`,
sessionId: input.sessionId,
to: input.to,
from: getTelnyxPhoneNumber(),
direction: "outbound",
status: "initiated",
});
return { success: true, mode: "in_app" as const };
}),This separation is important because the initiate procedure is used for bridge mode where the server dials via Telnyx Call Control API, while logWebRTCCall simply records that a WebRTC call was placed.
Audio Level Visualization
The useVoiceCall hook provides real-time audio level monitoring using the Web Audio API:
export interface AudioLevels {
/** Local audio level (0-1) */
local: number;
/** Remote audio level (0-1) */
remote: number;
}
// Setup audio analysis for visualization
const setupAudioAnalyser = (stream: MediaStream, type: "local" | "remote") => {
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
return analyser;
};
// Monitor audio levels every 100ms
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray);
const sum = dataArray.reduce((a, b) => a + b, 0);
const level = Math.min(1, sum / (dataArray.length * 128));The audio levels are exposed via the hook's return value and can be displayed using the AudioLevelIndicator component in CallControls.
Voice - Bridge Mode
For phone-to-phone calling (solver's phone ↔ customer's phone), the backend initiates calls using the Call Control API:
export async function initiateBridgeCall(
solverPhone: string,
customerPhone: string,
sessionId: string
): Promise<BridgeCallResult> {
const telnyx = getTelnyxClient();
const from = getTelnyxPhoneNumber();
const appId = getTelnyxAppId(); // Call Control Application ID
// Call the solver first
const solverResponse = await telnyx.calls.dial({
connection_id: appId,
from,
to: solverPhone,
timeout_secs: 30,
// Store customer info for bridging after answer
client_state: Buffer.from(JSON.stringify({
type: "bridge",
customerPhone,
sessionId,
})).toString("base64"),
});
return {
success: true,
solverCallControlId: solverResponse.data?.call_control_id,
callSessionId: solverResponse.data?.call_session_id,
};
}When the solver answers, the webhook triggers bridging to the customer:
export async function bridgeCallToNumber(
callControlId: string,
toNumber: string,
sessionId: string
): Promise<CallResult> {
const telnyx = getTelnyxClient();
// Dial customer and bridge to solver's call
const response = await telnyx.calls.dial({
connection_id: getTelnyxAppId(),
from: getTelnyxPhoneNumber(),
to: toNumber,
link_to: callControlId,
bridge_on_answer: true,
});
return {
success: true,
callControlId: response.data?.call_control_id,
};
}Webhooks
SavvySolve uses a unified webhook endpoint for all Telnyx events. Configure this URL in your Telnyx portal under both your Voice App settings and Messaging settings:
https://your-domain.com/api/webhooks/telnyxUnified Webhook Handler
The webhook handler routes events based on record type:
export async function POST(request: NextRequest) {
const body = await request.json() as TelnyxWebhookPayload;
const { event_type, payload, record_type } = body.data || {};
// Route to appropriate handler based on record type or event prefix
if (record_type === "message" || event_type.startsWith("message.")) {
await handleSmsEvent(event_type, payload);
} else if (record_type === "event" || event_type.startsWith("call.")) {
await handleCallEvent(event_type, payload);
}
// Always return 200 to acknowledge receipt
return NextResponse.json({ received: true });
}SMS Events
The handler processes SMS delivery status updates:
async function handleSmsEvent(eventType: string, payload: Record<string, unknown>) {
const messageId = payload.id as string;
switch (eventType) {
case "message.sent":
await updateSmsStatus(messageId, "sent");
break;
case "message.finalized":
const to = payload.to as Array<{ status: string }>;
const status = to?.[0]?.status || "unknown";
await updateSmsStatus(messageId, status);
break;
case "message.delivery.failed":
const errors = payload.errors as Array<{ description: string }>;
const errorMessage = errors?.[0]?.description || "Delivery failed";
await updateSmsStatus(messageId, "failed", errorMessage);
break;
}
}Voice Events
Call events update the call log and handle bridge connections:
async function handleCallEvent(eventType: string, payload: Record<string, unknown>) {
const callControlId = payload.call_control_id as string;
switch (eventType) {
case "call.initiated":
await updateCallStatus(callControlId, "initiated");
break;
case "call.answered":
await updateCallStatus(callControlId, "answered");
// For bridge mode, this is where we'd bridge to customer
break;
case "call.hangup":
const duration = payload.duration_secs as number;
await updateCallStatus(callControlId, "completed", duration);
break;
case "call.bridged":
await updateCallStatus(callControlId, "bridged");
break;
}
}Database Schema
SMS Logs
export const smsLogs = pgTable("sms_logs", {
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id").references(() => sessions.id),
messageId: varchar("message_id", { length: 255 }).notNull(),
from: varchar("from", { length: 50 }).notNull(),
to: varchar("to", { length: 50 }).notNull(),
direction: smsDirectionEnum("direction").notNull(),
body: text("body").notNull(),
status: varchar("status", { length: 50 }).notNull().default("sent"),
errorMessage: varchar("error_message", { length: 500 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
});Call Logs
export const callLogs = pgTable("call_logs", {
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id").references(() => sessions.id),
callId: varchar("call_id", { length: 255 }).notNull(),
from: varchar("from", { length: 50 }).notNull(),
to: varchar("to", { length: 50 }).notNull(),
direction: varchar("direction", { length: 20 }).notNull(),
status: varchar("status", { length: 50 }).notNull().default("initiated"),
duration: integer("duration"),
recordingUrl: varchar("recording_url", { length: 1024 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
});Solver Call Preference
export const solvers = pgTable("solvers", {
// ... other fields
callPreference: callPreferenceEnum("call_preference")
.notNull()
.default("in_app"),
});
// Enum values: "in_app" | "bridge"Environment Variables
| Variable | Description | Required |
|---|---|---|
TELNYX_API_KEY | API key from Telnyx portal | Yes |
TELNYX_PHONE_NUMBER | Your Telnyx number (E.164) | Yes |
TELNYX_APP_ID | Call Control Application ID | Yes (for bridge calls) |
TELNYX_CREDENTIAL_CONNECTION_ID | Credential Connection ID for WebRTC | For in-app calling |
Troubleshooting
SMS Not Sending
- Verify
TELNYX_API_KEYis correct - Check phone number is in E.164 format (
+15551234567) - Ensure your Telnyx number has SMS capability enabled
- Check Telnyx portal for error logs
Calls Not Connecting
- Verify
TELNYX_APP_IDis set correctly - Ensure your Voice App has an Outbound Voice Profile assigned
- Check webhook URLs are accessible from the internet
- Review call events in Telnyx portal
WebRTC Not Working
- Verify
TELNYX_CREDENTIAL_CONNECTION_IDis set correctly - Ensure the Credential Connection is active in Telnyx portal (Voice > SIP Trunking > SIP Connections)
- Verify the Credential Connection has an Outbound Voice Profile assigned
- Check browser console for WebRTC errors
- Verify microphone permissions are granted
- Check that the JWT token hasn't expired (tokens last 24 hours)
Related Documentation
- Session Interface - Where calls and SMS are initiated
- Telnyx API Reference - Official API documentation
- Telnyx WebRTC SDK - Browser calling SDK