Overview
The Tambua API is a JSON REST API. Every request goes to one base URL over HTTPS.
The flow is always the same: you send an OTP to a phone number or email address, we deliver it, and the user types the code into your app. You then verify the code with the otp_token we gave you. Codes are single-use and expire. We store only a salted hash of the code.
Authentication
Create API keys in the consoleunder Keys & webhooks. Send the key as a Bearer token on every request. Keys are either test (free, never reaches a handset) or live (bills your wallet). Keep keys on your server, never in a mobile app or browser bundle.
Authorization: Bearer $TAMBUA_KEY
Scopes
| Scope | Allows |
|---|---|
| send_otp | POST /v1/otp/send |
| verify_otp | POST /v1/otp/verify and POST /v1/otp/{otp_id} |
| manage_webhooks | GET, POST, PATCH, DELETE /v1/webhooks |
| read_logs | GET /v1/logs |
Quickstart
Send a code, then verify it. Replace the number with your own.
curl -X POST https://api.tambua.me/v1/otp/send \
-H "Authorization: Bearer $TAMBUA_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"+254712345678","purpose":"login"}'The response contains an otp_token. Keep it until the user submits the code:
curl -X POST https://api.tambua.me/v1/otp/verify \
-H "Authorization: Bearer $TAMBUA_KEY" \
-H "Content-Type: application/json" \
-d '{"otp_token":"<otp_token from the send response>","code":"482910"}'Node.js SDK
The same flow with the official SDK (zero dependencies, Node 18+, TypeScript types included). Failures throw a TambuaError with the stable error code, and webhooks.constructEvent verifies webhook signatures for you.
npm install tambua
import { Tambua } from "tambua";
const tambua = new Tambua(process.env.TAMBUA_KEY);
const sent = await tambua.otp.send({ phone: "+254712345678", purpose: "login" });
// keep sent.otp_token until the user submits the code
const ok = await tambua.otp.check({ otp_token: sent.otp_token, code: "482910" }); // true or falseSend an OTP
Send one request object, or an array of up to many request objects (the body may also be { "items": [...] }). An array returns one result per item.
Request body
| Field | Description |
|---|---|
| phone | Destination in international format, e.g. +254712345678. Use phone or to for SMS, WhatsApp, voice and M-PESA. |
| Destination email address for channel=email. If both phone and email are sent, a delivery journey can use either. | |
| to | Alternative to phone or email. An address containing @ is treated as email. |
| purpose | A short label for why you are sending, e.g. login, signup, payment. Shown in logs. Default: authentication. |
| channel | Optional. One of sms, whatsapp, voice, email, mpesa. Omit it to run your delivery journey (see below). Passing it forces exactly one channel. |
| code | Optional. Your own 4 to 10 digit code. If omitted we generate one using your configured length. |
| template_id | Optional. A template from the catalog for the channel, e.g. sms-standard. |
| reference | Optional. Your own reference string, stored with the request. |
Response
A single request returns 201 Created. An array returns 201 if every item succeeded, otherwise 207 with a per-item result.
{
"otp_id": "tambua_otp_8f3a9c12",
"status": "pending",
"channel": "sms",
"delivery": { "status": "sent" },
"attempts": { "used": 0, "max": 5 },
"expires_at": "2026-05-25T14:37:24.110Z",
"template": { "id": "sms-standard", "custom": {} },
"otp_token": "eyJhbGciOi..."
}| Field | Meaning |
|---|---|
| otp_id | Unique ID of this OTP. Use it in logs and webhooks. |
| status | pending, verified, expired or locked. |
| delivery.status | pending, sent, delivered, undelivered or failed, as reported by the carrier. |
| attempts | Verification attempts used and the maximum allowed. |
| expires_at | When the code stops being valid. |
| otp_token | A signed token you pass back to verify. Treat it as a secret for this OTP. |
| journey | Present when a delivery journey ran: step, steps and the attempts made. |
| test_code | Test keys only: the code itself, so you can test without a phone. |
Verify an OTP
You can also call POST /v1/otp/{otp_id} with the same body.
| Field | Description |
|---|---|
| otp_token | The token returned by the send call. |
| code | The code the user typed. |
{
"verified": true,
"otp_id": "tambua_otp_8f3a9c12",
"status": "verified",
"attempts": { "used": 1, "max": 5 },
"verified_at": "2026-05-25T14:32:24.108Z"
}A wrong code returns 400 invalid_code and counts as an attempt. After the maximum, the OTP is locked and returns 429 max_attempts_exceeded. An expired code returns 401 expired_token. Calling verify again for an OTP that is already verified returns the same verified response, so make your own login step single-use.
Delivery journey (automatic fallback)
A delivery journey is an ordered list of up to three channels per app, for example SMS, then WhatsApp, then Voice. Set it in the console under Settings, Delivery journey. When a request has no channel, Tambua tries the steps in order until one delivers.
- Failure fallback. If a channel fails to send, or the carrier reports a failure, the next step runs immediately.
- Timed fallback (optional). Each step can instead fall back after 1, 2, 5 or 10 minutes without a delivery receipt.
- One OTP. The same code and the same
otp_tokenare used on every attempt. You verify once. - Billing. Only attempts a carrier accepts are billed. Failed attempts are free. A step is skipped if the wallet cannot cover it.
- Forcing a channel. Passing
channelskips the journey and uses exactly that channel. - Country limits. A step can be limited to chosen countries. Steps that need an email address are skipped if none was sent.
{
"otp_id": "tambua_otp_8f3a9c12",
"channel": "whatsapp",
"journey": {
"step": 2,
"steps": 3,
"attempts": [
{ "step": 1, "channel": "sms", "status": "failed" },
{ "step": 2, "channel": "whatsapp", "status": "sent" }
]
}
}Each fallback also fires an otp.fallback_triggeredwebhook, and the request shows a "Fallback used" marker in the console logs.
Channels
| channel= | What it does |
|---|---|
| sms | Delivers the code by SMS over local routes with carrier failover. |
| Delivers the code in a WhatsApp message using approved templates. | |
| voice | Calls the user and reads the code aloud, in the language and voice you choose. |
| Sends the code by email from a template with your sender name and reply-to. | |
| mpesa | Kenyan numbers only. Sends an M-PESA PIN prompt; verification completes without the user typing a code. |
Reverse OTP is available in early access. Telegram, USSD and Shortcode are coming soon.
Test mode
Test keys never reach a handset and never charge your wallet. The send response includes test_code, so you can complete the whole flow, send, receive and verify, with no phone. Switch to a live key when you are ready. Nothing else changes.
Webhooks
We POST signed JSON events to your endpoint when an OTP changes state. Failed deliveries are retried with exponential backoff for up to 24 hours. Every delivery, with its payload and your server's response, is visible in the console under Keys & webhooks, Deliveries, where you can retry it.
| Event | Sent when |
|---|---|
| otp.created | A code was generated. |
| otp.delivered | The carrier accepted or confirmed delivery. |
| otp.verified | The user entered the correct code. |
| otp.failed | Maximum attempts were used. |
| otp.expired | The code expired unused. |
| otp.undelivered | The carrier reported the message was not delivered. |
| otp.fallback_triggered | A delivery journey moved to its next channel. |
{
"id": "tambua_evt_88f0a64",
"type": "otp.verified",
"created": "2026-05-25T14:32:24.110Z",
"data": {
"otp_id": "tambua_otp_8f3a9c12",
"purpose": "login",
"channel": "sms",
"verified_at": "2026-05-25T14:32:24.108Z"
}
}Verify the signature
Each request carries X-Tambua-Signature: t=<unix>,v1=<hex> and X-Tambua-Event. The signature is an HMAC-SHA256, using your signing secret, of the string `${t}.${rawBody}`. Compute it over the raw request body and compare in constant time. Reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}Manage endpoints by API
Requires the manage_webhooks scope.
| Request | Purpose |
|---|---|
| GET /v1/webhooks | List your endpoints. |
| POST /v1/webhooks | Create one: { "url": "https://...", "events": ["otp.verified"] }. The signing secret is returned once. |
| PATCH /v1/webhooks | Update an endpoint: { "id": "...", "url", "events", "enabled" }, or rotate its secret with { "id": "...", "rotate_secret": true }. |
| DELETE /v1/webhooks?id=... | Remove an endpoint. |
Logs
Requires the read_logs scope. Returns up to 10 records per page, newest first.
| Query | Filters by |
|---|---|
| status | verified, pending, failed or expired. |
| channel | sms, whatsapp, voice, email or mpesa. |
| fallback | 1 to return only requests that used a fallback. |
| phone | One phone number (international format). |
| search | Free text over ID, recipient and purpose. |
| before / after | ISO timestamps for paging. Use pagination.next_before from the last response. |
{
"data": [
{
"otp_id": "tambua_otp_8f3a9c12",
"status": "verified",
"delivery_status": "delivered",
"channel": "sms",
"purpose": "login",
"created_at": "2026-05-25T14:32:20.000Z",
"journey": null
}
],
"pagination": { "limit": 10, "next_before": "2026-05-25T14:32:20.000Z" }
}Errors
Errors use a standard HTTP status and a JSON body with a stable code. Branch on error, not on text.
{ "error": "insufficient_balance", "details": {} }| error | HTTP | Meaning |
|---|---|---|
| invalid_api_key | 401 | The Authorization header is missing or the key is not valid. |
| insufficient_scope | 403 | The key lacks the scope the endpoint needs (details.required_scope). |
| insufficient_balance | 402 | The wallet balance is depleted. Top up to keep sending. |
| invalid_phone | 400 | The phone number could not be read. Send international format, e.g. +254712345678. |
| invalid_email | 400 | The email address is not valid. |
| invalid_code_format | 400 | A custom code must be 4 to 10 digits. |
| invalid_channel | 400 | The channel is not one of sms, whatsapp, voice, email, mpesa. |
| invalid_template | 400 | The template_id does not exist for that channel. |
| mpesa_requires_kenyan_phone | 400 | channel=mpesa only works with +254 numbers. |
| invalid_token | 401 | The otp_token is malformed or was not issued for this app. |
| expired_token | 401 | The code expired before it was verified. |
| invalid_code | 400 | The code does not match. Attempts used are counted. |
| max_attempts_exceeded | 429 | Too many wrong guesses. Request a new OTP. |
| otp_not_found | 404 | No OTP exists with that otp_id. |
| route_unavailable | 503 | No carrier route is enabled for this channel and destination. |
| provider_failed | 502 | Every route for the channel refused the message. Failed attempts are not billed. |
| invalid_journey | 400 | A delivery journey was rejected: more than 3 steps, a repeated channel, or a bad timeout. |
| internal_error | 500 | Something went wrong on our side. Retry with backoff. |
Settings and limits
Defaults are set per app in the console under Settings, Verification. You can override them per request where noted.
| Setting | Range | Default |
|---|---|---|
| Code length | 4 to 10 digits | 6 |
| Expires after | 60 to 900 seconds | 300 |
| Verify attempts | 1 to 10 | 5 |
| Resend cooldown | 15 to 300 seconds | 30 |
otp_token as a secret, and use your own server to call the API. Never ship an API key in client code.