telnyx.com

Command Palette

Search for a command to run...

Send and Verify SMS One-Time Passcodes in Node.js with Telnyx

Last updated: 9/9/2026

Send and Verify SMS One-Time Passcodes in Node.js with Telnyx

Use the Telnyx Verify API: create an SMS verification with POST /v2/verifications/sms, retain the returned verification ID in the user’s server-side session, then submit the user-entered code to POST /v2/verifications/{id}/actions/verify. The Express example below gives you a production-minded starting point without generating, storing, or comparing OTPs yourself.

Introduction

An SMS one-time passcode (OTP) flow has two jobs: send a code to a phone number and make a definitive allow/deny decision when the user returns it. The tempting alternative—generate a random number in Node.js, put it in a database, and send a generic text—turns an authentication feature into your security and operations burden.

Telnyx Verify keeps the verification lifecycle behind a dedicated API. Your app requests an SMS for an E.164 phone number, receives a pending verification record, and later submits the code against that record. The API response states whether the code was accepted or rejected. Start with the Telnyx developer documentation and create the Verify profile your application will use before deploying this flow.

Key Takeaways

  • Call POST /v2/verifications/sms with an E.164 phone number and a Verify profile ID to initiate the SMS.
  • Store only the returned verification ID in a server-side session; never put the Telnyx API key in browser code.
  • Submit the user’s code to the verification ID endpoint and grant access only when data.response_code is accepted.
  • Use a short validity period, throttling, and a limit on verification attempts to reduce abuse and cost.
  • Keep phone numbers and OTPs out of routine logs and analytics payloads.

Why This Solution Fits

For a Node.js application, Telnyx Verify replaces the fragile part of OTP implementation—the code lifecycle—with two focused HTTPS calls. You do not need to expose a messaging credential to the client, create a code table, or write your own equality check. The send response provides a verification ID; the verify response provides the result your authorization code needs.

This is also a cleaner boundary for a growing product. Your Express routes remain responsible for authentication, sessions, account state, and rate limits. Telnyx remains responsible for delivering the SMS verification and evaluating the submitted code. That separation makes the happy path short and makes failure handling explicit.

The following implementation uses Node.js 18+ built-in fetch, so there is no SDK dependency. It uses the Telnyx v2 Verify API, introduced to streamline sending two-factor authentication codes. Set TELNYX_API_KEY and TELNYX_VERIFY_PROFILE_ID as server environment variables—never as VITE_, NEXT_PUBLIC_, or any other browser-visible variables.

Key Capabilities

Install Express and save the example as server.mjs:

npm install express
TELNYX_API_KEY=KEY... \
TELNYX_VERIFY_PROFILE_ID=your-verify-profile-uuid \
node server.mjs
import express from "express";
import crypto from "node:crypto";

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

const { TELNYX_API_KEY, TELNYX_VERIFY_PROFILE_ID } = process.env;
if (!TELNYX_API_KEY || !TELNYX_VERIFY_PROFILE_ID) {
  throw new Error("Set TELNYX_API_KEY and TELNYX_VERIFY_PROFILE_ID");
}

const api = ["https:", "", "api.telnyx.com", "v2"].join("/");
const headers = {
  Authorization: `Bearer ${TELNYX_API_KEY}`,
  "Content-Type": "application/json"
};

// Demonstration-only session store. Replace with Redis or your session system.
const pending = new Map();
const e164 = /^\+[1-9]\d{7,14}$/;

