> ## Documentation Index
> Fetch the complete documentation index at: https://docs.briq.tz/llms.txt
> Use this file to discover all available pages before exploring further.

# Karibu OTP API

> Complete reference for the Karibu OTP API — generate, deliver, verify, resend, invalidate, and inspect short numeric codes scoped to a Developer App, across SMS, voice call, and WhatsApp.

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 the Developer App bound to your `X-API-Key`.

* **API surface:** `https://karibu.briq.tz/v1/otp/*`
* **Audience:** backend engineers integrating phone-based verification (signup, login, transaction confirmation).
* **Version:** `v1.2`

***

## 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`) **bound to a Developer App**. Generate one in the Briq dashboard and attach it to the app that scopes your OTP traffic.
* At least one **Developer App** registered against your workspace (required for binding). Each app gives you an `app_id` (UUID) returned in API responses for reference.
* 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`).

<Note>
  Bind every OTP API key to a Developer App and keep it server-side. If you don't yet have a Developer App, see [Developer Apps](/guides/developer-apps) before continuing.

  Legacy integrations with an **unbound** API key may scope requests via the optional `X-App-ID` header or an `app_key` field in the body or query. New integrations should bind the key instead.
</Note>

***

## 3. Authentication & host

### Base URL

```
https://karibu.briq.tz
```

All OTP endpoints are versioned under `/v1/otp/`.

### Required headers

| Header         | Required   | Notes                                                                                                                             |
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `X-API-Key`    | yes        | Your developer API key, bound to a Developer App. Must be active and not expired.                                                 |
| `X-App-ID`     | no         | Optional Developer App UUID. When sent, must match the app bound to your API key — otherwise **403**. Omit when the key is bound. |
| `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

Karibu resolves your Developer App from the bound `X-API-Key` on every OTP endpoint:

* Your API key must be **bound** to a Developer App you own.
* The Developer App's workspace must allow developer access for your user — otherwise **403** (`Workspace does not allow developer access.`).
* If you send optional `X-App-ID`, it must match the app bound to your API key — otherwise **403**.

These checks run on every endpoint described below.

***

## 4. Standard response envelope

Every endpoint returns the same JSON envelope:

```json theme={null}
{
  "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.

<Warning>
  Errors raised by FastAPI itself (401, 403 from auth/host gates, 422 from validation) follow the standard `{"detail": "..."}` shape, **not** the envelope above.
</Warning>

***

## 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 (resolved from your bound API key). 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` (bound to a Developer App), `Content-Type: application/json`.

**Common fields (every channel):**

