api: 99.99%
// API REFERENCE

Tambua API documentation

Send and verify one-time passwords across Africa with one API. SMS, WhatsApp, voice, email and M-PESA, with automatic fallback between channels.

Overview

The Tambua API is a JSON REST API. Every request goes to one base URL over HTTPS.

BASE https://api.tambua.me

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.

Prefer to let an AI assistant write the integration? Copy the prompt from the AI skill section of the home page.

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

ScopeAllows
send_otpPOST /v1/otp/send
verify_otpPOST /v1/otp/verify and POST /v1/otp/{otp_id}
manage_webhooksGET, POST, PATCH, DELETE /v1/webhooks
read_logsGET /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 false

Send an OTP

POST /v1/otp/send

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

FieldDescription
phoneDestination in international format, e.g. +254712345678. Use phone or to for SMS, WhatsApp, voice and M-PESA.
emailDestination email address for channel=email. If both phone and email are sent, a delivery journey can use either.
toAlternative to phone or email. An address containing @ is treated as email.
purposeA short label for why you are sending, e.g. login, signup, payment. Shown in logs. Default: authentication.
channelOptional. One of sms, whatsapp, voice, email, mpesa. Omit it to run your delivery journey (see below). Passing it forces exactly one channel.
codeOptional. Your own 4 to 10 digit code. If omitted we generate one using your configured length.
template_idOptional. A template from the catalog for the channel, e.g. sms-standard.
referenceOptional. 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..."
}
FieldMeaning
otp_idUnique ID of this OTP. Use it in logs and webhooks.
statuspending, verified, expired or locked.
delivery.statuspending, sent, delivered, undelivered or failed, as reported by the carrier.
attemptsVerification attempts used and the maximum allowed.
expires_atWhen the code stops being valid.
otp_tokenA signed token you pass back to verify. Treat it as a secret for this OTP.
journeyPresent when a delivery journey ran: step, steps and the attempts made.
test_codeTest keys only: the code itself, so you can test without a phone.

Verify an OTP

POST /v1/otp/verify

You can also call POST /v1/otp/{otp_id} with the same body.

FieldDescription
otp_tokenThe token returned by the send call.
codeThe 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_token are 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 channel skips 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
smsDelivers the code by SMS over local routes with carrier failover.
whatsappDelivers the code in a WhatsApp message using approved templates.
voiceCalls the user and reads the code aloud, in the language and voice you choose.
emailSends the code by email from a template with your sender name and reply-to.
mpesaKenyan 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.

EventSent when
otp.createdA code was generated.
otp.deliveredThe carrier accepted or confirmed delivery.
otp.verifiedThe user entered the correct code.
otp.failedMaximum attempts were used.
otp.expiredThe code expired unused.
otp.undeliveredThe carrier reported the message was not delivered.
otp.fallback_triggeredA 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.

RequestPurpose
GET /v1/webhooksList your endpoints.
POST /v1/webhooksCreate one: { "url": "https://...", "events": ["otp.verified"] }. The signing secret is returned once.
PATCH /v1/webhooksUpdate an endpoint: { "id": "...", "url", "events", "enabled" }, or rotate its secret with { "id": "...", "rotate_secret": true }.
DELETE /v1/webhooks?id=...Remove an endpoint.

Logs

GET /v1/logs

Requires the read_logs scope. Returns up to 10 records per page, newest first.

QueryFilters by
statusverified, pending, failed or expired.
channelsms, whatsapp, voice, email or mpesa.
fallback1 to return only requests that used a fallback.
phoneOne phone number (international format).
searchFree text over ID, recipient and purpose.
before / afterISO 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": {} }
errorHTTPMeaning
invalid_api_key401The Authorization header is missing or the key is not valid.
insufficient_scope403The key lacks the scope the endpoint needs (details.required_scope).
insufficient_balance402The wallet balance is depleted. Top up to keep sending.
invalid_phone400The phone number could not be read. Send international format, e.g. +254712345678.
invalid_email400The email address is not valid.
invalid_code_format400A custom code must be 4 to 10 digits.
invalid_channel400The channel is not one of sms, whatsapp, voice, email, mpesa.
invalid_template400The template_id does not exist for that channel.
mpesa_requires_kenyan_phone400channel=mpesa only works with +254 numbers.
invalid_token401The otp_token is malformed or was not issued for this app.
expired_token401The code expired before it was verified.
invalid_code400The code does not match. Attempts used are counted.
max_attempts_exceeded429Too many wrong guesses. Request a new OTP.
otp_not_found404No OTP exists with that otp_id.
route_unavailable503No carrier route is enabled for this channel and destination.
provider_failed502Every route for the channel refused the message. Failed attempts are not billed.
invalid_journey400A delivery journey was rejected: more than 3 steps, a repeated channel, or a bad timeout.
internal_error500Something 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.

SettingRangeDefault
Code length4 to 10 digits6
Expires after60 to 900 seconds300
Verify attempts1 to 105
Resend cooldown15 to 300 seconds30
Always send numbers in international format, treat otp_token as a secret, and use your own server to call the API. Never ship an API key in client code.