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

# OTP verification

> Verify phone numbers with one-time passcodes using the UgaText Verify API: start and check codes, handle resends, test in the sandbox, and manage Verify Services.

The Verify API sends a one-time code to a phone number and checks the code your user enters.

```text Base URL theme={null}
https://ugatext.com/api/v1
```

Verify endpoints are also available under `/v1/*`.

## Supported countries

Numbers are routed automatically to the right carrier in each country:

| Country | Calling code |
| - | - |
| Uganda | `+256` |
| Kenya | `+254` |
| Tanzania | `+255` |
| DR Congo | `+243` |

## How codes are protected

* **Secure generation:** codes are generated from cryptographically secure random bytes.
* **No plaintext storage:** codes are hashed server-side with HMAC-SHA256 and a per-session salt. Plaintext codes are never stored in the database.
* **Constant-time checks:** submitted codes are compared in constant time (`crypto.timingSafeEqual`) to prevent timing attacks.
* **Automatic refunds:** wallet units are reserved before the OTP is sent. If the carrier fails to deliver it (`SMS_DELIVERY_FAILED`), the reserved units are refunded automatically.

## Authentication

All `/verify/*` endpoints accept any of these authentication methods:

| Method | Headers |
| - | - |
| Client credentials | `Client-ID: <client_id>` and `Client-Secret: <client_secret>` (or `X-Client-ID` and `X-Client-Secret`) |
| Bearer token | `Authorization: Bearer <client_id>:<client_secret>` or `Authorization: Bearer <api_key_or_pat>` |
| Basic auth | `Authorization: Basic <base64(client_id:client_secret)>` |
| API key header | `X-API-Key: <client_id>:<client_secret>` |

