> ## 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.

# Validating OTP Codes

> Verify a code submitted by the end user against the active OTP for a (phone, app) pair using POST /v1/otp/verify.

Use `POST /v1/otp/verify` to check a plaintext code submitted by the end user. The endpoint is keyed on `(phone_number, developer app, code)` — the developer app is resolved from your bound `X-API-Key`, so you do not send `app_key` when the key is bound. There is no separate `otp_id` to track, and the call is the same regardless of whether the code was originally delivered via SMS, voice call, or WhatsApp. The server looks up the single active OTP for that phone within your Developer App and compares the bcrypt hash.

The API enforces a **hard cap of 3 attempts** per active OTP. After the third wrong code the OTP is auto-locked and the user must request a new one. Drive your UI off `data.remaining_attempts` so the user always knows where they stand.

## Endpoint

```http theme={null}
POST https://karibu.briq.tz/v1/otp/verify
```

**Headers:** `X-API-Key` (required — bind to a Developer App for OTP), `Content-Type: application/json` (required).

## Request body

| Field             | Type                  | Required | Notes                                                                                                       |
| ----------------- | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `phone_number`    | string (E.164 digits) | yes      | Digits only, no `+`. 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. |
| `code`            | string                | yes      | Plaintext code submitted by the user.                                                                       |
| `callback_url`    | string (HTTPS)        | no       | Override or fallback callback URL if not set on request/resend.                                             |
| `callback_secret` | string                | no       | Signing secret override; secret from request/resend is reused when omitted.                                 |

<Note>
  Trim whitespace and strip non-digits from the user's input before submitting.
</Note>

## Success response

```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`.

If you passed `callback_url` on the original `request` or `resend`, Karibu also sends an async `flake.verified` webhook to that URL (best-effort). On max-attempt lockout, a `flake.failed` callback is sent instead. The synchronous response above is unchanged — see [Flake lifecycle callbacks](/guides/otp-callbacks).

## Handled errors

These are returned in the standard envelope with `success: false`. Branch on `body.message` and `body.data.remaining_attempts`:

| `message`                             | `data.remaining_attempts` | When                                                            | Recommended UX                                             |
| ------------------------------------- | ------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------- |
| `"No valid OTP found"`                | `0`                       | No active OTP for this `(phone, app)`, expired, or already used | Prompt the user to request a new code.                     |
| `"Invalid OTP code"`                  | `2`, `1`, `0`             | Wrong code; counter decrements with each failed attempt.        | Show "X attempts remaining"; allow retry until `0`.        |
| `"Max verification attempts reached"` | `0`                       | 3rd wrong attempt — OTP is now locked.                          | Disable the verify button; force the user to resend.       |
| `"Invalid phone number"`              | —                         | Malformed phone (non-digit, wrong shape).                       | Re-validate digits-only E.164 client-side before retrying. |

Auth (`401`), host/scoping (`403`), and validation (`422`) errors share the same shape as on `/v1/otp/request`. See [Error scenarios](/Karibu-OTP/index#8-error-scenarios--quick-reference) for the full matrix.

## 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}
  import requests

  BASE_URL = "https://karibu.briq.tz"
  API_KEY  = "YOUR_API_KEY"
  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 BASE_URL = "https://karibu.briq.tz";
  const API_KEY  = "YOUR_API_KEY";
  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}
  <?php
  $baseUrl = "https://karibu.briq.tz";
  $apiKey  = "YOUR_API_KEY";
  $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>

## Best practices

* **Branch on `success` first**, then on `data.remaining_attempts`. Treat `0` as a hard lockout — the user must resend.
* **Never log the plaintext code.** Don't echo it from your verify form, store it in analytics, or include it in error reports.
* **Strip whitespace and non-digits** from the user's input before submitting to avoid spurious "Invalid OTP code" responses.
* **Use the same bound API key** (and Developer App) that issued the OTP. Cross-app verifies always fail with `"No valid OTP found"`.

## What's next

* For "Resend code" buttons, idempotent invalidation on logout, and live countdowns, see [Managing OTP lifecycle](/guides/otp-lifecycle).
* For payload-level detail and the full error matrix, see the [Karibu OTP API reference](/Karibu-OTP/index).
