Skip to main content
Real-world integration examples showing how to use Telnyx Verify in common application flows. Each example includes complete server-side code, error handling, and frontend UX guidance.

Login verification

Add phone-based 2FA to your login flow. After the user enters their password, send a verification code to their registered phone number before granting access.

Sequence diagram

Server implementation

import express from 'express';
import Telnyx from 'telnyx';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());

const telnyx = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });
const VERIFY_PROFILE_ID = process.env.TELNYX_VERIFY_PROFILE_ID;
const JWT_SECRET = process.env.JWT_SECRET;

// Step 1: User submits credentials
app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // Validate credentials against your database
  const user = await db.users.findByEmail(email);
  if (!user || !await bcrypt.compare(password, user.password_hash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Check if user has 2FA enabled
  if (!user.phone_number) {
    // No 2FA — issue token directly
    const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '24h' });
    return res.json({ token });
  }

  try {
    // Send verification code
    const verification = await telnyx.verifications.sms({
      phone_number: user.phone_number,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    // Store pending verification in session
    req.session.pendingLogin = {
      userId: user.id,
      phoneNumber: user.phone_number,
      verificationId: verification.data.id,
      expiresAt: Date.now() + 300_000, // 5 minutes
    };

    // Mask phone number for display
    const masked = user.phone_number.replace(/(\+\d{1})\d{6}(\d{4})/, '$1******$2');

    res.json({
      requires_2fa: true,
      phone_hint: masked, // "+1******3456"
      timeout_secs: 300,
    });
  } catch (error) {
    console.error('Verification send failed:', error);
    res.status(500).json({ error: 'Failed to send verification code' });
  }
});

