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.

POST https://sozuri.net/api/v1/otp

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.
  • channels sets the order. Use whatsapp, sms and voice, 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_after seconds 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

ChannelWhat your project needs
whatsappAn 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.
smsA sender ID registered on the project. It is transactional unless you say promotional, and it must be named exactly as registered.
voiceAn 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

POST https://sozuri.net/api/v1/otp
FieldRequiredTypeDescription
projectYesStringYour project's name.
toYesStringThe 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.
codeYesStringThe 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.
channelsNoArrayThe order to try, as described in How it works. Default ["whatsapp", "sms"].
fallback_afterNoIntegerHow many seconds each channel waits for delivery before the next one goes. A whole number from 10 to 600, default 30.
callback_urlNoStringWhere 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.
WhatsApp
templateNoString or objectWhich 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.
fromNoStringWhich of your official WhatsApp numbers to send from (254711000000). You only need this when the project has more than one.
SMS
sms.fromWith SMSStringOne of your sender IDs, exactly as registered.
sms.typeNoStringtransactional (default) or promotional, matching how that sender ID is registered.
sms.textNoStringYour own wording, up to 320 characters, with {code} where the code goes. Default: {code} is your verification code.
Voice
voice.brandNoStringA 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.repeatNoIntegerHow many times the code is read: 1 to 3, default 2.
voice.fromNoStringWhich 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"
  }
}
FieldDescription
idThe OTP's id: otp_ followed by 24 hex characters. Keep it to check the code.
statussent, delivered or failed. See Statuses and reasons.
channelThe 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.
channelsThe order you asked for.
whatsappPresent when WhatsApp is one of the channels. status, plus error when it was refused or failed, and deliveredAt once WhatsApp confirms delivery.
smsPresent 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.
voicePresent 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.
fallbackPresent 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, finishedAtWhen 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 happenedWhat happens next
WhatsApp cannot be tried: no connected number, or no approved authentication templateThe next channel goes straight away, before the API answers.
WhatsApp refuses the messageThe next channel goes straight away, before the API answers.
WhatsApp reports the message failedThe next channel goes as soon as the report arrives, usually within seconds.
WhatsApp confirms delivery within FDone. Nothing more is sent.
No WhatsApp delivery after FThe 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 deliveredDone. No call is made.
F after the SMS went, its report says undelivered or failed, or there is no report yetThe call goes.
The call is placedDone: 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: channel is 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

GET https://sozuri.net/api/v1/otp/{id}?project=your_project_name

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

statusMeaning
sentA channel has taken the code (channel says which). Delivery is not confirmed yet, or cannot be: a call is sent once it is placed.
deliveredDelivery was confirmed by WhatsApp, or by the SMS's delivery report when the SMS step was checked.
failedNo 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.reasonWhy the code moved to the next channel
no_whatsapp_numberThe project has no connected official WhatsApp number.
no_authentication_templateThat number has no approved authentication template.
whatsapp_refusedWhatsApp refused the message; whatsapp.error says why.
whatsapp_failedWhatsApp reported the message failed; whatsapp.error says why, for example undeliverable (the number is not on WhatsApp).
timeoutWhatsApp had not confirmed delivery after fallback_after.
sms_refusedThe SMS was refused; sms.error says why.
sms_undeliveredThe SMS's delivery report said undelivered or failed.
sms_timeoutThe SMS had no delivery report after fallback_after.
FieldValues
whatsapp.statuspending (accepted, no report yet), delivered, read, failed, refused (refused before it was sent) and unavailable (no number or template; error says which).
voice.statuswaiting 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.errorWhy 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"
}
  • channel is missing when no channel took the code (failed), and reason is missing when the first channel did the job.
  • timestamp is 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-Signature header. The signing secret is under Manage API → Callback URLs → Webhook security, and the webhooks page explains how to check it. Answer with any 2xx.

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/otp takes up to 300 requests a minute per project, and GET /v1/otp/{id} up to 600. Past that the answer is 429; 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." }
  }
}
HTTPerror.codeWhat to do
422invalid_requesterrors names each field to fix: to, code, channels, fallback_after, sms.from, sms.type, sms.text, voice.brand, voice.repeat or callback_url.
422no_authentication_template, no_whatsapp_numberOnly 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.
422template_requiredThe number has several approved authentication templates: say which in template. errors.template lists them.
422from_required, from_not_foundThe project has several official WhatsApp numbers, or from is not one of them. errors.from lists them.
422no_voice_numbervoice is one of the channels, but the project has no active voice number, or voice.from is not one of them.
VariesA WhatsApp refusalOnly 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.
404otp_not_foundThis project has no OTP with that id.
500otp_errorSomething 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.