See [Authentication](https://docs.ugatext.com/authentication.md) for how to get your credentials.

## Verify a phone number

**1. Start a verification**

`POST /verify/start` sends an OTP to the phone number.

```bash theme={null}
curl -X POST https://ugatext.com/api/v1/verify/start \
  -H "Client-ID: YOUR_CLIENT_ID" \
  -H "Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+256772123456",
    "service_id": "default",
    "external_reference": "user_signup_9841"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `phone_number` | string | Yes | Destination number in E.164 or local format, for example `+256772123456`. Aliases: `phoneNumber`, `to`, `phone`, `recipient`. |
| `service_id` | string | No | Your Verify Service ID (`vsvc_...`). Defaults to `default`, which uses your account's default phone verification service. Aliases: `serviceId`, `app_id`, `appId`. |
| `external_reference` | string | No | Your internal correlation ID, for example `user_signup_9841`. Alias: `externalReference`. |

```json Response (200 OK) theme={null}
{
  "success": true,
  "verification_id": "v_1760425200000_8fa12bc3901d",
  "service_id": "vsvc_1760400000000_9b8a1c2d",
  "status": "pending",
  "expires_at": "2026-10-30T15:30:00.000Z",
  "resend_after": "2026-10-30T15:26:00.000Z",
  "masked_phone": "+256772 ••• 456",
  "recipient_masked": "+256772 ••• 456",
  "test_mode": false,
  "mode": "live",
  "carrier_segments": 1,
  "cost_units": 1,
  "cost_ugx": 28
}
```

Save the returned `verification_id`. You need it to check the code.

**2. Check the code**

`POST /verify/check` checks the code your user entered.

```bash theme={null}
curl -X POST https://ugatext.com/api/v1/verify/check \
  -H "Client-ID: YOUR_CLIENT_ID" \
  -H "Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "v_1760425200000_8fa12bc3901d",
    "code": "482910"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `verification_id` | string | Yes | The `verification_id` returned by `/verify/start`. Aliases: `verificationId`, `session_id`, `sessionId`, `id`. |
| `code` | string | Yes | The numeric code the user entered, for example `482910`. Aliases: `otp`, `token`. |

```json Approved (200 OK) theme={null}
{
  "success": true,
  "status": "approved",
  "verification_id": "v_1760425200000_8fa12bc3901d",
  "verified_at": "2026-10-30T15:25:42.110Z"
}
```

```json Wrong code (400 Bad Request) theme={null}
{
  "success": false,
  "status": "invalid",
  "verification_id": "v_1760425200000_8fa12bc3901d",
  "attempts_remaining": 4,
  "error": {
    "code": "INVALID_CODE",
    "message": "The verification code entered is incorrect. 4 attempt(s) remaining."
  }
}
```

## Resends and rate limits

### Resend a code

To resend an OTP, call `POST /verify/start` again with the same `phone_number` after the `resend_after` time has passed. The default cooldown is 60 seconds.

If you call it before `resend_after`, the API returns `429 COOLDOWN_ACTIVE`, and `error.retry_after_seconds` tells you how many seconds remain.

### Anti-abuse limits

| Scope | Limit | Error |
| - | - | - |
| Per phone number | 1 request per cooldown window (default 60 seconds), and at most 5 OTPs per hour | `429 PHONE_RATE_LIMITED` |
| Per client IP | 30 requests per minute | `429 IP_RATE_LIMITED` |
| Per account | 300 requests per minute | `429 ACCOUNT_RATE_LIMITED` |

## Sandbox testing

Test the full flow for free, without spending SMS balance.

Send `POST /verify/start` to one of these sandbox numbers:

| Country | Sandbox number |
| - | - |
| Uganda | `+256700000000` |
| Kenya | `+254700000000` |
| Tanzania | `+255700000000` |
| DR Congo | `+243810000000` |

You can also use a test mode Verify Service (`is_test_mode: true`) or a [test API key](https://docs.ugatext.com/sandbox.md#test-api-keys).

In sandbox and test mode:

* `cost_units` is `0`.
* No real SMS is sent.
* Submitting `"code": "123456"` to `POST /verify/check` always returns `"status": "approved"`.

## Verify Services

A Verify Service controls the message template, Sender ID, code length, expiry, and limits for your OTPs. Use separate services for different flows, such as login and password reset.

### Create a service

`POST /verify/services` (or `POST /verify/apps`) creates a Verify Service.

```bash theme={null}
curl -X POST https://ugatext.com/api/v1/verify/services \
  -H "Client-ID: YOUR_CLIENT_ID" \
  -H "Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mobile App Login",
    "purpose": "login_2fa",
    "template": "Your UgaText verification code is {{code}}. Valid for 5 minutes.",
    "code_length": 6,
    "expiry_seconds": 300,
    "resend_cooldown_seconds": 60,
    "max_attempts": 5,
    "allowed_countries": ["UG", "KE"],
    "is_test_mode": false
  }'
```

| Field | Type | Required | Default | Description |
| - | - | - | - | - |
| `name` | string | Yes | | Service name, for example `Mobile App Login`. |
| `purpose` | string | No | | One of `phone_verification`, `login_2fa`, `password_reset`, `transaction_approval`, `account_recovery`. |
| `template` | string | No | `Your UgaText verification code is {{code}}. Valid for 5 minutes.` | Message text. Must include `{{code}}` or `{code}`. |
| `sender_id` | string | No | `UgaText` | Approved 1 to 11 character alphanumeric [Sender ID](https://docs.ugatext.com/sms/sender-ids.md). Sender IDs are case-insensitive, so `UgaText` and `UGATEXT` both resolve to the default sender. |
| `code_length` | integer | No | `6` | `4`, `5`, `6`, or `8`. |
| `expiry_seconds` | integer | No | `300` | 60 to 3600 (1 to 60 minutes). |
| `resend_cooldown_seconds` | integer | No | `60` | 15 to 600. |
| `max_attempts` | integer | No | `5` | 3 to 10. |
| `allowed_countries` | array | No | `["ALL"]` | Any of `UG`, `KE`, `TZ`, `CD`, or `ALL`. |
| `is_test_mode` | boolean | No | `false` | Enables [sandbox behavior](#sandbox-testing) for this service. |

### Manage services, sessions, and analytics

| Action | Endpoint |
| - | - |
| List Verify Services with delivery and conversion stats | `GET /verify/services` |
| Retrieve a Verify Service | `GET /verify/services/{id}` |
| Update a Verify Service | `PUT /verify/services/{id}` |
| Delete a Verify Service | `DELETE /verify/services/{id}` |
| List verification sessions | `GET /verify/sessions` |
| Get aggregate verification metrics | `GET /verify/analytics` |

`PUT /verify/services/{id}` accepts `template`, `senderId`, `codeLength`, `expirySeconds`, `resendCooldownSeconds`, `maxAttempts`, `isActive`, and `isTestMode`.

`GET /verify/sessions` supports these query filters:

| Parameter | Description |
| - | - |
| `service_id` | Only sessions for this Verify Service. |
| `status` | `pending`, `approved`, `invalid`, `expired`, `locked`, or `delivery_failed`. |
| `start_date` | Sessions created on or after this date. |
| `end_date` | Sessions created on or before this date. |

`GET /verify/analytics` returns `totalRequests`, `successfulVerifications`, `successRate`, `incorrectAttempts`, `expiredSessions`, `lockedSessions`, and `deliveryFailures`.

## Verify errors

| Status | Code | Meaning |
| - | - | - |
| 400 | `MISSING_REQUIRED_FIELDS` | A required field (`phone_number`, `verification_id`, or `code`) is missing. |
| 400 | `INVALID_PHONE_NUMBER` | The number is not a valid Ugandan (+256), Kenyan (+254), Tanzanian (+255), or DR Congo (+243) mobile number. |
| 400 | `COUNTRY_RESTRICTED` | The destination country is not in the Verify Service's `allowed_countries` list. |
| 400 | `INVALID_CODE` | The code is wrong. Check `attempts_remaining` in the response. |
| 400 | `SESSION_INVALIDATED` | The verification secret was invalidated or cleared. Start a new session. |
| 400 | `SMS_DELIVERY_FAILED` | The carrier failed to deliver the OTP. Reserved wallet units are refunded automatically. |
| 401 | `INVALID_API_KEY` | Missing or invalid `Client-ID` and `Client-Secret`, or Bearer token. |
| 402 | `INSUFFICIENT_BALANCE` | Not enough SMS balance in your wallet to send the OTP. |
| 403 | `IP_NOT_WHITELISTED` | The requesting server IP is not in the API key's [whitelist](https://docs.ugatext.com/authentication.md#ip-whitelisting). |
| 404 | `SERVICE_NOT_FOUND` | The `service_id` does not exist or belongs to another account. |
| 404 | `SERVICE_INACTIVE` | The Verify Service is disabled. |
| 404 | `VERIFICATION_NOT_FOUND` | Unknown `verification_id`. |
| 410 | `CODE_EXPIRED` | The OTP window (`expires_at`) has passed. |
| 410 | `SESSION_ALREADY_VERIFIED` | This `verification_id` was already approved and can't be reused. |
| 429 | `COOLDOWN_ACTIVE` | Resend requested before `resend_after`. Wait `error.retry_after_seconds`. |
| 429 | `MAX_ATTEMPTS_EXCEEDED` | Too many incorrect attempts. The session is now locked. |
| 429 | `PHONE_RATE_LIMITED` | More than 5 OTP requests to the same number within 1 hour. |
| 429 | `IP_RATE_LIMITED` | More than 30 OTP requests from the same IP within 1 minute. |
| 429 | `ACCOUNT_RATE_LIMITED` | More than 300 OTP requests on the account within 1 minute. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.