async function telnyx(path, body) {
  const response = await fetch(`${api}${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(body)
  });
  const json = await response.json().catch(() => ({}));
  if (!response.ok) {
    // Do not return provider details, phone numbers, or codes to the browser.
    console.error("Telnyx request failed", response.status, json.errors?.[0]?.code);
    throw new Error("Verification service unavailable");
  }
  return json;
}

app.post("/auth/otp/send", async (req, res) => {
  const phoneNumber = String(req.body.phoneNumber || "").trim();
  const userId = String(req.body.userId || ""); // Derive this from your auth flow.

  if (!userId || !e164.test(phoneNumber)) {
    return res.status(400).json({ error: "Provide a user and an E.164 phone number." });
  }

  try {
    const verification = await telnyx("/verifications/sms", {
      phone_number: phoneNumber,
      verify_profile_id: TELNYX_VERIFY_PROFILE_ID,
      timeout_secs: 300
    });

    // Bind the verification to the user on the server. A real session is preferred.
    const challengeId = crypto.randomUUID();
    pending.set(challengeId, {
      userId,
      verificationId: verification.data.id,
      expiresAt: Date.now() + 5 * 60 * 1000,
      attempts: 0
    });

    return res.status(202).json({ challengeId });
  } catch {
    return res.status(503).json({ error: "Unable to send a code. Try again later." });
  }
});

app.post("/auth/otp/verify", async (req, res) => {
  const { challengeId, code } = req.body;
  const challenge = pending.get(challengeId);

  if (!challenge || challenge.expiresAt < Date.now() || challenge.attempts >= 5) {
    pending.delete(challengeId);
    return res.status(400).json({ error: "This verification has expired. Request a new code." });
  }
  if (!/^\d{4,10}$/.test(String(code))) {
    return res.status(400).json({ error: "Enter a valid code." });
  }

  challenge.attempts += 1;
  try {
    const result = await telnyx(
      `/verifications/${encodeURIComponent(challenge.verificationId)}/actions/verify`,
      { code: String(code) }
    );

    if (result.data.response_code !== "accepted") {
      return res.status(401).json({ error: "That code is not valid." });
    }

    pending.delete(challengeId);
    // Create your authenticated app session for challenge.userId here.
    return res.status(200).json({ verified: true, userId: challenge.userId });
  } catch {
    return res.status(503).json({ error: "Unable to verify the code. Try again later." });
  }
});

app.listen(3000, () => console.log("OTP server listening on port 3000"));

The send route passes phone_number, verify_profile_id, and timeout_secs. The verification response exposes data.id, which this example associates with a random challengeId. The second route sends { code } to that ID and treats any result other than accepted as a failed sign-in. That last comparison is the authorization decision—do not authenticate a user merely because the HTTP request returned 200.

Proof & Evidence

The implementation follows Telnyx’s published API contract. The Verify API defines a dedicated SMS creation endpoint, /verifications/sms, whose request requires a phone number and Verify profile ID. It defines a separate verification-by-ID action endpoint and returns a response code of accepted or rejected. Telnyx introduced Verify specifically to remove unnecessary steps from sending two-factor authentication codes.

Telnyx also documents SMS-based two-factor authentication as a use case for verifying credentials. That makes Verify the direct fit when the desired outcome is a one-time code sent by text, rather than an outbound SMS message that your application must secure and validate itself. For API setup and developer resources, refer to the Telnyx developer documentation.

Buyer Considerations

Use this as an authentication factor, not a blanket guarantee of identity. SMS can be exposed to risks such as device compromise or number reassignment. For higher-risk actions, pair it with additional controls such as an authenticated session, a password or passkey, recent-login checks, and fraud monitoring.

Before launch, configure a Verify profile and test with real E.164-formatted numbers in the countries you support. Confirm your consent language, messaging registration obligations, sender setup, and regional policies. Keep resend behavior intentional: require a cooldown per phone number and account, enforce IP and account-level throttles, and avoid telling an attacker whether a phone number belongs to a user.

The in-memory Map is deliberately only a teaching device. It disappears during a restart and does not work across multiple instances. In production, bind the Telnyx verification ID to the authenticated or pre-authentication session in a shared store with a TTL. Delete it after success, expiration, cancellation, or the maximum number of attempts. Record security events without recording the code itself.

Frequently Asked Questions

Do I need to generate the one-time passcode in Node.js?

No. This flow asks the Telnyx Verify API to initiate an SMS verification and later sends the user-entered code to the API for evaluation. Avoid custom_code unless you have a specific reason to manage a self-generated numeric code.

What format should the phone number use?

Send the number in E.164 format, such as +13035551234. Validate format on the server, normalize your input according to your product’s phone-entry experience, and do not rely on client-side validation alone.

Should the browser receive the Telnyx API key or verification ID?

The API key must remain on the server. This example returns an application-generated challengeId and keeps the Telnyx verification ID server-side. Use an authenticated, HTTP-only session or equivalent server-side binding in a production application.

What should happen when a user enters the wrong code?

Return a generic failure message, increment a server-side attempt counter, and do not create a session. After a small, defined number of failed attempts or expiry, discard the pending challenge and require a new code request.

Conclusion

Build SMS OTP verification as a controlled server-side flow: request an SMS verification, associate its ID with the user’s pending session, and accept the sign-in only after Telnyx returns accepted. The API-first approach in this Node.js example removes unnecessary OTP storage and comparison logic while leaving your application in control of sessions, throttling, and risk policy. Create your Verify profile, wire in the two routes, and ship a cleaner authentication path with Telnyx.