A complete reference for integrating Briq’s One-Time Password (OTP) API. The API lets you generate, deliver, verify, resend, and invalidate short numeric codes over SMS, voice call, or WhatsApp, scoped to a Developer App you own.
- API surface:
https://karibu.briq.tz/v1/otp/*
- Audience: backend engineers integrating phone-based verification (signup, login, transaction confirmation).
- Version:
v1.1
1. Overview
The Karibu OTP API is a stateless HTTP service that:
- Generates a numeric OTP (default 6 digits) and delivers it to a phone number via SMS (default), voice call, or WhatsApp.
- Verifies a code submitted by the end user, with a hard cap of 3 attempts per active OTP.
- Manages lifecycle: resend, invalidate, and inspect the currently active OTP for a
(phone, app) pair.
Codes are stored hashed (bcrypt) — the server never returns the plaintext code in any response. Only the recipient sees it via the SMS, call, or WhatsApp message.
Channel comparison
| Channel | delivery_method | Best for | Customisable per-call? |
|---|
| SMS | "sms" | Default; broadest reach | sender_id, message_template |
| Voice call | "call" | SMS-restricted regions, accessibility | none — TTS reads the code |
| WhatsApp | "whatsapp" | App-installed users, rich-format follow-ups | none — uses the platform-approved briq_otp template |
2. Prerequisites
Before integrating, make sure you have:
- A Briq account with a workspace.
- An active developer API key (
X-API-Key). Generate one in the Briq dashboard.
- At least one Developer App registered against your workspace. Each app gives you:
app_id — UUID, used optionally in the X-App-ID header.
app_key — string token, sent in the body of every OTP request.
- The workspace must allow developer access (toggle in workspace settings).
- Recipient phone numbers in E.164 digits-only format (no
+, no spaces, e.g. 255712345678).
If you don’t yet have a Developer App, see Developer Apps before continuing.
3. Authentication & host
Base URL
All OTP endpoints are versioned under /v1/otp/.
| Header | Required | Notes |
|---|
X-API-Key | yes | Your developer API key. Must be active and not expired. |
X-App-ID | no | A specific Developer App UUID. If your API key is bound to an app, this — when sent — must match that app, or the request fails with 403. |
Content-Type | yes (POST) | application/json |
Host | yes | Must be karibu.briq.tz (or docs.briq.tz). Other hosts are rejected with 403 outside development/sandbox envs. |
App-scoping rules
Every OTP endpoint requires an app_key field in the body (or query, for /status):
- The
app_key must resolve to a Developer App you own.
- If your
X-API-Key is bound to a specific Developer App, the body’s app_key must resolve to the same app — otherwise you get 403 (app_key does not match the API key's linked developer app).
- The Developer App’s workspace must allow developer access for your user — otherwise 403 (
Workspace does not allow developer access.).
These checks run on every endpoint described below.
4. Standard response envelope
Every endpoint returns the same JSON envelope:
{
"success": true,
"message": "Human-readable summary",
"data": { /* endpoint-specific payload, or null */ },
"status_code": 200
}
success — true for normal operation, false for handled errors / “not found” cases embedded in the envelope.
message — short human-readable string suitable for logging.
data — endpoint-specific. May be null.
status_code — mirrors the HTTP status code returned.
Errors raised by FastAPI itself (401, 403 from auth/host gates, 422 from validation) follow the standard {"detail": "..."} shape, not the envelope above.
5. Behavioral rules (read this first)
These rules apply across all endpoints. Designing your client around them upfront prevents most integration bugs.
| Rule | Detail |
|---|
Single active OTP per (phone, app) | request and resend invalidate any prior unused, unexpired OTP for the same phone scoped to the same Developer App before issuing a new one. |
| Hashed at rest | OTP codes are bcrypt-hashed. The plaintext is delivered only via SMS, call, or WhatsApp to the recipient. The server will never return it in any response. |
3-attempt cap on verify | After 3 wrong codes the OTP is auto-locked (marked used) and remaining_attempts becomes 0. The user must request a new OTP. |
| Default expiry | 10 minutes. Configure per call via minutes_to_expire. |
| Default OTP length | 6 digits. Configure per call via otp_length. |
| Delivery method | "sms" (default), "call", or "whatsapp". Anything else returns 400. |
| Sender ID | SMS only. Defaults to the platform-configured OTP_DEFAULT_SENDER_ID if omitted. Ignored on "call" and "whatsapp". |
| Message template | SMS only. Optional. Must include {code}. {expiry} is also substituted. If {code} is missing the server falls back to the default template. Ignored on "call" and "whatsapp". |
| WhatsApp sender resolution | WhatsApp only. The dispatcher resolves which sender number to dispatch from in this order: (1) a non-default WhatsApp sender owned by your developer user with an APPROVED briq_otp template; (2) the sender whose Infobip phone_number_id matches the platform-wide WHATSAPP_PHONE_NUMBER_ID env value; (3) the legacy is_default = TRUE sender row. You don’t pass any sender field — the resolution is automatic. |
| Phone format | E.164 digits only, no +. e.g. 255712345678. Invalid input → 400 "Invalid phone number". |
| Cross-app isolation | Status, verify, resend, and invalidate operate only on OTPs issued under the same Developer App. An OTP issued under app A cannot be verified, queried, or invalidated via app B. |
6. Endpoint reference
Each endpoint section below documents purpose, contract, error semantics, and copy-pasteable client snippets in cURL, Python (requests), Node.js (fetch), and PHP (cURL extension).
6.1 POST /v1/otp/request
Generate a fresh OTP and deliver it to the phone.
Method & path: POST https://karibu.briq.tz/v1/otp/request
Headers: X-API-Key, Content-Type: application/json. Optional: X-App-ID.
Common fields (every channel):
| Field | Type | Required | Default | Notes |
|---|
phone_number | string (E.164 digits) | yes | — | e.g. 255712345678 |
app_key | string | yes | — | from your Developer App |
delivery_method | string | yes (recommended) | "sms" | one of "sms", "call", "whatsapp" |
otp_length | int | no | 6 | code length |
minutes_to_expire | int | no | 10 | TTL |
The remaining fields are channel-specific. Pick the tab below that matches the channel you’re sending on — each one shows the exact payload, the success response shape, and copy-pasteable client snippets.
Success response (same shape across channels):
{
"success": true,
"message": "OTP Code sent successfully.",
"data": { "expires_at": "2026-05-08T12:34:56.000000" },
"status_code": 200
}
The plaintext code is never returned. The recipient receives it via SMS, voice call, or WhatsApp only.
Error responses (same across channels):
| HTTP | Cause | Shape |
|---|
| 400 | invalid phone, send failure, unsupported delivery_method, WhatsApp dispatcher returned an error (e.g. NO_DEFAULT_SENDER, TEMPLATE_NOT_APPROVED) | envelope with success: false and a message |
| 401 | missing/expired X-API-Key | {"detail": "Invalid or expired API key"} |
| 403 | wrong host, app_key not yours, app_key mismatch with the bound app, workspace not dev-accessible | {"detail": "..."} |
| 422 | malformed body (e.g. non-digit phone, missing fields) | FastAPI validation error |
Behavior: invalidates the prior active OTP for (phone, app) before issuing the new one. Returns 200 only after the dispatch is accepted by the upstream provider.
Channel-specific payload & code samples
Default channel. Customisable per call via sender_id and message_template.Channel-specific fields:| Field | Type | Required | Default | Notes |
|---|
sender_id | string | no | OTP_DEFAULT_SENDER_ID | The SMS sender ID shown to the recipient. Must be approved for your account. |
message_template | string | no | server default | Must contain {code}. {expiry} is also substituted. Falls back if {code} missing. |
Exact payload:{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "sms",
"otp_length": 6,
"minutes_to_expire": 10,
"sender_id": "BRIQ OTP",
"message_template": "Your verification code is {code}. It expires in {expiry} minutes."
}
curl -X POST https://karibu.briq.tz/v1/otp/request \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "sms",
"otp_length": 6,
"minutes_to_expire": 10,
"sender_id": "BRIQ OTP",
"message_template": "Your verification code is {code}. It expires in {expiry} minutes."
}'
The platform calls the recipient and reads the code via TTS. There is no sender_id or message_template field — both are SMS-only and ignored on this channel.Channel-specific fields: none.Exact payload:{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "call",
"otp_length": 6,
"minutes_to_expire": 10
}
curl -X POST https://karibu.briq.tz/v1/otp/request \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "call",
"otp_length": 6,
"minutes_to_expire": 10
}'
Delivers the OTP through the platform-managed briq_otp WhatsApp Business template. There is no sender_id or message_template field — sender resolution is automatic (see §5 behavioral rules) and the wording is fixed by the approved Meta template.Channel-specific fields: none.Exact payload:{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "whatsapp",
"otp_length": 6,
"minutes_to_expire": 10
}
curl -X POST https://karibu.briq.tz/v1/otp/request \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "whatsapp",
"otp_length": 6,
"minutes_to_expire": 10
}'
WhatsApp-specific 400s: if the dispatcher cannot resolve a sender + APPROVED briq_otp template (e.g. the platform default isn’t configured, or a custom one is in DRAFT/PAUSED), the response is success: false with a message describing which check failed. Fall back to SMS in your client when this happens.
6.2 POST /v1/otp/verify
Check a code submitted by the end user.
Method & path: POST https://karibu.briq.tz/v1/otp/verify
Headers: X-API-Key, Content-Type: application/json.
Request body:
| Field | Type | Required | Notes |
|---|
phone_number | string (E.164 digits) | yes | |
app_key | string | yes | must match the app the OTP was issued under |
code | string | yes | plaintext code submitted by the user |
Success 200:
{
"success": true,
"message": "OTP verified successfully.",
"data": { "verified_at": "2026-05-08T12:35:21.000000" },
"status_code": 200
}
The OTP is marked used; subsequent verifies for the same code return success: false.
Handled errors (envelope, success: false):
message | data.remaining_attempts | When |
|---|
"No valid OTP found" | 0 | no active OTP, expired, or already used |
"Invalid OTP code" | 2, 1, 0 | wrong code; counter decrements |
"Max verification attempts reached" | 0 | 3rd wrong attempt — OTP is now locked |
"Invalid phone number" | — | malformed phone |
Auth/host errors (401, 403) and validation (422) are the same as on /request.
Code samples
curl -X POST https://karibu.briq.tz/v1/otp/verify \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"code": "123456"
}'
6.3 POST /v1/otp/resend
Issue a new OTP after invalidating the previous one. Body shape and channel-specific fields are identical to §6.1 /request; only the path changes.
Method & path: POST https://karibu.briq.tz/v1/otp/resend
Success 200:
{
"success": true,
"message": "OTP resent successfully.",
"data": { "expires_at": "2026-05-08T12:45:01.000000" },
"status_code": 200
}
Error responses: identical to /request.
request vs resend — what’s the difference? Functionally both invalidate any prior active OTP and dispatch a new one. Use /resend when the end user explicitly clicks “Resend code”; this lets you treat it differently in your analytics, rate-limits, or UI without changing payloads.
Switching channels on resend is allowed — e.g. user clicks “I didn’t get the SMS, send via WhatsApp instead.” Just send the new delivery_method. The previous active OTP is invalidated regardless of which channel issued it.
Channel-specific payload & code samples
curl -X POST https://karibu.briq.tz/v1/otp/resend \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "sms",
"sender_id": "BRIQ OTP",
"message_template": "Your verification code is {code}. It expires in {expiry} minutes."
}'
resp = requests.post(
f"{BASE_URL}/v1/otp/resend",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"phone_number": "255712345678",
"app_key": APP_KEY,
"delivery_method": "sms",
"sender_id": "BRIQ OTP",
"message_template": "Your verification code is {code}. It expires in {expiry} minutes.",
},
timeout=15,
)
print(resp.json())
const resp = await fetch(`${BASE_URL}/v1/otp/resend`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
phone_number: "255712345678",
app_key: APP_KEY,
delivery_method: "sms",
sender_id: "BRIQ OTP",
message_template: "Your verification code is {code}. It expires in {expiry} minutes.",
}),
});
console.log(await resp.json());
$ch = curl_init("$baseUrl/v1/otp/resend");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: $apiKey",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"phone_number" => "255712345678",
"app_key" => $appKey,
"delivery_method" => "sms",
"sender_id" => "BRIQ OTP",
"message_template" => "Your verification code is {code}. It expires in {expiry} minutes.",
]),
]);
echo curl_exec($ch);
curl_close($ch);
curl -X POST https://karibu.briq.tz/v1/otp/resend \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "call"
}'
resp = requests.post(
f"{BASE_URL}/v1/otp/resend",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"phone_number": "255712345678",
"app_key": APP_KEY,
"delivery_method": "call",
},
timeout=15,
)
print(resp.json())
const resp = await fetch(`${BASE_URL}/v1/otp/resend`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
phone_number: "255712345678",
app_key: APP_KEY,
delivery_method: "call",
}),
});
console.log(await resp.json());
$ch = curl_init("$baseUrl/v1/otp/resend");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: $apiKey",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"phone_number" => "255712345678",
"app_key" => $appKey,
"delivery_method" => "call",
]),
]);
echo curl_exec($ch);
curl_close($ch);
curl -X POST https://karibu.briq.tz/v1/otp/resend \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY",
"delivery_method": "whatsapp"
}'
resp = requests.post(
f"{BASE_URL}/v1/otp/resend",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"phone_number": "255712345678",
"app_key": APP_KEY,
"delivery_method": "whatsapp",
},
timeout=15,
)
print(resp.json())
const resp = await fetch(`${BASE_URL}/v1/otp/resend`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
phone_number: "255712345678",
app_key: APP_KEY,
delivery_method: "whatsapp",
}),
});
console.log(await resp.json());
$ch = curl_init("$baseUrl/v1/otp/resend");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: $apiKey",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"phone_number" => "255712345678",
"app_key" => $appKey,
"delivery_method" => "whatsapp",
]),
]);
echo curl_exec($ch);
curl_close($ch);
6.4 POST /v1/otp/invalidate
Force-expire any active OTP for a phone scoped to your Developer App. Useful on logout, security events, or when the user changes their phone number.
Method & path: POST https://karibu.briq.tz/v1/otp/invalidate
Request body:
| Field | Type | Required |
|---|
phone_number | string (E.164 digits) | yes |
app_key | string | yes |
Success 200:
{
"success": true,
"message": "OTP invalidated successfully.",
"data": null,
"status_code": 200
}
Idempotent. Calling this when there is no active OTP also returns 200 — it simply has nothing to invalidate.
Code samples
curl -X POST https://karibu.briq.tz/v1/otp/invalidate \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "255712345678",
"app_key": "YOUR_APP_KEY"
}'
6.5 GET /v1/otp/status
Inspect the currently active OTP for a phone, scoped to your Developer App, without triggering a send. Useful for UI countdowns, diagnostics, and idempotent UX (e.g. “an OTP was already sent — please check your phone”).
Method & path: GET https://karibu.briq.tz/v1/otp/status
Query parameters:
| Param | Required |
|---|
phone_number | yes |
app_key | yes |
Success 200:
{
"success": true,
"message": "OTP status retrieved.",
"data": {
"is_valid": true,
"expires_at": "2026-05-08T12:45:01.000000",
"remaining_attempts": 3
},
"status_code": 200
}
404 (no active OTP):
{
"success": false,
"message": "No active OTP found.",
"data": null,
"status_code": 404
}
The HTTP response body wraps status_code: 404, but the underlying HTTP response itself is 200 OK (the envelope shape applies here too). Inspect body.success and body.status_code, not the raw HTTP status, when handling this endpoint.
Code samples
curl -G https://karibu.briq.tz/v1/otp/status \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "phone_number=255712345678" \
--data-urlencode "app_key=YOUR_APP_KEY"
7. End-to-end flow
A typical phone-verification flow:
- User enters phone in your UI → your backend calls
POST /v1/otp/request with the appropriate delivery_method.
- User receives SMS, voice call, or WhatsApp message with the plaintext code.
- User submits code in your UI → your backend calls
POST /v1/otp/verify.
- On
success: true → mark phone verified, continue signup/login.
- On
success: false → show error and use data.remaining_attempts to drive UX.
- User clicks “Resend” → your backend calls
POST /v1/otp/resend (which invalidates the prior code automatically). You may switch channels here — e.g. retry on WhatsApp after an SMS didn’t arrive.
- User logs out / changes phone → your backend calls
POST /v1/otp/invalidate.
- Optional UI affordances: poll
GET /v1/otp/status to drive countdown timers or detect that an OTP is already in-flight before issuing a new one.
For narrative walkthroughs, see Requesting OTP codes, Validating OTP codes, and Managing OTP lifecycle.
8. Error scenarios — quick reference
| Scenario | HTTP | Body shape | Recommended client action |
|---|
Missing/expired X-API-Key | 401 | {"detail": "Invalid or expired API key"} | Surface “Authentication failed” — operator should rotate the key. |
| API key expired | 401 | {"detail": "API key expired"} | Same — operator action. |
| Wrong host | 403 | {"detail": "...only available for the developer domain..."} | Fix your client’s Host / base URL. |
app_key not yours / invalid | 403 | {"detail": "Invalid app_key"} or "Invalid developer app or workspace." | Verify app_key matches an app under your account. |
app_key mismatch with bound API key | 403 | {"detail": "app_key does not match the API key's linked developer app"} | Either send the matching app_key, or use an API key not bound to an app. |
| Workspace blocks dev access | 403 | {"detail": "Workspace does not allow developer access."} | Toggle developer access for the workspace in the Briq UI. |
| Bad phone format | 400 / 422 | envelope success: false, "Invalid phone number" or FastAPI 422 | Re-validate digits-only E.164 client-side before sending. |
Unsupported delivery_method | 400 | envelope with success: false | Use "sms", "call", or "whatsapp". |
| WhatsApp dispatcher cannot resolve sender/template | 400 | envelope success: false with NO_DEFAULT_SENDER / TEMPLATE_NOT_APPROVED in the message | Fall back to SMS automatically; surface a “try another channel” UI. |
Wrong code on /verify | 200 (envelope-400) | envelope success: false, "Invalid OTP code", data.remaining_attempts=N | Decrement UI counter; prompt resend at 0. |
| Max attempts exceeded | 200 (envelope-400) | "Max verification attempts reached", remaining_attempts=0 | Force-resend; do not allow more verifies on this OTP. |
| OTP expired or already used | 200 (envelope-400) | "No valid OTP found", remaining_attempts=0 | Prompt user to request a new OTP. |
/status for unknown phone/app | 200 (envelope-404) | envelope success: false, status_code: 404 | Treat as “no active OTP”. |
9. Best practices
- Never log the plaintext code. Treat it as a credential. Don’t echo it from your verify form, don’t store it in analytics, don’t include it in error reports.
- Do not ship
app_key to untrusted clients. Hold it server-side; have your frontend hit your backend, which then calls Karibu.
- Respect the 3-attempt cap in your UI. Disable the verify button after 3 failures and surface “Resend code” prominently.
- Handle
remaining_attempts: 0 distinctly from remaining_attempts > 0. They demand different UX (lockout vs retry).
- Invalidate on logout. It’s safe to call even if you’re unsure an OTP is active.
- Default to
delivery_method: "sms". Use "call" for SMS-restricted regions or accessibility; use "whatsapp" for app-installed users with chat-first habits.
- Cascade channels for reliability. A common pattern: try
"whatsapp" first; if the dispatcher returns a 400 (template not approved, dispatcher misconfigured) fall back to "sms", and offer "call" as a manual third option.
- Sender ID is SMS-only. A misconfigured
sender_id may silently route through the platform default; verify deliverability with a test number before launch. The field is ignored on "call" and "whatsapp".
- WhatsApp template wording is fixed. Don’t ship a UI that promises a custom message body when sending over WhatsApp — only
briq_otp is dispatched, so the recipient sees the platform-approved Meta template.
- Rate-limit your own users. The API does not enforce per-phone request rate limits — your application should.
See also Best practices and Security notes for cross-product guidance.
10. Changelog
- v1.1 — added WhatsApp delivery channel (
delivery_method: "whatsapp"). Routes through the platform-managed briq_otp template via the WhatsApp dispatcher; sender resolution is automatic (developer-owned sender → env-pinned platform default → is_default = TRUE fallback). sender_id and message_template are ignored on this channel. /request and /resend payload tables split into channel-specific tabs.
- v1 — initial public surface:
request, verify, resend, invalidate, status. SMS + voice call delivery. 3-attempt cap. Single-active-OTP per (phone, app).