Back to blog

otp

Build a Phone Verification Flow with the senderZ API

Step-by-step tutorial to build OTP phone verification using senderZ. Generate code, send via iMessage, verify. Full Node.js example.

Noa

Build a Phone Verification Flow with the senderZ API

Phone verification is the most common reason developers integrate a messaging API. The flow is simple: generate a one-time code, send it to the user’s phone, and verify the code they enter. But the implementation details — code expiration, rate limiting, retry handling, and delivery speed — determine whether your verification flow feels instant or frustrating.

This tutorial walks through building a complete phone verification system using senderZ. The messages are delivered via iMessage when available (typically under 1 second) and fall back to SMS automatically. You will have working code by the end of this post.

Why iMessage for OTP

Most verification services send codes over SMS. SMS works, but it has limitations:

  • Delivery speed: SMS typically takes 3 to 5 seconds for delivery. On congested carrier networks, it can take 30 seconds or more.
  • Deliverability: SMS messages pass through carrier filtering systems that occasionally flag OTP codes as spam, especially from short codes or new long codes.
  • Security: SMS is vulnerable to SIM swap attacks and SS7 interception. These are not theoretical risks — they are actively exploited.

iMessage addresses all three:

  • Speed: iMessage delivery is typically under 1 second. The code arrives before the user finishes looking at the verification form.
  • Deliverability: iMessage bypasses carrier filtering entirely. Messages go through Apple’s push notification infrastructure, not the PSTN.
  • Security: iMessage uses end-to-end encryption. There is no carrier intermediary to intercept.

With senderZ, you get iMessage delivery for Apple device users and automatic SMS fallback for everyone else. You do not need to build two separate flows or detect the user’s device type. One API call handles both channels. For more on channel routing, see the send message documentation.

The Verification Flow

The complete flow has four steps:

  1. User enters their phone number in your application.
  2. Your server generates a 6-digit code, stores it with an expiration time, and sends it via the senderZ API.
  3. The user receives the code on their phone (via iMessage or SMS) and enters it in your application.
  4. Your server verifies the code against the stored value and marks the phone number as verified.

Here is the sequence in detail:

User → Your App: "My number is +15551234567"
Your App → senderZ API: POST /v1/messages (OTP code)
senderZ → User's Phone: "Your verification code is 847293"
User → Your App: "The code is 847293"
Your App: Verify code → Success

Prerequisites

Before starting, you need:

  • A senderZ API key. Sign up at senderZ.com to get one.
  • Node.js 18 or later.
  • A basic Express server (or any HTTP framework — the senderZ calls work with any stack).

Install the dependencies:

npm install express @senderz/sdk crypto

Step 1: Generate a Secure Code

Use crypto.randomInt to generate a cryptographically random 6-digit code. Never use Math.random() for verification codes — it is not cryptographically secure.

import crypto from "crypto";

function generateOTPCode(): string {
  const code = crypto.randomInt(100000, 999999);
  return code.toString();
}

Store the code with the phone number and an expiration timestamp. In production, use Redis or a database. For this tutorial, an in-memory Map works:

interface PendingVerification {
  code: string;
  expiresAt: number;
  attempts: number;
}

const pendingVerifications = new Map<string, PendingVerification>();

function storeCode(phoneNumber: string, code: string): void {
  pendingVerifications.set(phoneNumber, {
    code,
    expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
    attempts: 0,
  });
}

The 10-minute expiration is a reasonable default. Shorter windows (5 minutes) are more secure but may frustrate users on slow connections. Longer windows (30 minutes) increase the risk of code interception.

Step 2: Send the Code via senderZ

Use the senderZ API to deliver the code. The channel: "auto" setting routes through iMessage when available and falls back to SMS automatically.

Using curl

curl -X POST https://api.senderz.com/v1/messages \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+15551234567",
    "channel": "auto",
    "body": "Your verification code is 847293. It expires in 10 minutes."
  }'

Using the senderZ SDK

import { SenderZ } from "@senderz/sdk";

const senderz = new SenderZ({ apiKey: process.env.SENDERZ_API_KEY });

async function sendVerificationCode(
  phoneNumber: string,
  code: string
): Promise<string> {
  const message = await senderz.messages.send({
    to: phoneNumber,
    channel: "auto",
    body: `Your verification code is ${code}. It expires in 10 minutes.`,
  });

  return message.id;
}

The response includes a message_id you can use to track delivery status. For production systems, check the delivery status via a webhook or by polling the message status endpoint.

Using a Template

For production OTP flows, use a senderZ template instead of inline text. Templates are pre-approved message formats with variable substitution:

const message = await senderz.messages.send({
  to: phoneNumber,
  channel: "auto",
  template: "otp_verification",
  variables: {
    code: code,
    expiry_minutes: "10",
  },
});

Templates ensure consistent formatting and make it easy to update the message text without changing code. See the templates documentation for setup instructions.

Step 3: Verify the Code

When the user enters the code, validate it against the stored value:

interface VerificationResult {
  success: boolean;
  error?: string;
}

function verifyCode(
  phoneNumber: string,
  submittedCode: string
): VerificationResult {
  const pending = pendingVerifications.get(phoneNumber);

  if (!pending) {
    return { success: false, error: "No verification pending for this number." };
  }

  if (Date.now() > pending.expiresAt) {
    pendingVerifications.delete(phoneNumber);
    return { success: false, error: "Code has expired. Please request a new one." };
  }

  pending.attempts += 1;

  if (pending.attempts > 5) {
    pendingVerifications.delete(phoneNumber);
    return {
      success: false,
      error: "Too many attempts. Please request a new code.",
    };
  }

  if (pending.code !== submittedCode) {
    return { success: false, error: "Incorrect code." };
  }

  pendingVerifications.delete(phoneNumber);
  return { success: true };
}

