Add WhatsApp OTP to your login
Two endpoints do the work. The care goes into everything around them.
The flow
Your send-code endpoint
POST /auth/send-codeCalls
verify.start, then stores the verification id on the session — never in the browser.Your verify endpoint
POST /auth/verifyCalls
verify.checkwith the stored id and the code the user typed.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.
Send the code
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
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:
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:
// 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:
That runs in CI with no phone and no template approved by Meta. Going live is swapping the key.
Where to go next
- Set up your API access — the checks before real codes go to real people.
- Rate limits & quotas — the four ceilings an OTP endpoint meets first.