Before you start
- A running gateway and its base URL. If you do not have one yet, self-hosting takes about ten minutes.
- An API key from the dashboard, under api keys.
- At least one channel configured by the operator: a Meta WhatsApp Business account (whatsapp) or a Telegram bot (telegram).
Put the base URL and the key in environment variables. A key in source, in a log, or in a client bundle is a leaked key, and it is a spending credential.
1 · send a code
curl -X POST "$WOTP_API/v1/otp/send" \
-H "X-Api-Key: $WOTP_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "919876543210"}'The only required field is to. Formatting is forgiving: +, spaces and dashes are ignored, a bare 10-digit number is read as India, and a leading trunk 0 is dropped. Send "channel": "telegram" to use the free channel instead.
The response carries everything your UI needs:
{
"ok": true,
"channel": "whatsapp",
"request_id": "m8f3k2m9xq01zb4",
"message_id": "wamid.XXXXXXXXXX",
"expires_in": 300,
"used": 42,
"limit": 500,
"reset_utc": "2026-10-01T00:00:00Z"
}- Build the countdown from
expires_in, not a hardcoded 300. The operator can change it. - Keep
request_id. It is the fastest way to locate the send in the audit log when a user says nothing arrived. - Give your HTTP client a timeout of at least 30 seconds. The gateway waits up to about 15 on the provider.
2 · verify the code
curl -X POST "$WOTP_API/v1/otp/verify" \
-H "X-Api-Key: $WOTP_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "919876543210", "code": "123456"}'A success is exactly { "ok": true, "verified": true }. Act on that and nothing else. Never treat “the user says they got it” as verification.
Two behaviours surprise people, and both are deliberate:
- Codes are single-use. A code dies the moment it verifies. Verifying the same code twice returns
code_expiredon the second call, even though the first call succeeded. - Only the newest code counts. If you send twice for one screen, the first code stops working. Do not send twice; if you must, tell the user to use the newest message.
3 · handle these four
Everything else can wait until you need it. These four cannot, because each one has a different correct response and getting it wrong produces either a stuck user or a retry storm.
- 409
user_not_linked(telegram only). The phone has never spoken to your bot, so the message bounced. Show a button that opens thelink_urlfrom the response, then retry the same send. The bounce cost nothing. - 429 (three flavours:
rate_limited,phone_throttled,quota_exceeded). Sleep for theRetry-Afterheader. The header is authoritative and differs per flavour, from 60 seconds to the rest of the month. - 502
delivery_failed. Readretryablebefore you do anything.truemeans a transient provider hiccup, so retry with backoff.falsemeans the provider will refuse again: the number is not on WhatsApp, or the chat is blocked. Do not retry; tell the user to check the number. - 400. Your request is wrong. Retrying it unchanged changes nothing.
One more rule that covers the rest: never retry a 4xx, do retry a 503. And send an Idempotency-Key header on every send, which makes a retry safe even in the one case where the message was delivered but the gateway failed to record it.
The whole flow, in Node and Python
Sending is the short part. The error branches are the integration.
// Node 18+. The key stays on the server: never ship it to a browser.
const r = await fetch(process.env.WOTP_API + '/v1/otp/send', {
method: 'POST',
headers: {
'X-Api-Key': process.env.WOTP_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ to: '919876543210', channel: 'whatsapp' })
});
const sent = await r.json();
if (!r.ok) {
if (sent.error === 'user_not_linked') return showConnectTelegram(sent.link_url);
if (r.status === 429) {
// The response tells you exactly how long to wait. Do not guess.
return setTimeout(retry, Number(r.headers.get('Retry-After')) * 1000);
}
if (sent.retryable === false) return tellUserToCheckTheNumber();
throw new Error(sent.error);
}
// Build the countdown from the response, not a hardcoded 300.
startTimer(sent.expires_in);# Python 3.11+ with httpx
import os, httpx
r = httpx.post(
f"{os.environ['WOTP_API']}/v1/otp/send",
headers={"X-Api-Key": os.environ["WOTP_KEY"]},
json={"to": "919876543210", "channel": "whatsapp"},
timeout=30, # the gateway itself waits up to ~15s on the provider
)
r.raise_for_status()
sent = r.json()
start_timer(sent["expires_in"])Next
- API reference: every endpoint, field and error code.
- WhatsApp setup: the Meta work that has to happen before any of this sends a real message.
- FAQ: the questions that decide whether this fits your project.