OTP API: WhatsApp, SMS and voice
Send a code you generate on WhatsApp, SMS or a voice call, in the order you choose. Each channel goes only when the one before did not get the code there, and you can check the result or receive it as a webhook. Same API key and project name as SMS.
- How it works
- Before you start
- Send a code
- The response
- When each channel goes
- Check a code
- Statuses and reasons
- The
otp.statuswebhook - What happens to the code
- Billing and rate limits
- Error responses
How it works
- You generate the code and you check it. Sozuri only delivers it. When the person types the code into your app, compare it with the one you stored.
-
channelssets the order. Usewhatsapp,smsandvoice, in the order to try them. WhatsApp can only come first and voice only last. These orders are allowed:["whatsapp", "sms", "voice"],["whatsapp", "sms"](the default),["whatsapp", "voice"],["sms", "voice"], or any one channel on its own. -
The next channel goes only when it is needed. That happens straight away when a channel
refuses the code or WhatsApp reports the message failed. Otherwise it happens after
fallback_afterseconds if no delivery has been confirmed. See When each channel goes. - Each channel is used at most once. Once WhatsApp confirms delivery, nothing more is sent.
Before you start
| Channel | What your project needs |
|---|---|
| An official WhatsApp number connected to the project, and an approved authentication template on it. Create the template in the dashboard under WhatsApp → Official → Templates, or through the templates API. WhatsApp sets the wording ("482913 is your verification code."), and the message has a copy-code button. It can also have one-tap or zero-tap autofill into your Android app. | |
| sms | A sender ID registered on the project. It is transactional unless you say promotional, and it must be named exactly as registered. |
| voice | An active voice number on the project (Voice → Numbers). The call comes from that number and reads the code one digit at a time. |
Authenticate as for SMS: send your API key as Authorization: Bearer <key>, or as an
apiKey field in the query string or body, plus project, your project's
name. See Authentication.
Send a code
| Field | Required | Type | Description |
|---|---|---|---|
| project | Yes | String | Your project's name. |
| to | Yes | String | The number to send to, in international format with the country code first: 254700000000. A leading +, spaces and dashes are removed. A local number (0700…) is refused. |
| code | Yes | String | The code, up to 15 characters and no spaces. If voice is one of the channels, it must be digits only, because the call reads it digit by digit. |
| channels | No | Array | The order to try, as described in How it works. Default ["whatsapp", "sms"]. |
| fallback_after | No | Integer | How many seconds each channel waits for delivery before the next one goes. A whole number from 10 to 600, default 30. |
| callback_url | No | String | Where to send one otp.status webhook when the code is decided. It must be a public https or http address, up to 1,000 characters. A private or local address is refused. |
| template | No | String or object | Which authentication template to use: its name, or {"name": "login_code", "language": "en"}. You only need this when the number has more than one approved authentication template. |
| from | No | String | Which of your official WhatsApp numbers to send from (254711000000). You only need this when the project has more than one. |
| SMS | |||
| sms.from | With SMS | String | One of your sender IDs, exactly as registered. |
| sms.type | No | String | transactional (default) or promotional, matching how that sender ID is registered. |
| sms.text | No | String | Your own wording, up to 320 characters, with {code} where the code goes. Default: {code} is your verification code. |
| Voice | |||
| voice.brand | No | String | A name to read before the code, up to 60 characters: "Your Acme verification code is …". Without it the call says "Your verification code is …". |
| voice.repeat | No | Integer | How many times the code is read: 1 to 3, default 2. |
| voice.from | No | String | Which of your active voice numbers to call from. Default: your project's first active number. |
WhatsApp, then SMS, then a call:
curl -X POST https://sozuri.net/api/v1/otp \
-H "Authorization: Bearer $SOZURI_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"project": "your_project_name",
"to": "254700000000",
"code": "482913",
"channels": ["whatsapp", "sms", "voice"],
"fallback_after": 30,
"sms": { "from": "YourSenderID", "text": "{code} is your Acme login code." },
"voice": { "brand": "Acme" },
"callback_url": "https://your-app.example/hooks/otp"
}'
SMS only:
{ "project": "your_project_name", "to": "254700000000", "code": "482913",
"channels": ["sms"], "sms": { "from": "YourSenderID" } }
SMS, then a call if the SMS was not delivered within a minute:
{ "project": "your_project_name", "to": "254700000000", "code": "4821",
"channels": ["sms", "voice"], "fallback_after": 60,
"sms": { "from": "YourSenderID" }, "voice": { "brand": "Acme", "repeat": 3 } }
The response
200 OK means the request was accepted. The first channel has already been tried before the answer arrives. The answer is the OTP as it stands at that moment:
{
"otp": {
"id": "otp_5f0c2a7d9e1b4c3a8f6d2e10",
"status": "sent",
"channel": "whatsapp",
"to": "254700000000",
"channels": ["whatsapp", "sms", "voice"],
"whatsapp": { "status": "pending" },
"sms": { "from": "YourSenderID", "type": "transactional" },
"voice": { "status": "waiting" },
"fallback": { "after": 30 },
"createdAt": "2026-11-11T09:00:00+00:00"
}
}
| Field | Description |
|---|---|
| id | The OTP's id: otp_ followed by 24 hex characters. Keep it to check the code. |
| status | sent, delivered or failed. See Statuses and reasons. |
| channel | The channel that has the code now: the last one that went, or the one that delivered it. It is missing while no channel has taken the code. |
| channels | The order you asked for. |
Present when WhatsApp is one of the channels. status, plus error when it was refused or failed, and deliveredAt once WhatsApp confirms delivery. | |
| sms | Present when SMS is one of the channels. from and type; messageId once the SMS has gone (the same id your SMS log and SMS delivery webhooks use); error when it was refused. |
| voice | Present when voice is one of the channels. status is waiting until the call is placed, then the call's own status. Once there is a call, callSid is its id, the same one GET /v1/voice/calls/{callSid} takes. error appears when the call could not be placed. |
| fallback | Present when there is more than one channel. after is your fallback_after. Once the code has moved on, reason says why and at says when. |
| createdAt, finishedAt | When the request was made and when the code was decided (UTC, ISO 8601). finishedAt is missing until then. |
When each channel goes
Every wait is fallback_after seconds (F), counted from the moment the channel before
went:
| What happened | What happens next |
|---|---|
| WhatsApp cannot be tried: no connected number, or no approved authentication template | The next channel goes straight away, before the API answers. |
| WhatsApp refuses the message | The next channel goes straight away, before the API answers. |
| WhatsApp reports the message failed | The next channel goes as soon as the report arrives, usually within seconds. |
| WhatsApp confirms delivery within F | Done. Nothing more is sent. |
| No WhatsApp delivery after F | The next channel goes. |
| The SMS is refused (for example, an unknown sender ID or a low balance) | The call goes straight away. |
| F after the SMS went, its delivery report says delivered | Done. No call is made. |
| F after the SMS went, its report says undelivered or failed, or there is no report yet | The call goes. |
| The call is placed | Done: the code has gone to its last channel. |
With ["whatsapp", "sms", "voice"] and the default 30 seconds, WhatsApp goes at once. If WhatsApp
has not delivered, the SMS goes at about 30 seconds, and if the SMS has not been confirmed delivered, the call
goes at about 60 seconds. If WhatsApp fails or refuses the message, everything moves earlier by the time saved.
-
How exact the timing is. A move normally happens within a few seconds of its due time. If
Sozuri is delayed, it catches up within about a minute and a half. A move that would be more than five
minutes late is never made, because by then the code is too old to be useful. The code is then decided as
it stands:
channelis the last channel that went, and nothing more is sent. - An SMS that is not delivered still waits. An "undelivered" report that arrives before F is read at F; it does not start the call early.
- Choose F for your network's delivery reports. If an SMS delivery report takes longer than F to come back, the call goes to someone who may already have the SMS. A longer F avoids that, but the person waits longer.
-
WhatsApp's own delivery limit. An authentication template has a delivery time limit (30 to
900 seconds,
message_send_ttl_seconds). After that limit WhatsApp stops trying and reports the message failed, and that report moves the code on. Set the limit close to F. Then a WhatsApp message that missed its chance is dropped, rather than arriving after the SMS.
Check a code
Answers the same otp object as the response, as it is now. An id that
belongs to another project, or is not an OTP id, answers 404 otp_not_found. If you did not give a
callback_url, poll this every few seconds until finishedAt appears.
{
"otp": {
"id": "otp_5f0c2a7d9e1b4c3a8f6d2e10",
"status": "delivered",
"channel": "sms",
"to": "254700000000",
"channels": ["whatsapp", "sms", "voice"],
"whatsapp": { "status": "failed", "error": "undeliverable" },
"sms": { "from": "YourSenderID", "type": "transactional", "messageId": "8f3c2a1e9b7d4c6f0a5e2d1b3c4f5a6b7c8d9e0f" },
"voice": { "status": "waiting" },
"fallback": { "after": 30, "reason": "whatsapp_failed", "at": "2026-11-11T09:00:04+00:00" },
"createdAt": "2026-11-11T09:00:00+00:00",
"finishedAt": "2026-11-11T09:00:34+00:00"
}
}
Statuses and reasons
status | Meaning |
|---|---|
sent | A channel has taken the code (channel says which). Delivery is not confirmed yet, or cannot be: a call is sent once it is placed. |
delivered | Delivery was confirmed by WhatsApp, or by the SMS's delivery report when the SMS step was checked. |
failed | No channel took the code: every one was refused, failed or could not be tried. |
When a code is decided, finishedAt is set and the webhook goes. That happens when
WhatsApp or the SMS delivers it (delivered), when it goes to its last channel (sent),
or when no channel can take it (failed). There is one exception: when WhatsApp is the last channel,
the code is decided by WhatsApp's own report, delivered or failed. An SMS that
is the last channel is decided when it goes, as sent. Its delivery report then comes through your
SMS log and SMS delivery webhooks, under sms.messageId.
fallback.reason | Why the code moved to the next channel |
|---|---|
no_whatsapp_number | The project has no connected official WhatsApp number. |
no_authentication_template | That number has no approved authentication template. |
whatsapp_refused | WhatsApp refused the message; whatsapp.error says why. |
whatsapp_failed | WhatsApp reported the message failed; whatsapp.error says why, for example undeliverable (the number is not on WhatsApp). |
timeout | WhatsApp had not confirmed delivery after fallback_after. |
sms_refused | The SMS was refused; sms.error says why. |
sms_undelivered | The SMS's delivery report said undelivered or failed. |
sms_timeout | The SMS had no delivery report after fallback_after. |
| Field | Values |
|---|---|
| whatsapp.status | pending (accepted, no report yet), delivered, read, failed, refused (refused before it was sent) and unavailable (no number or template; error says which). |
| voice.status | waiting until the call is placed, then the call's own status: queued, ringing, in_progress, completed, no_answer or failed. The call's status keeps moving after the code is decided. |
| sms.error, voice.error | Why that channel did not go. code_expired means the code had already expired, so nothing was sent on that channel. |
The otp.status webhook
If you give a callback_url, one POST arrives when the code is decided. It is
sent once: a later change, such as a late WhatsApp delivery or a read, updates
the OTP you check but sends nothing more.
{
"event": "otp.status",
"id": "otp_5f0c2a7d9e1b4c3a8f6d2e10",
"status": "sent",
"channel": "sms",
"to": "254700000000",
"channels": ["whatsapp", "sms", "voice"],
"reason": "whatsapp_failed",
"timestamp": "2026-11-11T09:00:31+00:00"
}
channelis missing when no channel took the code (failed), andreasonis missing when the first channel did the job.timestampis when the code was decided (UTC). The code itself is never in the body.-
It is signed like every Sozuri callback, with an
X-Sozuri-Signatureheader. The signing secret is under Manage API → Callback URLs → Webhook security, and the webhooks page explains how to check it. Answer with any2xx.
What happens to the code
- The code is never returned by the API, never written to a log, and never in the webhook.
- In your WhatsApp conversations the message shows as
•••••• is your verification code., without the code. - The call is never recorded.
- An SMS is an ordinary SMS, so its text, code included, appears in your SMS log like any message you send.
- Sozuri keeps the code, encrypted, only while a later channel might still need it. It is deleted as soon as the code is decided.
Billing and rate limits
- You pay for each channel used, at that channel's own rate: a WhatsApp authentication message, an SMS, and a call. A code that WhatsApp delivered in time costs one WhatsApp message. A code that also needed the SMS and the call costs all three.
-
POST /v1/otptakes up to 300 requests a minute per project, andGET /v1/otp/{id}up to 600. Past that the answer is429; wait and retry.
Error responses
An OTP refusal names the field to fix in errors:
{
"error": {
"code": "invalid_request",
"message": "The request needs changes.",
"status": 422,
"errors": { "channels": "channels is an ordered list of whatsapp, sms and voice, e.g. [\"whatsapp\", \"sms\", \"voice\"]: whatsapp can only come first and voice only last." }
}
}
| HTTP | error.code | What to do |
|---|---|---|
| 422 | invalid_request | errors names each field to fix: to, code, channels, fallback_after, sms.from, sms.type, sms.text, voice.brand, voice.repeat or callback_url. |
| 422 | no_authentication_template, no_whatsapp_number | Only when WhatsApp is the only channel and cannot be tried. Create an approved authentication template, connect a number, or add "sms" or "voice" to channels. |
| 422 | template_required | The number has several approved authentication templates: say which in template. errors.template lists them. |
| 422 | from_required, from_not_found | The project has several official WhatsApp numbers, or from is not one of them. errors.from lists them. |
| 422 | no_voice_number | voice is one of the channels, but the project has no active voice number, or voice.from is not one of them. |
| Varies | A WhatsApp refusal | Only when WhatsApp is the only channel and refuses the message. The answer is WhatsApp's own refusal (see the official WhatsApp errors), with the otp object, now failed, beside it. |
| 404 | otp_not_found | This project has no OTP with that id. |
| 500 | otp_error | Something went wrong on our side. Check the id with GET before retrying, so the person does not get the code twice. |
Three refusals come from the platform rather than the OTP API, so they have the platform's shape:
{ "error_code", "message", "retryable" }. They are 401 AUTHENTICATION_FAILED (check
the API key and project), 422 VALIDATION_FAILED (a field is missing or has the wrong
type; errors lists them) and 429 RATE_LIMITED.