Skip to content

API documentation

OTP Integration via WhatsApp

Documentation for connecting your application to the Cascade service.

Base URL: https://cascade.kz/api


Table of contents

  1. Overview
  2. Getting an API token
  3. Authentication
  4. Phone number format
  5. Sending OTP
  6. Short links
  7. Verifying OTP
  8. Response codes and errors
  9. Limits
  10. Integration examples
  11. Recommendations
  12. 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:

  1. The user enters a phone number in your application.
  2. Your backend calls POST /api/otp/send.
  3. The client receives the code in WhatsApp on the specified number.
  4. The user enters the code in your form.
  5. Your backend calls POST /api/otp/verify.
  6. On success — confirm the account / sign-in / operation.

Getting an API token

  1. Sign in to the company cabinet: https://cascade.kz/cabinet
  2. Open the API keys section
  3. Click Create
  4. Enter the site name and HTTPS URL. A key is not issued at signup — create it here.
  5. Prove domain ownership: publish https://your-domain/.well-known/cascade-otp.txt with the one-time code from the cabinet. The key is not created without that file.
  6. 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 with 7…

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

  1. WhatsApp is connected in the admin panel (WhatsApp → Connect).
  2. An API token is created and active.
  3. Test send + verify on a real number with WhatsApp.
  4. 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: