OTP Integration via WhatsApp
Documentation for connecting your application to the Cascade service.
Base URL: https://cascade.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 │ ──────────────────────► │ cascade.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://cascade.kz/cabinet
- Open the API keys section
- Click Create
- Enter the site name and HTTPS URL. A key is not issued at signup — create it here.
- Prove domain ownership: publish
https://your-domain/.well-known/cascade-otp.txtwith the one-time code from the cabinet. The key is not created without that file. - 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.
OTP API accepts requests only from Kazakhstan IPs and only from the .kz domain bound to the key. From your backend you must send Origin or X-Cascade-Client-Domain matching the bound domain. A request without that header or from another domain returns 403. A key is issued only for a .kz site whose host resolves to a Kazakhstan IP.
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) |
locale |
string | no | OTP message language: ru (default), kk, or en. Templates: GET /api/otp/messages |
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://cascade.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,
"otp_id": 1241,
"channel": "whatsapp",
"locale": "ru",
"credits_charged": 1,
"short_url": "https://cascade.kz/s/Ab3xK9"
}
| Field | Description |
|---|---|
otp_id |
Send id. Status: GET /api/otp/{otp_id} |
expires_in |
Code lifetime in seconds (default 300 = 5 minutes) |
channel |
Actual delivery channel |
locale |
Message language |
credits_charged |
Credits deducted |
short_url |
Short link (only when link was sent) |
Optional header Idempotency-Key (max 255 chars): a retry with the same key and body returns the first successful response without a second send or debit. A different body with the same key returns 422 idempotency_conflict.
GET /api/otp/messages?locale=kk returns Kazakh templates (omit locale for Russian). GET /api/otp/{id} returns that send’s status for your company.
Errors
422 — invalid number:
{
"success": false,
"error_code": "invalid_phone",
"message": "Неверный формат номера телефона"
}
503 — channel not connected:
{
"success": false,
"error_code": "channel_unavailable",
"message": "WhatsApp не подключён. Обратитесь к администратору."
}
429 — cooldown, see Retry-After:
{
"success": false,
"error_code": "cooldown",
"message": "Подождите перед повторной отправкой OTP"
}
503 — delivery failed:
{
"success": false,
"error_code": "delivery_failed",
"message": "Не удалось отправить OTP через WhatsApp"
}
Short links
Short links use the same domain: https://cascade.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://cascade.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://cascade.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,
"error_code": "invalid_code",
"message": "Неверный или просроченный код"
}
Response codes and errors
| HTTP | Meaning |
|---|---|
200 |
Success (success: true) |
401 |
Missing or invalid API token |
402 |
Not enough tokens on the balance (error_code: insufficient_balance) |
403 |
Account suspended (error_code: account_suspended) |
422 |
Business-logic error (success: false) |
422 |
Field validation error (Laravel validation) |
429 |
Rate limit or cooldown (error_code: rate_limited, cooldown), see the Retry-After header |
503 |
Channel unavailable or delivery failed (error_code: channel_unavailable, delivery_failed) |
Validation (example):
{
"message": "Поле номер телефона нужно заполнить.",
"errors": {
"phone": ["Поле номер телефона нужно заполнить."]
}
}
Always check the success field in the response body, not only the HTTP status.
API messages always come back in Russian, whatever the site language is. Branch on
error_code and the HTTP status rather than on the message text.
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 мин. Не сообщайте код никому.
WhatsApp group notifications
POST /api/group/send
| Field | Type | Required | Description |
|---|---|---|---|
group |
string | yes | Name or id (…@g.us) |
message |
string | yes | Notification text |
curl -X POST "https://cascade.kz/api/group/send" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"group":"Business chat","message":"Новая заявка #123"}'
{
"success": true,
"message": "Сообщение отправлено в группу",
"group_id": "120363411273110879@g.us",
"group_name": "Business chat",
"message_id": null
}
In production, store group_id and send by id afterwards. Details: docs/group-notify-integration.md.
Integration examples
cURL
# Send OTP
curl -X POST "https://cascade.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://cascade.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://cascade.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://cascade.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://cascade.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
error_code |
HTTP | Action in the app |
|---|---|---|
channel_unavailable |
503 |
Show “Service temporarily unavailable”, notify the admin |
cooldown |
429 |
Show a “Send again” button with a timer from Retry-After |
rate_limited |
429 |
Slow the sends down, retry after Retry-After |
delivery_failed |
503 |
“Try again later” — delivery failed or the number has no WhatsApp |
insufficient_balance |
402 |
Tell the admin the balance needs a top-up |
account_suspended |
403 |
Stop sending and contact support |
invalid_code |
422 |
Ask to re-enter the code or request a new one |
invalid_phone |
422 |
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