Key security details in this implementation:

  • Expiration check runs before the code comparison. Expired codes are rejected even if the value matches.
  • Attempt limiting prevents brute-force attacks. After 5 wrong attempts, the code is invalidated and the user must request a new one.
  • Constant-time comparison is not needed here because the codes are short-lived and attempt-limited, but for higher-security applications, use crypto.timingSafeEqual.

Step 4: Wire It Together

Here is the complete Express server with both endpoints:

import express from "express";
import crypto from "crypto";
import { SenderZ } from "@senderz/sdk";

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

const senderz = new SenderZ({ apiKey: process.env.SENDERZ_API_KEY! });

const pendingVerifications = new Map<
  string,
  { code: string; expiresAt: number; attempts: number }
>();

// Rate limit: 1 code per number per 60 seconds
const lastSent = new Map<string, number>();

app.post("/verify/send", async (req, res) => {
  const { phone_number } = req.body;

  if (!phone_number || !phone_number.startsWith("+")) {
    return res.status(400).json({ error: "Phone number must be in E.164 format." });
  }

  const lastTime = lastSent.get(phone_number);
  if (lastTime && Date.now() - lastTime < 60000) {
    return res.status(429).json({
      error: "Please wait 60 seconds before requesting another code.",
    });
  }

  const code = crypto.randomInt(100000, 999999).toString();

  pendingVerifications.set(phone_number, {
    code,
    expiresAt: Date.now() + 10 * 60 * 1000,
    attempts: 0,
  });
  lastSent.set(phone_number, Date.now());

  await senderz.messages.send({
    to: phone_number,
    channel: "auto",
    body: `Your verification code is ${code}. It expires in 10 minutes.`,
  });

  res.json({ success: true, message: "Verification code sent." });
});

app.post("/verify/check", (req, res) => {
  const { phone_number, code } = req.body;

  const pending = pendingVerifications.get(phone_number);

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

  if (Date.now() > pending.expiresAt) {
    pendingVerifications.delete(phone_number);
    return res.status(400).json({ error: "Code expired." });
  }

  pending.attempts += 1;
  if (pending.attempts > 5) {
    pendingVerifications.delete(phone_number);
    return res.status(429).json({ error: "Too many attempts." });
  }

  if (pending.code !== code) {
    return res.status(400).json({ error: "Incorrect code." });
  }

  pendingVerifications.delete(phone_number);
  res.json({ success: true, verified: true });
});

app.listen(3000, () => console.log("Verification server running on :3000"));

Testing the Flow

Start the server and test with curl:

# Request a verification code
curl -X POST http://localhost:3000/verify/send \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+15551234567"}'

# Verify the code (replace 847293 with the actual code you received)
curl -X POST http://localhost:3000/verify/check \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+15551234567", "code": "847293"}'

The first request sends the code via senderZ. If the recipient has an iPhone, the code arrives via iMessage in about 1 second. If not, it arrives via SMS in 3 to 5 seconds.

Production Considerations

Use a database for pending codes. The in-memory Map works for a single server but does not survive restarts or work across multiple instances. Use Redis with TTL-based expiration, or a database table with an index on phone_number.

Log verification events for compliance. senderZ automatically logs consent events through the compliance system. For your application, log the phone number (hashed), timestamp, and verification result. See the quickstart guide for compliance setup.

Implement IP-based rate limiting. The per-number rate limit prevents abuse of a single number, but an attacker could try many different numbers. Add IP-based rate limiting (e.g., 10 requests per IP per hour) to prevent this.

Use webhooks for delivery confirmation. For critical verification flows (banking, healthcare), register a webhook with senderZ to receive delivery status updates. If a message fails to deliver, you can prompt the user to try again immediately instead of making them wait for a timeout. See the pricing page for plan-level webhook support.

Do not include the code in the delivery confirmation. Never send the verification code back to the client in the API response. The code should only appear on the user’s phone.

Frequently Asked Questions

How fast does the verification code arrive?

Via iMessage, the code typically arrives in under 1 second. Via SMS fallback, delivery takes 3 to 5 seconds on average. senderZ automatically uses iMessage when the recipient’s number supports it, so Apple device users get near-instant delivery without any extra configuration.

Do I need to handle iMessage and SMS separately?

No. Set channel: "auto" and senderZ handles routing automatically. Your verification flow code is identical regardless of which channel delivers the message. The only difference is delivery speed, which the user experiences but your code does not need to account for.

What happens if the message fails to deliver?

If iMessage delivery fails (for example, the recipient recently switched away from iPhone), senderZ automatically retries via SMS. If both channels fail, the message status changes to failed. You can detect this by polling the message status or registering a webhook, and prompt the user to request a new code.

Can I customize the OTP message format?

Yes. You can either pass a custom body string with each request or create a reusable template in the senderZ dashboard. Templates support variables like code and expiry_minutes for dynamic content. See the templates documentation for details.

Is there a rate limit on sending verification codes?

senderZ enforces plan-level rate limits on API calls. Within those limits, you should implement your own per-number rate limiting (as shown in the tutorial) to prevent abuse. A common pattern is one code per number per 60 seconds and a maximum of 5 codes per number per hour.


Ready to add phone verification to your application? Get your API key at senderZ.com and follow the quickstart guide to send your first verification code in under five minutes.

Tagged otp verification tutorial node.js

Ready to start sending?

Create your free account and send your first message in minutes.