OTP API Dashboard Get an API key

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.

From $0.025 per code One POST request We can verify it too Test mode included Idempotent No monthly fee

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.

What you need first. A Replio account with WhatsApp connected, at least one approved Authentication or Utility template on your WhatsApp Business Account, and OTP credits. All three are set up from your dashboard.

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"}'
const res = await fetch("https://engine-production-2647.up.railway.app/api/otp/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.REPLIO_OTP_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ phone: "447911123456", code: "483920" })
});
const data = await res.json();
// { ok: true, sent_to: "+447911123456", template: "verify_code" }
import os, requests

resp = requests.post(
    "https://engine-production-2647.up.railway.app/api/otp/send",
    headers={"Authorization": f"Bearer {os.environ['REPLIO_OTP_KEY']}"},
    json={"phone": "447911123456", "code": "483920"},
    timeout=15,
)
resp.raise_for_status()
# {'ok': True, 'sent_to': '+447911123456', 'template': 'verify_code'}

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.

Authorization: Bearer rpl_sk_<your key>

Create the key in Dashboard → Engage → WhatsApp OTP API. Only the account owner can create or rotate it.

The key is shown once. We store only a hash of it, so it cannot be shown again, and nobody at Replio can read it back to you. Save it in your secret manager when you create it. Lost it? Rotate to get a new one. Rotating stops the old key immediately.

Send a code

POSThttps://engine-production-2647.up.railway.app/api/otp/send

Body parameters

FieldDescription
phoneREQUIRED Recipient in international format, digits only. 447911123456. A leading +, spaces and dashes are accepted and stripped.
codeoptional 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_secondsoptional Only used when code is omitted. How long the generated code stays valid, 60–1800 seconds. Defaults to 300 (5 minutes).
template_nameoptional Send on a specific approved template. Defaults to your Authentication template.
variablesoptional Values for any non-code variables the template carries. See Template variables.
idempotency_keyoptional 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."
  }
}
Branch on 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.

POSThttps://engine-production-2647.up.railway.app/api/otp/verify
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

FieldDescription
phoneREQUIRED Same number the code was sent to.
codeREQUIRED 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.

What's stored. Only a sha256 hash of the code, never the code itself — the same model your API key uses. A database read cannot recover a code that was never written down.

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:

OrderWhat is chosen
1Any approved Authentication template
2An approved Utility template containing a code variable
3Whatever you name in template_name, if approved and in one of those two categories
Marketing templates are refused. Sending a verification code on a Marketing template bypasses opt-out and WhatsApp's marketing rules, and is a common way to get a number restricted or banned. The API rejects it with 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.

Authorization: Bearer rpl_sk_test_<your test key>
{ "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

LimitValueError code
Per account, per minute60rate_limited
Per account, per day5,000daily_limit
Per recipient, per hour5recipient_rate_limited
Verify attempts, per recipient, per 10 min10too_many_attempts
Wrong guesses, per code5too_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

HTTPCodeMeaning
401missing_keyNo Authorization header
401invalid_keyKey not recognised, or has been rotated
403account_inactiveReplio account is not active
400invalid_phoneNot 7 to 15 digits with country code
400invalid_codeNot 3 to 12 letters and digits
400invalid_variablesToo many, or a value over 120 chars
400missing_variablesTemplate needs values you did not send
400template_not_allowedNamed template is not approved Authentication or Utility
400no_templateNo usable approved template on the account
400whatsapp_not_connectedNo WhatsApp number connected
400no_wabaNo WhatsApp Business Account found
400invalid_ttlttl_seconds outside 60–1800
402no_balanceOut of OTP credits
429rate_limitedPer-minute limit
429daily_limitPer-day limit
429recipient_rate_limitedToo many codes to one number
502upstream_errorWhatsApp unreachable. Safe to retry
502send_failedWhatsApp rejected the message
400no_pending_codeNothing to check — none generated, or already verified
400code_expiredPast its ttl_seconds
400incorrect_codeDoesn't match
429too_many_attempts5 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.

PackPricePer 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:

DestinationDial codeCredits
Indonesia+622
United Arab Emirates+9712
Malaysia+602
Everywhere else1

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.

Ready to start? Create your key in the dashboard, or read the setup guide for connecting WhatsApp first.