| Field               | Type                  | Required          | Default | Notes                                                                                                            |
| ------------------- | --------------------- | ----------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `phone_number`      | string (E.164 digits) | yes               | —       | e.g. `255712345678`                                                                                              |
| `app_key`           | string                | no                | —       | Omit when API key is bound to a developer app. Legacy unbound keys may send this or use `X-App-ID` instead.      |
| `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                                                                                                              |
| `callback_url`      | string (HTTPS)        | no                | —       | Optional per-request webhook for async `flake.verified` / `flake.failed` events. Active for this OTP's lifetime. |
| `callback_secret`   | string                | no                | —       | Optional HMAC secret for `X-Briq-Signature` on callbacks.                                                        |

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):**

```json theme={null}
{
  "success": true,
  "message": "OTP Code sent successfully.",
  "data": { "expires_at": "2026-05-08T12:34:56.000000" },
  "status_code": 200
}
```

<Warning>
  The plaintext code is **never** returned. The recipient receives it via SMS, voice call, or WhatsApp only.
</Warning>

**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, API key not bound to a developer app, `app_key`/`X-App-ID` 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

<Tabs>
  <Tab title="SMS">
    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:**

    ```json theme={null}
    {
      "phone_number": "255712345678",
      "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."
    }
    ```

    <CodeGroup>
      ```bash cURL theme={null}
      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",
          "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."
        }'
      ```

      ```python Python theme={null}
      import requests

      BASE_URL = "https://karibu.briq.tz"
      API_KEY  = "YOUR_API_KEY"

      resp = requests.post(
          f"{BASE_URL}/v1/otp/request",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "phone_number": "255712345678",
              "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.",
          },
          timeout=15,
      )
      print(resp.status_code, resp.json())
      ```

      ```javascript Node.js theme={null}
      const BASE_URL = "https://karibu.briq.tz";
      const API_KEY  = "YOUR_API_KEY";

      const resp = await fetch(`${BASE_URL}/v1/otp/request`, {
        method: "POST",
        headers: {
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          phone_number: "255712345678",
          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.",
        }),
      });
      console.log(resp.status, await resp.json());
      ```

      ```php PHP theme={null}
      <?php
      $baseUrl = "https://karibu.briq.tz";
      $apiKey  = "YOUR_API_KEY";

      $ch = curl_init("$baseUrl/v1/otp/request");
      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",
              "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.",
          ]),
      ]);
      $response = curl_exec($ch);
      $status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      curl_close($ch);
      echo $status . "\n" . $response;
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Voice call">
    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:**

    ```json theme={null}
    {
      "phone_number": "255712345678",
      "delivery_method": "call",
      "otp_length": 6,
      "minutes_to_expire": 10
    }
    ```

    <CodeGroup>
      ```bash cURL theme={null}
      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",
          "delivery_method": "call",
          "otp_length": 6,
          "minutes_to_expire": 10
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/otp/request",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "phone_number": "255712345678",
              "delivery_method": "call",
              "otp_length": 6,
              "minutes_to_expire": 10,
          },
          timeout=15,
      )
      print(resp.status_code, resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(`${BASE_URL}/v1/otp/request`, {
        method: "POST",
        headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
        body: JSON.stringify({
          phone_number: "255712345678",
          delivery_method: "call",
          otp_length: 6,
          minutes_to_expire: 10,
        }),
      });
      console.log(resp.status, await resp.json());
      ```

      ```php PHP theme={null}
      $ch = curl_init("$baseUrl/v1/otp/request");
      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",
              "delivery_method"   => "call",
              "otp_length"        => 6,
              "minutes_to_expire" => 10,
          ]),
      ]);
      echo curl_exec($ch);
      curl_close($ch);
      ```
    </CodeGroup>
  </Tab>

  <Tab title="WhatsApp">
    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](#5-behavioral-rules-read-this-first)) and the wording is fixed by the approved Meta template.

    **Channel-specific fields:** none.

    **Exact payload:**

    ```json theme={null}
    {
      "phone_number": "255712345678",
      "delivery_method": "whatsapp",
      "otp_length": 6,
      "minutes_to_expire": 10
    }
    ```

    <CodeGroup>
      ```bash cURL theme={null}
      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",
          "delivery_method": "whatsapp",
          "otp_length": 6,
          "minutes_to_expire": 10
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/otp/request",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "phone_number": "255712345678",
              "delivery_method": "whatsapp",
              "otp_length": 6,
              "minutes_to_expire": 10,
          },
          timeout=15,
      )
      print(resp.status_code, resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(`${BASE_URL}/v1/otp/request`, {
        method: "POST",
        headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
        body: JSON.stringify({
          phone_number: "255712345678",
          delivery_method: "whatsapp",
          otp_length: 6,
          minutes_to_expire: 10,
        }),
      });
      console.log(resp.status, await resp.json());
      ```

      ```php PHP theme={null}
      $ch = curl_init("$baseUrl/v1/otp/request");
      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",
              "delivery_method"   => "whatsapp",
              "otp_length"        => 6,
              "minutes_to_expire" => 10,
          ]),
      ]);
      echo curl_exec($ch);
      curl_close($ch);
      ```
    </CodeGroup>

    <Warning>
      **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.
    </Warning>
  </Tab>
</Tabs>

***

### 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` (bound to a Developer App), `Content-Type: application/json`.

**Request body:**

| Field             | Type                  | Required | Notes                                                                                                       |
| ----------------- | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `phone_number`    | string (E.164 digits) | yes      |                                                                                                             |
| `app_key`         | string                | no       | Omit when API key is bound to a developer app. Legacy unbound keys may send this or use `X-App-ID` instead. |
| `code`            | string                | yes      | plaintext code submitted by the user                                                                        |
| `callback_url`    | string (HTTPS)        | no       | Override or fallback if not set on request/resend                                                           |
| `callback_secret` | string                | no       | Signing secret override; secret from request/resend reused when omitted                                     |

**Success 200:**

```json theme={null}
{
  "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`.

When a callback URL was set on request/resend or passed on verify, Karibu also sends an async `flake.verified` or `flake.failed` webhook (best-effort). See [§10 Per-request flake callbacks](#10-per-request-flake-callbacks).

**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

<CodeGroup>
  ```bash cURL theme={null}
  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",
      "code": "123456"
    }'
  ```

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/otp/verify",
      headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      json={
          "phone_number": "255712345678",
          "code": "123456",
      },
      timeout=10,
  )
  body = resp.json()
  if body.get("success"):
      print("Verified at", body["data"]["verified_at"])
  else:
      print("Failed:", body["message"], "remaining:", body.get("data", {}).get("remaining_attempts"))
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(`${BASE_URL}/v1/otp/verify`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      phone_number: "255712345678",
      code: "123456",
    }),
  });
  const body = await resp.json();
  if (body.success) {
    console.log("Verified at", body.data.verified_at);
  } else {
    console.log("Failed:", body.message, "remaining:", body.data?.remaining_attempts);
  }
  ```

  ```php PHP theme={null}
  $ch = curl_init("$baseUrl/v1/otp/verify");
  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",
          "code"         => "123456",
      ]),
  ]);
  $response = curl_exec($ch);
  curl_close($ch);
  $body = json_decode($response, true);
  if ($body["success"] ?? false) {
      echo "Verified at " . $body["data"]["verified_at"];
  } else {
      echo "Failed: " . $body["message"];
  }
  ```
</CodeGroup>

***

### 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`](#61-post-v1otprequest); only the path changes.

**Method & path:** `POST https://karibu.briq.tz/v1/otp/resend`

**Success 200:**

```json theme={null}
{
  "success": true,
  "message": "OTP resent successfully.",
  "data": { "expires_at": "2026-05-08T12:45:01.000000" },
  "status_code": 200
}
```

**Error responses:** identical to `/request`.

<Note>
  **`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.
</Note>

<Note>
  **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.
</Note>

#### Channel-specific payload & code samples

<Tabs>
  <Tab title="SMS">
    ```bash cURL theme={null}
    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",
        "delivery_method": "sms",
        "sender_id": "BRIQ OTP",
        "message_template": "Your verification code is {code}. It expires in {expiry} minutes."
      }'
    ```

    ```python Python theme={null}
    resp = requests.post(
        f"{BASE_URL}/v1/otp/resend",
        headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
        json={
            "phone_number": "255712345678",
            "delivery_method": "sms",
            "sender_id": "BRIQ OTP",
            "message_template": "Your verification code is {code}. It expires in {expiry} minutes.",
        },
        timeout=15,
    )
    print(resp.json())
    ```

    ```javascript Node.js theme={null}
    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",
        delivery_method: "sms",
        sender_id: "BRIQ OTP",
        message_template: "Your verification code is {code}. It expires in {expiry} minutes.",
      }),
    });
    console.log(await resp.json());
    ```

    ```php PHP theme={null}
    $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",
            "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);
    ```
  </Tab>

  <Tab title="Voice call">
    ```bash cURL theme={null}
    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",
        "delivery_method": "call"
      }'
    ```

    ```python Python theme={null}
    resp = requests.post(
        f"{BASE_URL}/v1/otp/resend",
        headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
        json={
            "phone_number": "255712345678",
            "delivery_method": "call",
        },
        timeout=15,
    )
    print(resp.json())
    ```

    ```javascript Node.js theme={null}
    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",
        delivery_method: "call",
      }),
    });
    console.log(await resp.json());
    ```

    ```php PHP theme={null}
    $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",
            "delivery_method" => "call",
        ]),
    ]);
    echo curl_exec($ch);
    curl_close($ch);
    ```
  </Tab>

  <Tab title="WhatsApp">
    ```bash cURL theme={null}
    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",
        "delivery_method": "whatsapp"
      }'
    ```

    ```python Python theme={null}
    resp = requests.post(
        f"{BASE_URL}/v1/otp/resend",
        headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
        json={
            "phone_number": "255712345678",
            "delivery_method": "whatsapp",
        },
        timeout=15,
    )
    print(resp.json())
    ```

    ```javascript Node.js theme={null}
    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",
        delivery_method: "whatsapp",
      }),
    });
    console.log(await resp.json());
    ```

    ```php PHP theme={null}
    $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",
            "delivery_method" => "whatsapp",
        ]),
    ]);
    echo curl_exec($ch);
    curl_close($ch);
    ```
  </Tab>
</Tabs>

***

### 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 | Notes                                                                                                       |
| -------------- | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `phone_number` | string (E.164 digits) | yes      |                                                                                                             |
| `app_key`      | string                | no       | Omit when API key is bound to a developer app. Legacy unbound keys may send this or use `X-App-ID` instead. |

**Success 200:**

```json theme={null}
{
  "success": true,
  "message": "OTP invalidated successfully.",
  "data": null,
  "status_code": 200
}
```

<Note>
  **Idempotent.** Calling this when there is no active OTP also returns 200 — it simply has nothing to invalidate.
</Note>

#### Code samples

<CodeGroup>
  ```bash cURL theme={null}
  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"
    }'
  ```

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/otp/invalidate",
      headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      json={"phone_number": "255712345678"},
      timeout=10,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(`${BASE_URL}/v1/otp/invalidate`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ phone_number: "255712345678" }),
  });
  console.log(await resp.json());
  ```

  ```php PHP theme={null}
  $ch = curl_init("$baseUrl/v1/otp/invalidate");
  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",
      ]),
  ]);
  echo curl_exec($ch);
  curl_close($ch);
  ```
