OTP Integration via WhatsApp
Documentation for connecting your application to the Cascade service.
Base URL: https://otp.kztusdt.kz/api
Table of contents
- Overview
- Getting an API token
- Authentication
- Phone number format
- Sending OTP
- Short links
- Verifying OTP
- Response codes and errors
- Limits
- Integration examples
- Recommendations
- Outgoing webhooks
Overview
┌─────────────────┐ POST /otp/send ┌──────────────────┐
│ Your app │ ──────────────────────► │ otp.kztusdt.kz │
│ (site, API) │ │ Laravel API │
└────────┬────────┘ └────────┬─────────┘
│ │
│ User enters the code │ WhatsApp
│ ▼
│ ┌──────────────────┐
│ POST /otp/verify │ Client number │
└───────────────────────────────► └──────────────────┘
Typical flow:
- The user enters a phone number in your application.
- Your backend calls
POST /api/otp/send. - The client receives the code in WhatsApp on the specified number.
- The user enters the code in your form.
- Your backend calls
POST /api/otp/verify. - On success — confirm the account / sign-in / operation.
Getting an API token
- Sign in to the company cabinet: https://otp.kztusdt.kz/cabinet
- Open the API keys section
- Click Create
- Enter a name (for example,
My website) - Copy the key — you can view it again in the list (the «Show» button)
Store the token only on the server (environment variables, secrets). Do not put it in the frontend, a mobile app, or a public repository.
Authentication
All OTP API requests require the header:
Authorization: Bearer <YOUR_API_TOKEN>
Content-Type: application/json
Accept: application/json
If the token is missing or invalid, the server returns 401 Unauthorized:
{
"success": false,
"message": "Invalid API token"
}
Phone number format
The phone field is a string — digits only, or with formatting characters (they will be stripped).
| Input | Normalized form |
|---|---|
+7 700 123 45 67 |
77001234567 |
87001234567 |
77001234567 |
77001234567 |
77001234567 |
Rules:
- Length after normalization: 10–15 digits
- A number starting with
8(11 digits, Kazakhstan) is automatically replaced with7…
Sending OTP
Request
POST /api/otp/send
Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
phone |
string | yes | Recipient phone number |
purpose |
string | no | Code purpose (default verification) |
channel |
string | no | whatsapp, telegram, sms, or auto. If omitted — company default from the dashboard (Where to send). auto load-balances across WhatsApp and Telegram numbers; if Telegram fails, falls back to WhatsApp. A channel disabled in company settings returns an error. |
link |
string | no | Full http/https URL — shortened and added to the same message |
link_expires_in |
integer | no | Short link lifetime in seconds (60–2592000) |
Example:
{
"phone": "77001234567",
"purpose": "registration",
"link": "https://example.com/confirm/abc"
}
The purpose field lets you separate codes for different scenarios (registration, login, password reset). On verification you must pass the same purpose.
If link is provided, the service creates a short URL like https://otp.kztusdt.kz/s/Ab3xK9 and embeds it in the message with the code. Cost per delivery: WhatsApp/Telegram = 1 credit, SMS = 16 credits (code+link together does not double the cost).
Successful response — 200 OK
{
"success": true,
"message": "OTP sent via WhatsApp",
"expires_in": 300,
"short_url": "https://otp.kztusdt.kz/s/Ab3xK9"
}
| Field | Description |
|---|---|
expires_in |
Code lifetime in seconds (default 300 = 5 minutes) |
short_url |
Short link (only when link was sent) |
Errors — 422 Unprocessable Entity
{
"success": false,
"message": "Неверный формат номера телефона"
}
{
"success": false,
"message": "WhatsApp не подключён. Обратитесь к администратору."
}
{
"success": false,
"message": "Подождите перед повторной отправкой OTP"
}
{
"success": false,
"message": "Не удалось отправить OTP через WhatsApp"
}
Short links
Short links use the same domain: https://otp.kztusdt.kz/s/{slug} → 302 to the target URL. Clicks are counted. Inactive or expired links return 404.
Manage them in the company dashboard (Short links): create, copy, clicks, expiry, on/off.
Shorten without sending — free
POST /api/links/shorten
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | Target http/https URL |
alias |
string | no | Custom slug (3–32: letters, digits, -, _) |
title |
string | no | Label for the dashboard |
expires_in |
integer | no | Lifetime in seconds |
{
"url": "https://example.com/very/long/path",
"alias": "promo-may"
}
{
"success": true,
"short_url": "https://otp.kztusdt.kz/s/promo-may",
"slug": "promo-may",
"expires_at": null
}
Send a link without a code — 1 credit
POST /api/links/send
| Field | Type | Required | Description |
|---|---|---|---|
phone |
string | yes | Recipient phone |
url |
string | yes | Target http/https URL |
channel |
string | no | whatsapp, telegram, sms, or auto (must be allowed / valid in company settings) |
text |
string | no | Message with :link placeholder (otherwise default template) |
expires_in |
integer | no | Short link lifetime |
{
"phone": "77001234567",
"url": "https://example.com/promo",
"text": "Your offer: :link"
}
{
"success": true,
"message": "Link sent via WhatsApp",
"short_url": "https://otp.kztusdt.kz/s/Ab3xK9",
"slug": "Ab3xK9"
}
Billing: WhatsApp and Telegram cost 1 credit per delivery; SMS costs 16 credits. In auto mode the least-loaded WhatsApp/Telegram number is used first; SMS is only used when messengers are unavailable (and SMS is allowed, balance ≥ 16). Shortening without sending is free.
Verifying OTP
Request
POST /api/otp/verify
Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
phone |
string | yes | The same number as when sending |
code |
string | yes | Code from WhatsApp |
purpose |
string | no | Same value as in send (default verification) |
Example:
{
"phone": "77001234567",
"code": "482910",
"purpose": "registration"
}
Successful response — 200 OK
{
"success": true,
"message": "Номер подтверждён"
}
After a successful verification the code is one-time — reusing the same code will return an error.
Error — 422 Unprocessable Entity
{
"success": false,
"message": "Неверный или просроченный код"
}
Response codes and errors
| HTTP | Meaning |
|---|---|
200 |
Success (success: true) |
401 |
Missing or invalid API token |
422 |
Business-logic error (success: false) |
422 |
Field validation error (Laravel validation) |
Validation (example):
{
"message": "The phone field is required.",
"errors": {
"phone": ["The phone field is required."]
}
}
Always check the success field in the response body, not only the HTTP status.
Limits
| Parameter | Default value |
|---|---|
| Code length | 6 digits |
| Validity period | 300 sec (5 min) |
| Resend to the same number | no more than once per 60 sec |
WhatsApp message (template):
Ваш код подтверждения: 123456. Действителен 5 мин. Не сообщайте код никому.
Integration examples
cURL
# Send OTP
curl -X POST "https://otp.kztusdt.kz/api/otp/send" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"phone":"77001234567","purpose":"registration"}'
# Verify code
curl -X POST "https://otp.kztusdt.kz/api/otp/verify" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"phone":"77001234567","code":"482910","purpose":"registration"}'
PHP (Laravel / Guzzle)
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Http;
final class OtpClient
{
public function __construct(
private readonly string $baseUrl = 'https://otp.kztusdt.kz/api',
private readonly string $token = '', // from env('OTP_API_TOKEN')
) {}
public function send(string $phone, string $purpose = 'verification'): array
{
$response = Http::withToken($this->token)
->acceptJson()
->post("{$this->baseUrl}/otp/send", [
'phone' => $phone,
'purpose' => $purpose,
]);
return $response->json();
}
public function verify(string $phone, string $code, string $purpose = 'verification'): array
{
$response = Http::withToken($this->token)
->acceptJson()
->post("{$this->baseUrl}/otp/verify", [
'phone' => $phone,
'code' => $code,
'purpose' => $purpose,
]);
return $response->json();
}
}
// Usage
$otp = new OtpClient(token: config('services.otp.token'));
$result = $otp->send('77001234567', 'registration');
if (! ($result['success'] ?? false)) {
throw new RuntimeException($result['message'] ?? 'OTP send failed');
}
$check = $otp->verify('77001234567', '482910', 'registration');
if ($check['success'] ?? false) {
// number confirmed — create a session / activate the user
}
JavaScript (Node.js / fetch)
const BASE_URL = 'https://otp.kztusdt.kz/api';
const API_TOKEN = process.env.OTP_API_TOKEN;
async function sendOtp(phone, purpose = 'verification') {
const res = await fetch(`${BASE_URL}/otp/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ phone, purpose }),
});
return res.json();
}
async function verifyOtp(phone, code, purpose = 'verification') {
const res = await fetch(`${BASE_URL}/otp/verify`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ phone, code, purpose }),
});
return res.json();
}
// Example
const sent = await sendOtp('77001234567', 'login');
if (!sent.success) {
console.error(sent.message);
}
const verified = await verifyOtp('77001234567', '123456', 'login');
if (verified.success) {
console.log('OK');
}
Python
import os
import requests
BASE_URL = "https://otp.kztusdt.kz/api"
TOKEN = os.environ["OTP_API_TOKEN"]
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def send_otp(phone: str, purpose: str = "verification") -> dict:
r = requests.post(
f"{BASE_URL}/otp/send",
json={"phone": phone, "purpose": purpose},
headers=headers,
timeout=30,
)
return r.json()
def verify_otp(phone: str, code: str, purpose: str = "verification") -> dict:
r = requests.post(
f"{BASE_URL}/otp/verify",
json={"phone": phone, "code": code, "purpose": purpose},
headers=headers,
timeout=30,
)
return r.json()
Recommendations
Security
- Call the API only from the backend, not directly from the browser.
- Do not log the API token or OTP codes in plain text.
- On your side, limit code entry attempts (for example, 5 attempts → lock for 15 minutes).
UX
- Show a timer until resend is allowed (minimum 60 sec).
- Tell the user that the code will arrive in WhatsApp, not SMS.
- The number must have WhatsApp installed.
Different scenarios (purpose)
| purpose | When to use |
|---|---|
registration |
New user registration |
login |
Sign-in by phone number |
password_reset |
Password reset |
verification |
General verification (default) |
For one number, several active codes with different purpose values can exist at the same time.
Error handling on your side
| API message | Action in the app |
|---|---|
WhatsApp не подключён |
Show “Service temporarily unavailable”, notify the admin |
Подождите перед повторной отправкой |
Show a “Send again” button with a timer |
Неверный или просроченный код |
Ask to re-enter the code or request a new one |
Неверный формат номера |
Highlight the phone field |
Pre-production checklist
- WhatsApp is connected in the admin panel (WhatsApp → Connect).
- An API token is created and active.
- Test
send+verifyon a real number with WhatsApp. - The send appears in OTP History.
Outgoing webhooks
In the cabinet (Integration → Webhooks) you can set a URL that the service will call with events:
| Event | When |
|---|---|
otp.sent |
Code sent successfully |
otp.verified |
Code confirmed |
otp.failed |
Send / billing error |
Request: POST with a JSON body. Headers:
Content-Type: application/json
X-Webhook-Event: otp.sent
X-Webhook-Signature: sha256=<hmac_sha256_hex_of_raw_body>
Signature: HMAC-SHA256 of the raw request body with the webhook secret (shown once on create / rotate).
Verification example (PHP):
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
hash_equals($expected, $request->header('X-Webhook-Signature'));
Payload example:
{
"id": "evt_01h…",
"event": "otp.sent",
"created_at": "2026-07-21T10:00:00+00:00",
"company_id": 1,
"data": {
"otp_log_id": 42,
"phone": "77001234567",
"purpose": "verification",
"status": "sent",
"api_token_id": 3,
"session_name": "wa-1",
"expires_at": "2026-07-21T10:05:00+00:00",
"verified_at": null,
"error_message": null
}
}
Delivery is asynchronous (queue). Requires php artisan queue:work. Up to 3 retries.
Support
For questions about connection, billing, and service operation:
- Email: support@otp.kztusdt.kz
- Phone: +7 (777) 123-45-67