API reference

WhatsApp OTP API.

Two endpoints. Send a one-time code over WhatsApp from your backend, verify what the customer types. Brute-force protection, rate-limits, expiry, and audit log included.

Last updated 4 June 2026 · v1

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

  1. An OonoBox workspace.
  2. A connected WhatsApp number in that workspace (Settings → Numbers).
  3. An approved authentication-category template(Settings → Templates → category Authentication, body containing exactly one{{1}} placeholder for the code). Meta typically approves auth templates within minutes.
  4. An API key with the scopes otp.send and otp.verify(Settings → API keys).
  5. 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.

HTTP header
Authorization: Bearer oono_sk_live_AbCd1234EfGh5678iJkL9012MnOp3456

A 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

FieldTypeDescription
tostringRecipient phone number in E.164 (with or without the leading +). 8–15 digits.
channelIdstringThe OTP channel id (otpc_…) configured in the dashboard. Must belong to the API key's workspace.

Response

FieldTypeDescription
idstringThe OTP request id (otpr_…). Pass this back when verifying.
expiresAtstring (ISO-8601)When the code stops being valid. Defaults to 5 minutes after send; channel-configurable.

Example

curl
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..."
  }'
Response 200
{
  "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

FieldTypeDescription
idstringThe otpr_… id returned from /send.
codestringThe 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.

reasonMeaning
invalid_codeCode didn't match. The customer can try again until max_attempts is reached.
expiredThe code passed its expiry. Send a new one.
exhaustedToo many wrong attempts. This OTP request is locked; send a new one.
unknownThe id doesn't exist or belongs to another workspace.

Example

curl
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"
  }'
Response 200 (success)
{ "verified": true }
Response 200 (failure)
{ "verified": false, "reason": "invalid_code" }

End-to-end examples

Node.js (built-in fetch)

JavaScript · Node 18+
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)

Python 3.8+
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:

Response 429 RATE_LIMITED
{
  "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 /verify calls for the same id + correct code keep returning verified: true. This lets your retry loop survive transient network failures after a successful verification.
  • Verification attempts count against max_attempts before the comparison runs. An attacker racing parallel requests gets no extra tries.

Error response format

Failures use a single envelope across the whole API:

HTTP 4xx / 5xx
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Recipient phone must be E.164 (8-15 digits).",
    "details": null
  }
}
HTTPcodeWhen you'll see it
400VALIDATION_FAILEDBad shape: missing fields, wrong types, phone too short.
401NOT_AUTHENTICATEDMissing / invalid / revoked / wrong-scope API key.
404NOT_FOUNDOTP channel id doesn't exist in this workspace.
409CONFLICTThe channel is paused.
422META_ERRORWhatsApp Cloud API failure. The message body forwards Meta's reason.
422TEMPLATE_NOT_APPROVEDChannel's template lost approval (paused, rejected, or disabled).
429RATE_LIMITEDPer-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