</CodeGroup>

***

### 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 | Notes                                                                                                       |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `phone_number` | yes      |                                                                                                             |
| `app_key`      | no       | Omit when API key is bound to a developer app. Legacy unbound keys may send this or use `X-App-ID` instead. |

**Success 200:**

```json theme={null}
{
  "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):**

```json theme={null}
{
  "success": false,
  "message": "No active OTP found.",
  "data": null,
  "status_code": 404
}
```

<Warning>
  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.
</Warning>

#### Code samples

<CodeGroup>
  ```bash cURL theme={null}
  curl -G https://karibu.briq.tz/v1/otp/status \
    -H "X-API-Key: YOUR_API_KEY" \
    --data-urlencode "phone_number=255712345678"
  ```

  ```python Python theme={null}
  resp = requests.get(
      f"{BASE_URL}/v1/otp/status",
      headers={"X-API-Key": API_KEY},
      params={"phone_number": "255712345678"},
      timeout=10,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  const params = new URLSearchParams({
    phone_number: "255712345678",
  });
  const resp = await fetch(`${BASE_URL}/v1/otp/status?${params}`, {
    headers: { "X-API-Key": API_KEY },
  });
  console.log(await resp.json());
  ```

  ```php PHP theme={null}
  $query = http_build_query([
      "phone_number" => "255712345678",
  ]);
  $ch = curl_init("$baseUrl/v1/otp/status?$query");
  curl_setopt_array($ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER     => ["X-API-Key: $apiKey"],
  ]);
  echo curl_exec($ch);
  curl_close($ch);
  ```
</CodeGroup>

***

## 7. End-to-end flow

A typical phone-verification flow:

1. **User enters phone** in your UI → your backend calls `POST /v1/otp/request` with the appropriate `delivery_method`.
2. **User receives SMS, voice call, or WhatsApp message** with the plaintext code.
3. **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.
4. **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.
5. **User logs out / changes phone** → your backend calls `POST /v1/otp/invalidate`.
6. **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](/guides/otp-requesting-codes), [Validating OTP codes](/guides/otp-validating-codes), and [Managing OTP lifecycle](/guides/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.                                                                 |
| API key not bound to a Developer App                 | 403                | `{"detail": "..."}` (no linked developer app)                                               | Bind the API key to a Developer App in the dashboard or via the attach endpoint.                     |
| `app_key` / `X-App-ID` mismatch with bound key       | 403                | `{"detail": "app_key does not match the API key's linked developer app"}` or similar        | Omit `app_key` and `X-App-ID` when the key is bound; legacy unbound keys must send a matching scope. |
| 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".                                                                            |
| Invalid `callback_url` (HTTP, localhost, private IP) | 422                | FastAPI validation error (`callback_url must use https`, etc.)                              | Use a public HTTPS URL; test with [webhook.site](https://webhook.site).                              |

***

## 9. Best practices

* **Bind your API key to a Developer App.** Karibu resolves the app from `X-API-Key` — do not send `app_key` in request bodies or query params in new integrations.
* **Never expose your API key to untrusted clients.** Keep it server-side; have your frontend hit your backend, which then calls Karibu with `X-API-Key` only.
* **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.
* **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](/best-practices) and [Security notes](/security-notes) for cross-product guidance.

***

## 10. Per-request flake callbacks

OTP codes are internally called **flakes**. Optional `callback_url` and `callback_secret` on `request`, `resend`, and `verify` let you receive **async HTTPS webhooks** on verify outcomes — without registering a dashboard webhook.

For a narrative walkthrough and step-by-step testing with webhook.site, see [Flake lifecycle callbacks](/guides/otp-callbacks).

### Fields

| Field             | Endpoints                     | Notes                                                                                                                  |
| ----------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `callback_url`    | `request`, `resend`, `verify` | HTTPS only; no private/loopback targets. Set on `request`/`resend` to receive callbacks for that OTP window.           |
| `callback_secret` | `request`, `resend`, `verify` | When set, callbacks include `X-Briq-Signature: sha256=<hex>`. On verify, omit to reuse the secret from request/resend. |

### Events

| Event            | When                                                   |
| ---------------- | ------------------------------------------------------ |
| `flake.verified` | `/verify` returns `success: true`                      |
| `flake.failed`   | `/verify` hits max attempts (`reason: "max_attempts"`) |

Callbacks use `channel: "flake"` and `X-Briq-Service-Type: flake`. The plaintext code is never included.

### Envelope example (`flake.verified`)

```json theme={null}
{
  "id": "evt_...",
  "event": "flake.verified",
  "channel": "flake",
  "app_id": "<developer-app-uuid>",
  "created_at": "2026-06-07T12:00:00.000Z",
  "data": {
    "flake_id": "...",
    "phone_number": "255712345678",
    "status": "verified",
    "verified_at": "2026-06-07T12:00:05.000000"
  }
}
```

Delivery is **best-effort** with retries on non-2xx responses. Synchronous `/verify` responses are unchanged if a callback fails.

### Testing in the API playground

1. Open **POST /v1/otp/request** in this reference's interactive playground.
2. Set `callback_url` to a public HTTPS inspector URL (e.g. from [webhook.site](https://webhook.site)).
3. Optionally set `callback_secret` and note it for signature checks.
4. Call **POST /v1/otp/verify** with the code received on the phone.
5. Confirm the inspector receives `flake.verified` within a few seconds.

Use the same `callback_url` fields on **POST /v1/otp/resend** to set the callback target for a resent code.

***

## 11. Changelog

* **v1.2** — optional per-request `callback_url` and `callback_secret` on `request`, `resend`, and `verify`; async `flake.verified` and `flake.failed` callbacks (best-effort, signed when secret provided).
* **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)`.
