Cascade Cascade

API

API documentation

OTP Integration via WhatsApp

Documentation for connecting your application to the Cascade service.

Base URL: https://otp.kztusdt.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        │ ──────────────────────► │  otp.kztusdt.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://otp.kztusdt.kz/cabinet
  2. Open the API keys section
  3. Click Create
  4. Enter a name (for example, My website)
  5. 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 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)

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

  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: