Almost every app eventually has to answer one question: is this really the person who owns this phone number? A one-time passcode is how you ask. The pattern never changes — generate a code, send it, store it, check what the user types back, expire it on time, and stop anyone from guessing. Zend OTP does all of that behind two API calls. You send, then you verify. You never generate a code, keep one in your database, or write the expiry and attempt-limiting logic yourself. Here is how it works.
Sending a code
One POST to /otp/send. You pick the channel, the code length, and how long it stays valid, and you tag each request with an app label so you can tell your login codes apart from your checkout codes later.
curl https://api.tryzend.com/otp/send \
-H "X-API-Key: $ZEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+233201234567",
"app": "acme_checkout",
"channel": "sms",
"length": 6,
"expiry": 10
}'
The important part of the response is what is not in it — the code. Zend generates and stores the passcode; you get back an id to verify against, plus a status and an expiry timestamp.
{
"id": "otp_64f8a1b2c3d4e5f6",
"phone_number": "+233201234567",
"app": "acme_checkout",
"status": "sent",
"expires_at": "2026-07-23T10:30:00Z",
"message_id": "msg_64f8a1b2c3d4e5f6"
}
Codes are 4 or 6 digits, and expiry is anything from 1 to 60 minutes. Because the code never leaves Zend, it never lands in your logs, your database, or a support ticket.
Choosing how it's delivered
The channel decides how the code reaches the user:
sms— a plain text message, the universal default.whatsapp— delivered over WhatsApp, and if it doesn't get through there, Zend automatically falls back to SMS so the code still lands.auto— let Zend choose the channel for you.
The WhatsApp route is the one to reach for when you want cheaper or richer delivery but can't afford for a code to simply not arrive — the SMS fallback is the safety net underneath it.
Verifying what the user typed
When the user enters the code, send it to /otp/:id/verify using the id from the send call. You are not comparing anything yourself — Zend checks the code, the expiry, and the attempt count together, in one request.
curl https://api.tryzend.com/otp/otp_64f8a1b2c3d4e5f6/verify \
-H "X-API-Key: $ZEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }'
A correct, in-time code comes back verified:
{
"success": true,
"status": "verified",
"verified_at": "2026-07-23T10:25:00Z"
}
A wrong one tells you how many tries are left, so you can show the user the truth — and so Zend can shut the door after too many:
{
"success": false,
"status": "failed",
"error": "Invalid OTP code",
"attempts_remaining": 2
}
Once the attempts run out, or the code expires, verification fails closed. There is no way to keep guessing.
What you don't have to build
That is really the whole point. With Zend OTP you are not:
- generating or storing codes, or keeping them out of your logs
- writing a job to expire them on schedule
- counting attempts to stop brute-force guessing
- wiring up SMS and WhatsApp delivery, and the fallback between them, separately
You send, you verify, and the security-sensitive part in the middle is handled for you.
Keeping an eye on it
If you want to check on a code without verifying it — to power a "resend" button, say, or a status screen — GET /otp/:id/status returns where it stands and how many attempts remain:
{
"id": "otp_64f8a1b2c3d4e5f6",
"status": "delivered",
"expires_at": "2026-07-23T10:30:00Z",
"attempts_remaining": 3,
"verified_at": null
}
And in your dashboard, every code you send shows up with its delivery and verification outcome, so you can watch conversion and catch delivery problems by channel.
Get started
- Grab an API key from your dashboard
- Send your first code with
POST /otp/send, then verify it with theidyou get back - Watch codes land and convert in the OTP dashboard
Send a code to your own number, type it back, and watch it flip to verified.
