The OonoBox OTP API turns your existing OonoBox workspace into a WhatsApp OTP backend. Your server calls two endpoints; we handle code generation, message delivery via the WhatsApp Cloud API, expiry, and verification.
What you'll build
By the end you'll be able to:
- Send a 4–10 digit code to any WhatsApp number via an approved authentication template.
- Verify the code the customer types, with built-in brute-force protection.
- Per-recipient rate limiting and per-channel attempt caps configurable from the OonoBox dashboard.
Prerequisites
- An OonoBox workspace.
- A connected WhatsApp number in that workspace (
Settings → Numbers). - An approved authentication-category template(
Settings → Templates→ categoryAuthentication, body containing exactly one{{1}}placeholder for the code). Meta typically approves auth templates within minutes. - An API key with the scopes
otp.sendandotp.verify(Settings → API keys). - An OTP channel binding the above (
Settings → OTP).
Authentication
Every request to /api/v1/otp/* uses bearer-token authentication. Send your API key in the Authorization header. Keys look like oono_sk_live_ followed by 32 url-safe characters and are shown once at creation. If you lose a key, revoke it from the dashboard and create a new one.
Authorization: Bearer oono_sk_live_AbCd1234EfGh5678iJkL9012MnOp3456A missing or invalid key, a revoked key, or a key without the right scope all return 401 NOT_AUTHENTICATED. The response is identical in every case to avoid leaking which check failed.
Send a code
POST https://api.oonobox.co.zw/api/v1/otp/send
Required scope: otp.send
Request body
| Field | Type | Description |
|---|---|---|
to | string | Recipient phone number in E.164 (with or without the leading +). 8–15 digits. |
channelId | string | The OTP channel id (otpc_…) configured in the dashboard. Must belong to the API key's workspace. |
Response
| Field | Type | Description |
|---|---|---|
id | string | The OTP request id (otpr_…). Pass this back when verifying. |
expiresAt | string (ISO-8601) | When the code stops being valid. Defaults to 5 minutes after send; channel-configurable. |
Example
curl -X POST https://api.oonobox.co.zw/api/v1/otp/send \
-H "Authorization: Bearer $OONO_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+263772345678",
"channelId": "otpc_01ksp..."
}'{
"id": "otpr_01ksqr...",
"expiresAt": "2026-06-04T07:08:23.000Z"
}Verify a code
POST https://api.oonobox.co.zw/api/v1/otp/verify
Required scope: otp.verify
Request body
| Field | Type | Description |
|---|---|---|
id | string | The otpr_… id returned from /send. |
code | string | The digits the customer typed. Length must match the channel's configured code length. |
Response
On success: { "verified": true }. On failure, a reason tells you which check failed.
reason | Meaning |
|---|---|
invalid_code | Code didn't match. The customer can try again until max_attempts is reached. |
expired | The code passed its expiry. Send a new one. |
exhausted | Too many wrong attempts. This OTP request is locked; send a new one. |
unknown | The id doesn't exist or belongs to another workspace. |
Example
curl -X POST https://api.oonobox.co.zw/api/v1/otp/verify \
-H "Authorization: Bearer $OONO_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "otpr_01ksqr...",
"code": "123456"
}'{ "verified": true }{ "verified": false, "reason": "invalid_code" }End-to-end examples
Node.js (built-in fetch)
const OONO_KEY = process.env.OONO_KEY;
const CHANNEL_ID = process.env.OONO_OTP_CHANNEL;
const BASE = "https://api.oonobox.co.zw";
async function sendOtp(toPhone) {
const res = await fetch(`${BASE}/api/v1/otp/send`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OONO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to: toPhone, channelId: CHANNEL_ID }),
});
if (!res.ok) throw new Error(`OTP send failed: ${res.status}`);
return res.json(); // { id, expiresAt }
}
async function verifyOtp(id, code) {
const res = await fetch(`${BASE}/api/v1/otp/verify`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OONO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ id, code }),
});
if (!res.ok) throw new Error(`OTP verify failed: ${res.status}`);
return res.json(); // { verified: boolean, reason?: string }
}Python (requests)
import os
import requests
OONO_KEY = os.environ["OONO_KEY"]
CHANNEL_ID = os.environ["OONO_OTP_CHANNEL"]
BASE = "https://api.oonobox.co.zw"
HEADERS = {
"Authorization": f"Bearer {OONO_KEY}",
"Content-Type": "application/json",
}
def send_otp(to_phone: str) -> dict:
r = requests.post(
f"{BASE}/api/v1/otp/send",
json={"to": to_phone, "channelId": CHANNEL_ID},
headers=HEADERS,
timeout=10,
)
r.raise_for_status()
return r.json() # { id, expiresAt }
def verify_otp(otp_id: str, code: str) -> dict:
r = requests.post(
f"{BASE}/api/v1/otp/verify",
json={"id": otp_id, "code": code},
headers=HEADERS,
timeout=10,
)
r.raise_for_status()
return r.json() # { verified, reason? }Rate limits
Each OTP channel has a per-recipient send limit (default 3 per phone per hour; configurable 1–100). When a recipient hits the limit, /send returns:
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many OTP sends to this number in the last hour (limit 3).",
"details": null
}
}Brute-force is bounded separately by the channel's max attempts per OTP request (default 5; configurable 1–20). After that many wrong codes against the same id, future/verify calls return verified: false with reason: "exhausted" — the customer must request a fresh code.
Idempotency and retries
- /sendis not idempotent. Each call creates a new code and counts against the per-phone rate limit. Always use a unique idempotency-style guard on your side (e.g., debounce the user's "Send code" button) before calling us.
- /verify is idempotent on success: once an OTP request is verified, subsequent valid
/verifycalls for the sameid+ correct code keep returningverified: true. This lets your retry loop survive transient network failures after a successful verification. - Verification attempts count against
max_attemptsbefore the comparison runs. An attacker racing parallel requests gets no extra tries.
Error response format
Failures use a single envelope across the whole API:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Recipient phone must be E.164 (8-15 digits).",
"details": null
}
}| HTTP | code | When you'll see it |
|---|---|---|
| 400 | VALIDATION_FAILED | Bad shape: missing fields, wrong types, phone too short. |
| 401 | NOT_AUTHENTICATED | Missing / invalid / revoked / wrong-scope API key. |
| 404 | NOT_FOUND | OTP channel id doesn't exist in this workspace. |
| 409 | CONFLICT | The channel is paused. |
| 422 | META_ERROR | WhatsApp Cloud API failure. The message body forwards Meta's reason. |
| 422 | TEMPLATE_NOT_APPROVED | Channel's template lost approval (paused, rejected, or disabled). |
| 429 | RATE_LIMITED | Per-phone send limit exceeded for this channel. |
What we store, what we don't
- Plaintext codes are never persisted.We hash with SHA-256 on the way in, compare timing-safely on the way out. A database leak doesn't reveal codes that were ever sent.
- API keys are hashed. Same SHA-256 deterministic pattern. The full key is shown once at creation and unrecoverable after.
- Each request logs the recipient phone (in E.164), the channel, send timestamp, verify timestamp, attempts count, and final status. Available in the dashboard for audit.
Next steps
- Create your first API key in the OonoBox dashboard.
- Configure an OTP channel with your number, template, and policy.
- Need help? Email developers@oonobox.co.zw — we read every message.