WhatsApp OTP API
The Replio OTP service sends one-time passcodes to your users on WhatsApp with a single HTTP request. No WhatsApp Business Platform integration to build, no Meta app review to pass, no template plumbing to maintain. Connect your number in Replio, create a key, and send.
Overview
People open WhatsApp. Verification codes sent there get seen, and in most markets they cost a fraction of an SMS. This API sends a code through your own verified WhatsApp number, so the message arrives from your business, not from a shared shortcode. Deciding whether to use it rather than wiring it in? The service overview covers pricing, protections and where it fits, in plain language.
Two ways to use it. Pass your own code and Replio just delivers it — nothing about
your existing verification logic has to change. Or omit code and Replio
generates one, hashes and stores it, and hands you a matching
verify endpoint to check what the user typed back — no code to
write on your side at all.
send_failed handling
below for the case where the number genuinely has no WhatsApp.
Quickstart
Two steps: create a key in the dashboard, then send.
curl -X POST https://replio.live/api/otp/send \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"phone":"447911123456","code":"483920"}'
That example passes its own code, so Replio only delivers it. Drop the
code field and Replio will generate one and let you check it with
POST /api/otp/verify instead.
Authentication
Every request carries your secret key as a bearer token.
Create the key in Dashboard → Engage → WhatsApp OTP API. Only the account owner can create or rotate it.
browser_call_blocked.
Send a code
Body parameters
| Field | Description | |
|---|---|---|
phone | REQUIRED | Recipient in international format, digits only. 447911123456. A leading
+, spaces and dashes are accepted and stripped. |
code | optional | The passcode you generated, 3 to 12 characters (letters and digits only). Omit it
and Replio generates a 6-digit code itself, stores its hash, and makes it checkable via
verify — see verify_enabled below. |
ttl_seconds | optional | Only used when code is omitted. How long the generated code stays valid,
60–1800 seconds. Defaults to 300 (5 minutes). |
template_name | optional | Send on a specific approved template. Defaults to your Authentication template. |
variables | optional | Values for any non-code variables the template carries. See Template variables. |
idempotency_key | optional | Up to 80 characters. Retrying with the same key returns the first result instead of sending again. See Idempotency. |
channel | optional | "whatsapp" (default) or "email". Email needs email.
See Email codes & fallback. |
email | optional | The person's email address, for channel: "email" or fallback: "email". |
fallback | optional | "email": if WhatsApp reports the code failed, or hasn't confirmed delivery in
time, Replio emails a code automatically. Needs code omitted. |
fallback_after_seconds | optional | How long to wait for WhatsApp to confirm delivery before falling back, 15–600. Defaults to 60. |
Response
{
"ok": true,
"id": 48213,
"sent_to": "+447911123456",
"template": "verify_code",
"credits_charged": 1,
"verify_enabled": false
}
id identifies this send for delivery status and
webhooks.
verify_enabled is true only when you omitted code — that's
what tells you whether POST /api/otp/verify has anything to check
for this send.
Any non-2xx response carries a stable code you can branch on, plus a human
readable message that may be reworded at any time.
{
"detail": {
"code": "recipient_rate_limited",
"message": "That number has already had 5 codes in the last hour."
}
}
code, never on message. The codes
are part of the contract. The prose is not.Verify a code
Only checks codes Replio generated — send with code omitted, so
verify_enabled came back true. A code you supplied yourself was
never stored, so there's nothing here to check it against — keep verifying those on your side.
curl -X POST https://replio.live/api/otp/verify \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"phone":"447911123456","code":"482913"}'
Body parameters
| Field | Description | |
|---|---|---|
phone | REQUIRED | Same number the code was sent to. |
code | REQUIRED | What the user typed back. |
Response
{ "ok": true, "verified": true }
A wrong or expired code is a normal 400, not a 200 with verified: false — same
"branch on the error code" contract as send:
{ "detail": { "code": "incorrect_code", "message": "That code is incorrect." } }
Each pending code allows 5 wrong guesses before it's dead
(too_many_attempts) and expires after its ttl_seconds
(code_expired, 5 minutes by default). Sending a new code to the same number
supersedes the old one — only the latest is ever checkable. A correct code is consumed: it
cannot be verified a second time.
Email codes and fallback
Not everyone can receive WhatsApp: a landline, a typo, a phone without the app. Two ways to reach them without building a second integration:
Email only. Send with "channel": "email" and the person's email.
Verify works exactly the same, by phone.
curl -X POST https://replio.live/api/otp/send \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"phone":"447911123456","channel":"email","email":"ada@example.com"}'
WhatsApp first, email if it doesn't land. Add "fallback": "email". If
WhatsApp reports the code failed, Replio emails one straight away. If WhatsApp simply hasn't
confirmed delivery within fallback_after_seconds (60 by default), it emails one then.
A code that was delivered, or already verified, never falls back.
curl -X POST https://replio.live/api/otp/send \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"phone":"447911123456","email":"ada@example.com","fallback":"email"}'
The email carries its own code. Verify accepts either the WhatsApp code
or the emailed one, whichever the person typed, and the first correct one closes both. That is
why fallback needs Replio to generate the codes (omit code): we never store a code
in readable form, so we can't resend yours.
Delivery status
WhatsApp reports what happened to every message. Ask for one send by the id
send returned:
{
"ok": true,
"id": 48213,
"phone": "+447911123456",
"channel": "whatsapp",
"status": "sent",
"delivery_status": "delivered",
"delivered_at": "2026-10-06T09:14:03+00:00",
"error": null,
"verified": true,
"fallback": null
}
delivery_status moves forward through sent (WhatsApp accepted it),
delivered (it reached the phone) and read, or ends at
failed with WhatsApp's reason in error (for example
131026: the number can't receive WhatsApp). With a fallback,
fallback.status is pending, sent, failed,
no_balance or skipped_verified. Rather than polling, use
webhooks.
Webhooks
Set a URL in your dashboard (OTP API → Webhook) and Replio posts an event as each send
moves along. It must be a public https:// address.
| Event | When |
|---|---|
otp.sent | The code was accepted for delivery |
otp.delivered | It reached the person's phone |
otp.read | They opened it |
otp.failed | WhatsApp couldn't deliver it (data.error says why) |
otp.fallback | The email fallback went out |
otp.fallback_failed | The fallback couldn't be sent (no balance, expired, email refused) |
otp.verified | The person entered a correct code |
otp.test | You pressed "Send test event" |
{
"event": "otp.delivered",
"data": { "id": 48213, "phone": "+447911123456", "delivery_status": "delivered", …same fields as delivery status },
"sent_at": "2026-10-06T09:14:03+00:00"
}
Check the signature
Every post carries X-Replio-Timestamp and X-Replio-Signature: v1=<hex>,
an HMAC-SHA256 of timestamp + "." + raw body with your webhook secret. Reject
anything that doesn't match, or whose timestamp is more than 5 minutes old.
const crypto = require('crypto'); function isFromReplio(rawBody, ts, sigHeader, secret) { const want = 'v1=' + crypto.createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex'); return Math.abs(Date.now()/1000 - Number(ts)) < 300 && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sigHeader || '')); }
Answer with any 2xx within 5 seconds. A failed post is retried once. Events can arrive out of
order, so read data.delivery_status rather than assuming the sequence.
Number check
Check a number before you send: whether it's valid, mobile or landline, its country and original carrier, and whether your earlier WhatsApp codes to it were delivered. Free, no credits.
curl -X POST https://replio.live/api/otp/lookup \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"phone":"2348031234567"}'
{
"ok": true,
"phone": "+2348031234567",
"valid": true,
"country": "NG",
"country_code": 234,
"type": "mobile",
"carrier": "MTN",
"e164": "+2348031234567",
"national_format": "0803 123 4567",
"whatsapp": { "status": "reachable", "last_seen": "2026-10-01T08:02:11+00:00", "source": "your earlier sends" }
}
type is mobile, fixed_line, fixed_line_or_mobile,
voip, toll_free and so on, or unknown. carrier is
the network the number was first issued on; a number moved to another network still shows its
first one. whatsapp.status is reachable, unreachable or
unknown, judged only from codes you sent: we never use other businesses'
sends.
Verify widget
A ready-made "enter your code" box for your website: phone number, code entry with one-tap autofill, a resend timer and a clear success state. Two lines of HTML, and your API key never touches the browser.
1. On your server: start a session
curl -X POST https://replio.live/api/otp/sessions \ -H "Authorization: Bearer rpl_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{}' # { "ok": true, "id": "vs_9fK…", "client_secret": "vs_9fK…_secret_…", "expires_at": "…" }
All fields are optional: phone locks the widget to one number (the person can't
change it), email plus "fallback": "email" turns on the
email fallback, and ttl_seconds sets the session's life
(60–3600, default 900). A test key makes a test session.
2. On your page: mount the widget
<div id="verify"></div> <script src="https://replio.live/otp-widget.js"></script> <script> ReplioVerify.mount('#verify', { clientSecret: 'vs_9fK…_secret_…', // from step 1 onVerified: function (r) { // r.sessionId → tell your server, which confirms it in step 3 } }); </script>
| Option | Description |
|---|---|
clientSecret | Required. From step 1. |
phone | Skip the number screen and send straight away (use with a locked session). |
allowEmail | Show "Email me a code instead" (needs email on the session). |
theme | "light" (default), "dark" or "auto". |
accent | Button and focus colour, e.g. "#0a7cff". |
text | Override any wording, e.g. { title: "Confirm your phone" }. |
onVerified, onSent, onError | Callbacks. |
3. On your server: confirm the result
{ "ok": true, "id": "vs_9fK…", "verified": true, "phone": "+2348031234567", "verified_at": "…" }
GET /api/otp/sessions/{id} with your key proves the number was verified.Each session allows 4 codes and 10 guesses for one number. Codes, credits, fallback and webhooks work exactly as for /api/otp/send.
Templates
WhatsApp requires business-initiated messages to use a template Meta has approved. You do not have to write one from scratch: Meta ships a library of ready-made ones.
In WhatsApp Manager → Message templates → Template library, filter to Authentication and pick one. Approval is usually quick because the wording is Meta's own.
Replio picks your template automatically in this order:
| Order | What is chosen |
|---|---|
| 1 | Any approved Authentication template |
| 2 | An approved Utility template containing a code variable |
| 3 | Whatever you name in template_name, if approved and in one of
those two categories |
template_not_allowed, including
when you name it explicitly.
Template variables
Templates contain placeholders. The classic Authentication template has exactly one:
{{1}} is your verification code.
Nothing to do here. The code fills it automatically, including the one-tap copy button.
Many templates in Meta's library use named placeholders instead, and carry more than one:
Hi {{name}}, your {{item}} order is pending shipment.
The delivery person may ask for your delivery code {{code}}.
Anything named code, otp, pin or
verification_code is filled with your code automatically. Everything else you
supply:
{
"phone": "447911123456",
"code": "483920",
"variables": { "name": "Sam", "item": "jacket" }
}
Miss one and the error names exactly which:
{ "code": "missing_variables",
"message": "Template 'delivery_code_2' also needs: item." }
Up to 12 variables, each 120 characters or fewer.
Idempotency
Networks time out after the send has already happened. Pass an
idempotency_key and a retry returns the original result rather than sending a
second code and spending a second credit.
curl -X POST https://replio.live/api/otp/send \ -H "Authorization: Bearer $REPLIO_OTP_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"447911123456","code":"483920", "idempotency_key":"signup-8f31c2a4"}'
A replayed request answers with "idempotent_replay": true. Use something tied to
the attempt, such as your own session or signup id.
Test mode
Build the integration without spending credits or messaging real people. A test key validates the entire request, applies every rule, and stops short of sending or checking anything real.
A test key is a separate credential from your live one — create it in Dashboard → Engage → WhatsApp OTP API ("Create test key"), or via the API:
curl -X POST https://replio.live/api/otp/rotate \ -H "Authorization: Bearer $REPLIO_LIVE_KEY" \ -H "Content-Type: application/json" \ -d '{"test":true}'
That request is authenticated with your existing live key (only the account owner can
mint either kind) and returns a new key starting rpl_sk_test_. Rotating it never
touches your live key, and vice versa — they're independent credentials that can both be
live at once.
{ "ok": true, "sent_to": "+447911123456", "test_mode": true }
Test sends appear in your logs marked as tests, and never bill.
Verify works the same way in test mode — any phone
and code you send it comes back verified: true, no real code needed.
Rate limits
| Limit | Value | Error code |
|---|---|---|
| Per account, per minute | 60 | rate_limited |
| Per account, per day | 5,000 | daily_limit |
| Per recipient, per hour | 5 | recipient_rate_limited |
| Verify attempts, per recipient, per 10 min | 10 | too_many_attempts |
| Wrong guesses, per code | 5 | too_many_attempts |
The per-recipient limit is the important one. It stops a single number being pumped with codes, which costs you money and gets numbers reported. Need higher limits for a launch? Ask us.
Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 401 | missing_key | No Authorization header |
| 401 | invalid_key | Key not recognised, or has been rotated |
| 403 | account_inactive | Replio account is not active |
| 403 | browser_call_blocked | Request came from a browser — call from your server instead |
| 400 | invalid_json | Body is not a valid JSON object |
| 400 | invalid_phone | Not 7 to 15 digits with country code |
| 400 | invalid_code | Not 3 to 12 letters and digits |
| 400 | invalid_variables | Too many, or a value over 120 chars |
| 400 | missing_variables | Template needs values you did not send |
| 400 | template_not_allowed | Named template is not approved Authentication or Utility |
| 400 | no_template | No usable approved template on the account |
| 400 | whatsapp_not_connected | No WhatsApp number connected |
| 400 | no_waba | No WhatsApp Business Account found |
| 400 | invalid_ttl | ttl_seconds outside 60–1800 |
| 400 | invalid_channel | channel is not whatsapp or email |
| 400 | missing_email | Email channel or fallback without an email |
| 400 | invalid_email | email isn't a valid address |
| 400 | invalid_fallback | Unknown fallback, or fallback_after_seconds outside 15–600 |
| 400 | fallback_needs_generated_code | Fallback needs code omitted |
| 404 | not_found | No send with that id on this account (status) |
| 402 | no_balance | Out of OTP credits |
| 429 | rate_limited | Per-minute limit |
| 429 | daily_limit | Per-day limit |
| 429 | recipient_rate_limited | Too many codes to one number |
| 502 | upstream_error | WhatsApp unreachable. Safe to retry |
| 502 | send_failed | WhatsApp rejected the message |
| 400 | no_pending_code | Nothing to check — none generated, or already verified |
| 400 | code_expired | Past its ttl_seconds |
| 400 | incorrect_code | Doesn't match |
| 429 | too_many_attempts | 5 wrong guesses on this code, or 10 verify calls on this number in 10 min |
Retry upstream_error and rate_limited with backoff. Every 429
carries a Retry-After header (seconds) — honour it. Everything in
the 400 range needs a change to the request. Always retry with the same
idempotency_key.
Credits and pricing
OTP sends draw on their own prepaid balance, separate from your Replio message allowance. That is deliberate: your users' sign-ins should never fail because your support inbox had a busy month.
| Pack | Price | Per OTP |
|---|---|---|
| 1,000 credits | $35 | $0.035 |
| 10,000 credits | $300 | $0.030 |
| 50,000 credits | $1,250 | $0.025 |
Credits never expire. Only a delivered send costs credits: rejected requests, rate limits, failed sends and test-mode calls are all free.
Premium destinations
One credit sends to almost everywhere. Three destinations cost two credits, because WhatsApp itself charges several times more to deliver there:
| Destination | Dial code | Credits |
|---|---|---|
| Indonesia | +62 | 2 |
| United Arab Emirates | +971 | 2 |
| Malaysia | +60 | 2 |
| Everywhere else | — | 1 |
Every response tells you exactly what it cost, so you never have to infer it:
{ "ok": true, "sent_to": "+6281234567890",
"template": "verify_code", "credits_charged": 2 }
Your live balance, usage and the current pack prices are on the dashboard and at
GET /api/otp/config.
How this compares
Comparing providers? See Replio as a Twilio Verify alternative or a Dexatel alternative for WhatsApp OTP.
Twilio Verify charges $0.05 per verification plus the channel fee, about $0.053 for a WhatsApp code, at every volume. There is no separate monthly platform fee here: OTP credits are an add-on to the Replio account you already have.
Security
- Keys are stored as a hash. Nobody, including us, can read your key back to you.
- Only the account owner can create or rotate keys.
- Rotating takes effect immediately, so a leaked key can be killed in one click.
- Never put the key in front-end code. It sends from your verified business number.
- Generate codes with a cryptographically secure random source, and expire them quickly. (Let Replio generate the code and this is already handled for you.)
- Codes Replio generates are stored as a sha256 hash, never in plain text.
FAQ
Do you generate and verify the code for me?
Yes, if you want that. Omit code on send and Replio generates
it, stores only its hash, and you check what the user typed with
POST /api/otp/verify. Prefer to keep owning verification yourself?
Pass your own code and nothing changes — Replio just delivers it, same as
before this existed.
Can I use my own WhatsApp number?
Yes, and you should. Codes arrive from your verified business number.
What if the user has no WhatsApp?
Add "fallback": "email" and their email address, and Replio emails a code when
WhatsApp can't deliver. See Email codes & fallback. You can also
check the number first.
Which countries?
Anywhere WhatsApp operates. Per-message pricing is set by Meta and varies by country.
Is there an SDK?
Not needed. It is one JSON POST, shown above in three languages. Delivery results come to you by webhook, and the verify widget gives you the code-entry screen without building one.