Add WhatsApp OTP to your login

Two endpoints do the work. The care goes into everything around them.

The flow

  1. Your send-code endpoint

    POST /auth/send-code

    Calls verify.start, then stores the verification id on the session — never in the browser.

  2. Your verify endpoint

    POST /auth/verify

    Calls verify.check with the stored id and the code the user typed.

  3. On approved, create your session

    We verify a phone number. Logging anyone in is yours.

Create the client once

Build it once, in module scope. A client per request throws away the connection pool, and on a busy login endpoint that is the slowest thing you will do.

// msgeasy.ts
import { MsgEasy } from "msgeasy";
 
export const msg = new MsgEasy(process.env.MSGEASY_API_KEY!);

Send the code

import { MsgEasy, MsgEasyError } from "msgeasy";
 
const msg = new MsgEasy(process.env.MSGEASY_API_KEY);
 
app.post("/auth/send-code", async (req, res) => {
  const { phone } = req.body;
 
  try {
    const verification = await msg.verify.start({ phone, ttlSeconds: 300 });
 
    // Never send the id to the browser. Keep it server-side, against the session.
    req.session.verificationId = verification.verificationId;
    req.session.phone = phone;
 
    res.json({ sent: true });
  } catch (error) {
    if (error instanceof MsgEasyError) {
      // Do not leak which numbers exist or why we refused.
      req.log.warn({ code: error.code, requestId: error.requestId }, "verify.start failed");
      return res.json({ sent: true });
    }
    throw error;
  }
});

A few things worth noticing.

The verification id stays on the server. If the browser holds it, anyone can check codes against any verification.

The error is swallowed on purpose. Telling a caller "that number is not registered" hands them a way to enumerate your users. Log it, return the same thing either way.

Verify the code

app.post("/auth/verify", async (req, res) => {
  const { verificationId, phone } = req.session;
  if (!verificationId) return res.status(400).json({ error: "Start again." });
 
  const result = await msg.verify.check({ verificationId, code: req.body.code });
 
  switch (result.status) {
    case "approved":
      delete req.session.verificationId;      // one code, one use
      req.session.user = await findOrCreateUser(phone);
      return res.json({ ok: true });
 
    case "invalid":
      return res.status(400).json({ error: "Wrong code.", left: result.remainingAttempts });
 
    case "expired":
    case "max_attempts":
      delete req.session.verificationId;
      return res.status(400).json({ error: "Start again." });
  }
});

Clear the id on success. Otherwise the same code works twice.

A wrong code does not raise. It comes back as an invalid status with the attempts remaining. Only a refusal — a spent quota, a bad key — raises. If you wrap check in a try and treat every outcome as failure, you will never see the attempt counter.

Only approved means the user is verified — see Verify a phone number.

When a send is refused

We cap how many codes go to the same number within a window — both numbers are yours to set, under Settings → Console → Verify. When that cap trips, start raises rate_limited and the refusal carries how long to wait:

import { MsgEasyError } from "msgeasy";
 
try {
  await msg.verify.start({ phone });
} catch (error) {
  if (error instanceof MsgEasyError && error.code === "rate_limited") {
    return res.status(429).json({ retryAfter: error.retryAfterSeconds });
  }
  throw error;
}

Resends

There is no resend call. Call start again and you get a new code, and pay for it. It issues a new verification id too, so overwrite the one on the session — the old one still has attempts left on it. Put a 30-second cooldown on the button.

Using it in Next.js

Identical, in a route handler:

TypeScript
// app/api/auth/send-code/route.ts
import { msg } from "@/msgeasy";
 
export async function POST(request: Request) {
  const { phone } = await request.json();
  const verification = await msg.verify.start({ phone });
  // ... store server-side, as above
}

Test it without a phone

With a test key the response carries the code, so an end-to-end test needs no phone and no mock:

const started = await msg.verify.start({ phone: "+919812345678" });
const result = await msg.verify.check({
  verificationId: started.verificationId,
  code: started.code!,
});
expect(result.status).toBe("approved");

That runs in CI with no phone and no template approved by Meta. Going live is swapping the key.

Where to go next