WhatsApp OTP API
Send 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.
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.
Quickstart
Two steps: create a key in the dashboard, then send.
curl -X POST https://engine-production-2647.up.railway.app/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.
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. |
Response
{
"ok": true,
"sent_to": "+447911123456",
"template": "verify_code",
"credits_charged": 1,
"verify_enabled": false
}
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://engine-production-2647.up.railway.app/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.
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://engine-production-2647.up.railway.app/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://engine-production-2647.up.railway.app/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 |
| 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 |
| 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. 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
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?
The send fails with send_failed. Fall back to your existing SMS or email path.
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.