// Step 2: User submits verification code
app.post('/login/verify', async (req, res) => {
  const { code } = req.body;
  const pending = req.session.pendingLogin;

  if (!pending) {
    return res.status(400).json({ error: 'No pending verification. Start login again.' });
  }

  if (Date.now() > pending.expiresAt) {
    delete req.session.pendingLogin;
    return res.status(410).json({ error: 'Verification expired. Start login again.' });
  }

  try {
    const result = await telnyx.verifications.verify(pending.phoneNumber, {
      code,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    if (result.data.response_code === 'accepted') {
      delete req.session.pendingLogin;
      const token = jwt.sign({ userId: pending.userId }, JWT_SECRET, { expiresIn: '24h' });
      return res.json({ token });
    }

    res.status(401).json({
      error: 'Invalid code',
      response_code: result.data.response_code,
    });
  } catch (error) {
    console.error('Verification check failed:', error);
    res.status(500).json({ error: 'Verification failed' });
  }
});

// Resend code
app.post('/login/resend', async (req, res) => {
  const pending = req.session.pendingLogin;

  if (!pending) {
    return res.status(400).json({ error: 'No pending verification' });
  }

  try {
    const verification = await telnyx.verifications.sms({
      phone_number: pending.phoneNumber,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    pending.verificationId = verification.data.id;
    pending.expiresAt = Date.now() + 300_000;

    res.json({ message: 'Code resent', timeout_secs: 300 });
  } catch (error) {
    res.status(500).json({ error: 'Failed to resend code' });
  }
});

app.listen(3000);

Frontend UX guidance

  • Use a single input field or separate digit boxes (4–6 boxes depending on code length)
  • Auto-focus the input when the 2FA screen appears
  • Auto-submit when the full code length is entered (no submit button needed)
  • Accept numeric input only — set inputmode="numeric" and pattern="[0-9]*"
  • Enable paste support for users copying from SMS notifications
  • Show a countdown timer (e.g., “Code expires in 4:32”)
  • Disable the Resend button for 30 seconds after sending to prevent rapid resends
  • After timeout, show a clear “Code expired” message with a Resend button
  • Wrong code: “Invalid code. X attempts remaining.” Keep the input active for retry.
  • Expired code: “Code expired. Click Resend to get a new code.”
  • Max attempts: “Too many attempts. Please start the login process again.”
  • Network error: “Could not verify. Check your connection and try again.”

Registration verification

Verify phone number ownership during account creation. This confirms the user has access to the phone number before your application creates the account.

Sequence diagram

Server implementation

import express from 'express';
import Telnyx from 'telnyx';

const app = express();
app.use(express.json());

const telnyx = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });
const VERIFY_PROFILE_ID = process.env.TELNYX_VERIFY_PROFILE_ID;

// Step 1: Start registration — validate and send code
app.post('/register/start', async (req, res) => {
  const { name, email, phone_number, password } = req.body;

  // Validate input
  if (!name || !email || !phone_number || !password) {
    return res.status(400).json({ error: 'All fields required' });
  }

  // Check for existing account
  const existing = await db.users.findByEmailOrPhone(email, phone_number);
  if (existing) {
    return res.status(409).json({
      error: existing.email === email
        ? 'Email already registered'
        : 'Phone number already registered'
    });
  }

  try {
    // Send verification code
    const verification = await telnyx.verifications.sms({
      phone_number,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    // Store pending registration (use Redis/DB in production)
    req.session.pendingRegistration = {
      name,
      email,
      phone_number,
      password_hash: await bcrypt.hash(password, 12),
      verificationId: verification.data.id,
      expiresAt: Date.now() + 600_000, // 10 minutes
    };

    res.json({ verification_sent: true, timeout_secs: 600 });
  } catch (error) {
    console.error('Verification failed:', error);
    res.status(500).json({ error: 'Failed to send verification code' });
  }
});

// Step 2: Verify code and create account
app.post('/register/verify', async (req, res) => {
  const { code } = req.body;
  const pending = req.session.pendingRegistration;

  if (!pending) {
    return res.status(400).json({ error: 'No pending registration' });
  }

  if (Date.now() > pending.expiresAt) {
    delete req.session.pendingRegistration;
    return res.status(410).json({ error: 'Registration expired. Please start again.' });
  }

  try {
    const result = await telnyx.verifications.verify(pending.phone_number, {
      code,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    if (result.data.response_code !== 'accepted') {
      return res.status(401).json({
        error: 'Invalid verification code',
        response_code: result.data.response_code,
      });
    }

    // Phone verified — create the account
    const user = await db.users.create({
      name: pending.name,
      email: pending.email,
      phone_number: pending.phone_number,
      password_hash: pending.password_hash,
      phone_verified: true,
      phone_verified_at: new Date(),
    });

    delete req.session.pendingRegistration;

    const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.status(201).json({ user_id: user.id, token });
  } catch (error) {
    console.error('Registration verification failed:', error);
    res.status(500).json({ error: 'Verification failed' });
  }
});

app.listen(3000);

Payment verification

Add step-up authentication for high-value transactions. When a user initiates a payment above a threshold, require phone verification before processing.

Sequence diagram

Server implementation

import express from 'express';
import Telnyx from 'telnyx';

const app = express();
app.use(express.json());

const telnyx = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });
const VERIFY_PROFILE_ID = process.env.TELNYX_VERIFY_PROFILE_ID;
const VERIFICATION_THRESHOLD = 100.00; // Require verification above $100

// Middleware: require authenticated user
function requireAuth(req, res, next) {
  // Your auth middleware here
  next();
}

// Step 1: Initiate payment
app.post('/payments/initiate', requireAuth, async (req, res) => {
  const { amount, currency, recipient, description } = req.body;
  const user = req.user;

  if (amount <= 0) {
    return res.status(400).json({ error: 'Invalid amount' });
  }

  // Create pending payment record
  const payment = await db.payments.create({
    user_id: user.id,
    amount,
    currency: currency || 'USD',
    recipient,
    description,
    status: 'pending',
  });

  // Check if verification is needed
  if (amount >= VERIFICATION_THRESHOLD) {
    try {
      const verification = await telnyx.verifications.sms({
        phone_number: user.phone_number,
        verify_profile_id: VERIFY_PROFILE_ID,
      });

      await db.payments.update(payment.id, {
        verification_id: verification.data.id,
        verification_required: true,
        verification_expires_at: new Date(Date.now() + 300_000),
      });

      return res.json({
        payment_id: payment.id,
        requires_verification: true,
        amount,
        currency: currency || 'USD',
        timeout_secs: 300,
      });
    } catch (error) {
      await db.payments.update(payment.id, { status: 'failed' });
      return res.status(500).json({ error: 'Failed to send verification' });
    }
  }

  // Low-value payment — process directly
  const result = await processPayment(payment);
  res.json({ payment_id: payment.id, status: result.status });
});

// Step 2: Verify and process payment
app.post('/payments/confirm', requireAuth, async (req, res) => {
  const { payment_id, code } = req.body;
  const user = req.user;

  const payment = await db.payments.findById(payment_id);

  if (!payment || payment.user_id !== user.id) {
    return res.status(404).json({ error: 'Payment not found' });
  }

  if (payment.status !== 'pending') {
    return res.status(400).json({ error: `Payment already ${payment.status}` });
  }

  if (new Date() > payment.verification_expires_at) {
    await db.payments.update(payment.id, { status: 'expired' });
    return res.status(410).json({ error: 'Verification expired. Initiate a new payment.' });
  }

  try {
    const result = await telnyx.verifications.verify(user.phone_number, {
      code,
      verify_profile_id: VERIFY_PROFILE_ID,
    });

    if (result.data.response_code !== 'accepted') {
      return res.status(401).json({
        error: 'Invalid verification code',
        response_code: result.data.response_code,
      });
    }

    // Verification passed — process payment
    await db.payments.update(payment.id, {
      verified_at: new Date(),
      status: 'processing',
    });

    const paymentResult = await processPayment(payment);

    res.json({
      payment_id: payment.id,
      status: paymentResult.status,
      transaction_id: paymentResult.transaction_id,
    });
  } catch (error) {
    console.error('Payment verification failed:', error);
    res.status(500).json({ error: 'Verification failed' });
  }
});

async function processPayment(payment) {
  // Integrate with your payment processor (Stripe, etc.)
  // This is a placeholder
  await db.payments.update(payment.id, { status: 'completed' });
  return { status: 'completed', transaction_id: `txn_${Date.now()}` };
}

app.listen(3000);
Security considerations for payment verification:
  • Always verify the payment amount hasn’t been tampered with between initiation and confirmation
  • Set short timeouts (5 minutes) for payment verification codes
  • Log all verification attempts for audit purposes
  • Never expose payment details in verification messages — the SMS should only contain the code

Common patterns across all flows

Rate limiting resend requests

Prevent abuse by limiting how often users can request new codes:
// In-memory rate limiter (use Redis in production)
const resendLimits = new Map();

function canResend(phoneNumber) {
  const key = phoneNumber;
  const now = Date.now();
  const limit = resendLimits.get(key);

  if (limit && now - limit.lastSent < 30_000) {
    return { allowed: false, retryAfter: Math.ceil((30_000 - (now - limit.lastSent)) / 1000) };
  }

  if (limit && limit.count >= 5 && now - limit.firstSent < 3600_000) {
    return { allowed: false, retryAfter: Math.ceil((3600_000 - (now - limit.firstSent)) / 1000) };
  }

  return { allowed: true };
}

function recordResend(phoneNumber) {
  const key = phoneNumber;
  const now = Date.now();
  const existing = resendLimits.get(key);

  resendLimits.set(key, {
    lastSent: now,
    firstSent: existing?.firstSent || now,
    count: (existing?.count || 0) + 1,
  });
}

Handling user cancellation

Always clean up pending verifications when users navigate away:
// Frontend: clean up on unmount
useEffect(() => {
  return () => {
    if (pendingVerification) {
      fetch('/verify/cancel', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ verification_id: pendingVerification }),
        keepalive: true, // Ensures the request completes even during navigation
      });
    }
  };
}, [pendingVerification]);

Webhook-driven verification

Instead of polling, use webhooks for real-time verification status updates:
app.post('/webhooks/verify', (req, res) => {
  const event = req.body.data;

  if (event.event_type === 'verification.completed') {
    const { phone_number, status } = event.payload;
    // Update your UI via WebSocket or SSE
    notifyClient(phone_number, { verified: status === 'accepted' });
  }

  res.sendStatus(200);
});
For more on webhooks, see Receiving Webhooks for Telnyx Verify.

Next steps