Skip to content

API Quickstart ​

Base URL: https://your-domain.com

This guide takes you from zero to a working integration in five steps, in the language of your choice:

  1. Install an HTTP client
  2. Log in and get a token
  3. Call a protected endpoint
  4. Keep the session alive — refresh the token before it expires, re-login when it has
  5. Create a subscriber and list subscribers

At the end you'll find a complete, reusable client that bundles all of this.

Pick your language once

Every code block on this site has cURL · PHP · JavaScript · Python · Go · Rust tabs. Select a tab anywhere and every other example switches to the same language — the choice is remembered on your next visit.

How authentication works ​

LoginPOST /api/v1/users/login with email (or username) + password
TokenSend Authorization: Bearer <token> on every protected request
Lifetime30 days by default (server-configurable) — the login response tells you exactly when in data.expires_at
RefreshPOST /api/v1/users/refresh-token returns a new token and revokes the old one
Expired?Requests return 401 with {"message": "Unauthenticated."} — an expired token can't be refreshed, log in again

Tokens can die before expires_at

A token is revoked immediately — regardless of its lifetime — when the user is disabled, when the user's password is changed (by anyone, including an admin), or when logout / logout-all is called. Refreshing also rotates the token: the old one stops working the moment the new one is issued. So never assume a stored token is valid just because expires_at is in the future — always handle 401 by logging in again.

The robust pattern used by the client below:

  1. Before each request, if less than a day of lifetime is left, refresh the token.
  2. If a request still comes back 401, log in again and retry the request once.

Multi-tenancy

Most endpoints require isp_id and branch_id. Use the values returned in data.user from the login response unless you're an admin working across several ISPs.


1. Install an HTTP client ​

bash
composer require guzzlehttp/guzzle
bash
# Node.js 18+ ships with fetch — nothing to install.
node --version
bash
pip install requests
bash
# Uses the standard library only.
go mod init zalultra-example
bash
cargo add reqwest --features json
cargo add tokio --features full
cargo add serde_json

2. Log in and get a token ​

Send your credentials once and keep the token and expires_at from the response.

bash
curl -X POST "https://your-domain.com/api/v1/users/login" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "your-password"
  }'
php
<?php

require 'vendor/autoload.php';

use GuzzleHttp\Client;

$http = new Client(['base_uri' => 'https://your-domain.com']);

$response = $http->post('/api/v1/users/login', [
    'headers' => ['Accept' => 'application/json'],
    'json'    => [
        'email'    => '[email protected]',
        'password' => 'your-password',
    ],
]);

$auth = json_decode($response->getBody(), true)['data'];

$token     = $auth['token'];       // "12|k3jH8sDf..."
$expiresAt = $auth['expires_at'];  // "2025-01-02 10:30:00"
$ispId     = $auth['user']['isp_id'];
$branchId  = $auth['user']['branch_id'];
javascript
const BASE_URL = 'https://your-domain.com';

const response = await fetch(`${BASE_URL}/api/v1/users/login`, {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: '[email protected]',
    password: 'your-password',
  }),
});

const { data: auth } = await response.json();

const token = auth.token;          // "12|k3jH8sDf..."
const expiresAt = auth.expires_at; // "2025-01-02 10:30:00"
const { isp_id: ispId, branch_id: branchId } = auth.user;
python
import requests

BASE_URL = "https://your-domain.com"

response = requests.post(
    f"{BASE_URL}/api/v1/users/login",
    headers={"Accept": "application/json"},
    json={
        "email": "[email protected]",
        "password": "your-password",
    },
)

auth = response.json()["data"]

token = auth["token"]            # "12|k3jH8sDf..."
expires_at = auth["expires_at"]  # "2025-01-02 10:30:00"
isp_id = auth["user"]["isp_id"]
branch_id = auth["user"]["branch_id"]
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

const baseURL = "https://your-domain.com"

type loginResponse struct {
	Data struct {
		Token     string `json:"token"`
		ExpiresAt string `json:"expires_at"`
		User      struct {
			IspID    int `json:"isp_id"`
			BranchID int `json:"branch_id"`
		} `json:"user"`
	} `json:"data"`
}

func main() {
	payload, _ := json.Marshal(map[string]string{
		"email":    "[email protected]",
		"password": "your-password",
	})

	req, _ := http.NewRequest("POST", baseURL+"/api/v1/users/login", bytes.NewBuffer(payload))
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var auth loginResponse
	json.NewDecoder(resp.Body).Decode(&auth)

	token := auth.Data.Token         // "12|k3jH8sDf..."
	expiresAt := auth.Data.ExpiresAt // "2025-01-02 10:30:00"
	fmt.Println(token, expiresAt, auth.Data.User.IspID, auth.Data.User.BranchID)
}
rust
use reqwest::Client;
use serde_json::{json, Value};

const BASE_URL: &str = "https://your-domain.com";

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let client = Client::new();

    let auth: Value = client
        .post(format!("{BASE_URL}/api/v1/users/login"))
        .header("Accept", "application/json")
        .json(&json!({
            "email": "[email protected]",
            "password": "your-password"
        }))
        .send()
        .await?
        .json()
        .await?;

    let token = auth["data"]["token"].as_str().unwrap();          // "12|k3jH8sDf..."
    let expires_at = auth["data"]["expires_at"].as_str().unwrap(); // "2025-01-02 10:30:00"
    let isp_id = auth["data"]["user"]["isp_id"].as_i64().unwrap();
    let branch_id = auth["data"]["user"]["branch_id"].as_i64().unwrap();

    println!("{token} {expires_at} {isp_id} {branch_id}");
    Ok(())
}

Response:

json
{
    "status": "success",
    "message": "Login Successful",
    "data": {
        "user": { "id": 1, "name": "Admin User", "email": "[email protected]", "profile_type": 1, "isp_id": 1, "branch_id": 1 },
        "token": "12|k3jH8sDf9aLpQwErTyUiOp1234567890abcdef",
        "token_type": "Bearer",
        "expires_at": "2025-01-02 10:30:00"
    }
}

Store the token securely

Treat the token like a password: keep it server-side or in secure storage, never in front-end source code or version control. Login is rate-limited to 5 attempts per minute — cache the token instead of logging in on every request.


3. Call a protected endpoint ​

Add the Authorization header. GET /api/v1/users/profile is a handy way to check a stored token still works.

bash
curl -X GET "https://your-domain.com/api/v1/users/profile" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer your-auth-token"
php
$response = $http->get('/api/v1/users/profile', [
    'headers' => [
        'Accept'        => 'application/json',
        'Authorization' => "Bearer {$token}",
    ],
]);

$profile = json_decode($response->getBody(), true)['data']['user'];
echo $profile['name'];
javascript
const response = await fetch(`${BASE_URL}/api/v1/users/profile`, {
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
  },
});

const { data } = await response.json();
console.log(data.user.name);
python
response = requests.get(
    f"{BASE_URL}/api/v1/users/profile",
    headers={
        "Accept": "application/json",
        "Authorization": f"Bearer {token}",
    },
)

profile = response.json()["data"]["user"]
print(profile["name"])
go
req, _ := http.NewRequest("GET", baseURL+"/api/v1/users/profile", nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
fmt.Println(body["data"].(map[string]any)["user"].(map[string]any)["name"])
rust
let profile: Value = client
    .get(format!("{BASE_URL}/api/v1/users/profile"))
    .header("Accept", "application/json")
    .bearer_auth(token)
    .send()
    .await?
    .json()
    .await?;

println!("{}", profile["data"]["user"]["name"]);

4. Keep the session alive (refresh and re-login) ​

The token expires 30 days after login (unless revoked earlier). Two things to handle:

  • Before expiry — call POST /api/v1/users/refresh-token. It returns a new token (and a new expires_at) and revokes the old one, so overwrite what you stored.
  • After expiry (or if the token was revoked by a logout) — every request returns 401 Unauthenticated.. Refresh won't work anymore; log in again with the credentials and retry the request.
bash
# Refresh (works only while the current token is still valid)
curl -X POST "https://your-domain.com/api/v1/users/refresh-token" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer your-auth-token"

# → {"status":"success","data":{"token":"13|Zx9c...","token_type":"Bearer","expires_at":"2025-01-03 10:30:00"}}
# If this returns 401, the token already expired: call /api/v1/users/login again.
php
/**
 * Returns a valid token, refreshing or re-logging-in as needed.
 * $auth is the ['token' => ..., 'expires_at' => ...] array you stored at login.
 */
function ensureToken(Client $http, array &$auth, string $email, string $password): string
{
    $expiresAt = new DateTimeImmutable($auth['expires_at']);

    // Still comfortably valid
    if ($expiresAt > new DateTimeImmutable('+1 day')) {
        return $auth['token'];
    }

    // Less than a day left: try to refresh
    $response = $http->post('/api/v1/users/refresh-token', [
        'http_errors' => false,
        'headers' => [
            'Accept'        => 'application/json',
            'Authorization' => "Bearer {$auth['token']}",
        ],
    ]);

    if ($response->getStatusCode() === 200) {
        $auth = json_decode($response->getBody(), true)['data'];
        return $auth['token'];
    }

    // Already expired or revoked: log in again
    $response = $http->post('/api/v1/users/login', [
        'headers' => ['Accept' => 'application/json'],
        'json'    => ['email' => $email, 'password' => $password],
    ]);
    $auth = json_decode($response->getBody(), true)['data'];

    return $auth['token'];
}
javascript
/**
 * Returns a valid token, refreshing or re-logging-in as needed.
 * `auth` is the { token, expires_at } object you stored at login.
 */
async function ensureToken(auth, { email, password }) {
  const oneDay = 24 * 60 * 60 * 1000;
  const expiresAt = new Date(auth.expires_at.replace(' ', 'T'));

  // Still comfortably valid
  if (expiresAt.getTime() - Date.now() > oneDay) return auth.token;

  // Less than a day left: try to refresh
  const refresh = await fetch(`${BASE_URL}/api/v1/users/refresh-token`, {
    method: 'POST',
    headers: { Accept: 'application/json', Authorization: `Bearer ${auth.token}` },
  });

  if (refresh.ok) {
    Object.assign(auth, (await refresh.json()).data);
    return auth.token;
  }

  // Already expired or revoked: log in again
  const login = await fetch(`${BASE_URL}/api/v1/users/login`, {
    method: 'POST',
    headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  Object.assign(auth, (await login.json()).data);

  return auth.token;
}
python
from datetime import datetime, timedelta


def ensure_token(auth: dict, email: str, password: str) -> str:
    """Return a valid token, refreshing or re-logging-in as needed.

    `auth` is the {"token": ..., "expires_at": ...} dict stored at login;
    it is updated in place.
    """
    expires_at = datetime.strptime(auth["expires_at"], "%Y-%m-%d %H:%M:%S")

    # Still comfortably valid
    if expires_at - datetime.now() > timedelta(days=1):
        return auth["token"]

    # Less than a day left: try to refresh
    refresh = requests.post(
        f"{BASE_URL}/api/v1/users/refresh-token",
        headers={"Accept": "application/json", "Authorization": f"Bearer {auth['token']}"},
    )
    if refresh.status_code == 200:
        auth.update(refresh.json()["data"])
        return auth["token"]

    # Already expired or revoked: log in again
    login = requests.post(
        f"{BASE_URL}/api/v1/users/login",
        headers={"Accept": "application/json"},
        json={"email": email, "password": password},
    )
    auth.update(login.json()["data"])
    return auth["token"]
go
type Auth struct {
	Token     string `json:"token"`
	ExpiresAt string `json:"expires_at"`
}

// ensureToken returns a valid token, refreshing or re-logging-in as needed.
func ensureToken(auth *Auth, email, password string) (string, error) {
	expiresAt, _ := time.ParseInLocation("2006-01-02 15:04:05", auth.ExpiresAt, time.Local)

	// Still comfortably valid
	if time.Until(expiresAt) > 24*time.Hour {
		return auth.Token, nil
	}

	// Less than a day left: try to refresh
	req, _ := http.NewRequest("POST", baseURL+"/api/v1/users/refresh-token", nil)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+auth.Token)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var body struct {
		Data Auth `json:"data"`
	}
	if resp.StatusCode == http.StatusOK {
		json.NewDecoder(resp.Body).Decode(&body)
		*auth = body.Data
		return auth.Token, nil
	}

	// Already expired or revoked: log in again
	payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
	req, _ = http.NewRequest("POST", baseURL+"/api/v1/users/login", bytes.NewBuffer(payload))
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	resp, err = http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("login failed: HTTP %d", resp.StatusCode)
	}
	json.NewDecoder(resp.Body).Decode(&body)
	*auth = body.Data
	return auth.Token, nil
}
rust
use chrono::{Duration, NaiveDateTime, Local};

#[derive(Debug, Clone, serde::Deserialize)]
struct Auth {
    token: String,
    expires_at: String,
}

/// Returns a valid token, refreshing or re-logging-in as needed.
async fn ensure_token(
    client: &Client,
    auth: &mut Auth,
    email: &str,
    password: &str,
) -> Result<String, reqwest::Error> {
    let expires_at = NaiveDateTime::parse_from_str(&auth.expires_at, "%Y-%m-%d %H:%M:%S")
        .expect("expires_at format");

    // Still comfortably valid
    if expires_at - Local::now().naive_local() > Duration::days(1) {
        return Ok(auth.token.clone());
    }

    // Less than a day left: try to refresh
    let refresh = client
        .post(format!("{BASE_URL}/api/v1/users/refresh-token"))
        .header("Accept", "application/json")
        .bearer_auth(&auth.token)
        .send()
        .await?;

    if refresh.status().is_success() {
        let body: Value = refresh.json().await?;
        *auth = serde_json::from_value(body["data"].clone()).expect("auth payload");
        return Ok(auth.token.clone());
    }

    // Already expired or revoked: log in again
    let body: Value = client
        .post(format!("{BASE_URL}/api/v1/users/login"))
        .header("Accept", "application/json")
        .json(&json!({ "email": email, "password": password }))
        .send()
        .await?
        .json()
        .await?;

    *auth = serde_json::from_value(body["data"].clone()).expect("auth payload");
    Ok(auth.token.clone())
}
Why check expires_at client-side instead of just retrying on 401?

Both. Refreshing proactively avoids a failed request and a re-login round-trip in the middle of a batch job. Retrying on 401 covers the cases you can't predict — a token revoked by Logout All Devices, or clock drift between your machine and the server (expires_at is server time). The complete client does both.


5. Create a subscriber ​

POST /api/v1/subscribers/create — the seven required fields are shown; see the full field list for the optional ones (static IP, MAC, address, area, quotas…).

A 422 response means validation failed: errors maps each field to its messages, and message explains business-rule failures such as Username already exists or Package not assigned to this salesperson.

bash
curl -X POST "https://your-domain.com/api/v1/subscribers/create" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "isp_id": 1,
    "branch_id": 1,
    "username": "subscriber001",
    "fullname": "John Doe",
    "password": "pass123",
    "package_id": 1,
    "salesperson_id": 1,
    "phone": "01712345678",
    "email": "[email protected]"
  }'
php
$response = $http->post('/api/v1/subscribers/create', [
    'http_errors' => false,
    'headers' => [
        'Accept'        => 'application/json',
        'Authorization' => "Bearer {$token}",
    ],
    'json' => [
        'isp_id'         => $ispId,
        'branch_id'      => $branchId,
        'username'       => 'subscriber001',
        'fullname'       => 'John Doe',
        'password'       => 'pass123',
        'package_id'     => 1,
        'salesperson_id' => 1,
        'phone'          => '01712345678',
        'email'          => '[email protected]',
    ],
]);

$body = json_decode($response->getBody(), true);

if ($response->getStatusCode() === 422) {
    // Field errors: ['username' => ['The username field is required.'], ...]
    foreach ($body['errors'] ?? [] as $field => $messages) {
        echo "{$field}: " . implode(', ', $messages) . PHP_EOL;
    }
    // Business-rule errors (duplicate username, package not assigned...) come in 'message'
    exit("Validation failed: " . json_encode($body['message']) . PHP_EOL);
}

echo "Created subscriber #{$body['data']['id']}" . PHP_EOL;
javascript
const response = await fetch(`${BASE_URL}/api/v1/subscribers/create`, {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    isp_id: ispId,
    branch_id: branchId,
    username: 'subscriber001',
    fullname: 'John Doe',
    password: 'pass123',
    package_id: 1,
    salesperson_id: 1,
    phone: '01712345678',
    email: '[email protected]',
  }),
});

const body = await response.json();

if (response.status === 422) {
  // Field errors: { username: ['The username field is required.'], ... }
  for (const [field, messages] of Object.entries(body.errors ?? {})) {
    console.error(`${field}: ${messages.join(', ')}`);
  }
  // Business-rule errors (duplicate username, package not assigned...) come in `message`
  throw new Error(`Validation failed: ${JSON.stringify(body.message)}`);
}

console.log(`Created subscriber #${body.data.id}`);
python
response = requests.post(
    f"{BASE_URL}/api/v1/subscribers/create",
    headers={
        "Accept": "application/json",
        "Authorization": f"Bearer {token}",
    },
    json={
        "isp_id": isp_id,
        "branch_id": branch_id,
        "username": "subscriber001",
        "fullname": "John Doe",
        "password": "pass123",
        "package_id": 1,
        "salesperson_id": 1,
        "phone": "01712345678",
        "email": "[email protected]",
    },
)

body = response.json()

if response.status_code == 422:
    # Field errors: {"username": ["The username field is required."], ...}
    for field, messages in body.get("errors", {}).items():
        print(f"{field}: {', '.join(messages)}")
    # Business-rule errors (duplicate username, package not assigned...) come in "message"
    raise SystemExit(f"Validation failed: {body['message']}")

print(f"Created subscriber #{body['data']['id']}")
go
payload, _ := json.Marshal(map[string]any{
	"isp_id":         ispID,
	"branch_id":      branchID,
	"username":       "subscriber001",
	"fullname":       "John Doe",
	"password":       "pass123",
	"package_id":     1,
	"salesperson_id": 1,
	"phone":          "01712345678",
	"email":          "[email protected]",
})

req, _ := http.NewRequest("POST", baseURL+"/api/v1/subscribers/create", bytes.NewBuffer(payload))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

var body struct {
	Message any                 `json:"message"`
	Errors  map[string][]string `json:"errors"`
	Data    struct {
		ID int `json:"id"`
	} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&body)

if resp.StatusCode == http.StatusUnprocessableEntity {
	// Field errors: {"username": ["The username field is required."], ...}
	for field, messages := range body.Errors {
		fmt.Printf("%s: %s\n", field, strings.Join(messages, ", "))
	}
	// Business-rule errors (duplicate username, package not assigned...) come in "message"
	panic(fmt.Sprintf("validation failed: %v", body.Message))
}

fmt.Printf("Created subscriber #%d\n", body.Data.ID)
rust
let response = client
    .post(format!("{BASE_URL}/api/v1/subscribers/create"))
    .header("Accept", "application/json")
    .bearer_auth(token)
    .json(&json!({
        "isp_id": isp_id,
        "branch_id": branch_id,
        "username": "subscriber001",
        "fullname": "John Doe",
        "password": "pass123",
        "package_id": 1,
        "salesperson_id": 1,
        "phone": "01712345678",
        "email": "[email protected]"
    }))
    .send()
    .await?;

let status = response.status();
let body: Value = response.json().await?;

if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
    // Field errors: {"username": ["The username field is required."], ...}
    if let Some(errors) = body["errors"].as_object() {
        for (field, messages) in errors {
            eprintln!("{field}: {messages}");
        }
    }
    // Business-rule errors (duplicate username, package not assigned...) come in "message"
    panic!("validation failed: {}", body["message"]);
}

println!("Created subscriber #{}", body["data"]["id"]);

Success response (201 Created) — data is the full subscriber record:

json
{
    "status": "success",
    "message": "Subscriber created successfully.",
    "data": {
        "id": 151,
        "username": "subscriber001",
        "fullname": "John Doe",
        "package_id": 1,
        "salesperson_id": 1,
        "profile_status": 0,
        "isp_id": 1,
        "branch_id": 1,
        "created_at": "2025-01-01T10:30:00.000000Z"
    }
}

6. List subscribers with pagination ​

GET /api/v1/subscribers returns a JSON array of subscribers. Pass both offset and limit to page through it (without them the first 100 rows are returned); keep going until a page comes back with fewer than limit rows. Add package_module=1, balance_module=1, … to include related data — see Fetch All Subscribers for every filter.

bash
# Page 1
curl -X GET "https://your-domain.com/api/v1/subscribers?isp_id=1&branch_id=1&offset=0&limit=100" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer your-auth-token"

# Page 2
curl -X GET "https://your-domain.com/api/v1/subscribers?isp_id=1&branch_id=1&offset=100&limit=100" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer your-auth-token"
php
$limit  = 100;
$offset = 0;
$all    = [];

do {
    $response = $http->get('/api/v1/subscribers', [
        'headers' => [
            'Accept'        => 'application/json',
            'Authorization' => "Bearer {$token}",
        ],
        'query' => [
            'isp_id'    => $ispId,
            'branch_id' => $branchId,
            'offset'    => $offset,
            'limit'     => $limit,
        ],
    ]);

    $page = json_decode($response->getBody(), true);
    $all  = array_merge($all, $page);
    $offset += $limit;
} while (count($page) === $limit);

echo count($all) . " subscribers" . PHP_EOL;
javascript
const limit = 100;
let offset = 0;
let page;
const all = [];

do {
  const params = new URLSearchParams({ isp_id: ispId, branch_id: branchId, offset, limit });

  const response = await fetch(`${BASE_URL}/api/v1/subscribers?${params}`, {
    headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
  });

  page = await response.json();
  all.push(...page);
  offset += limit;
} while (page.length === limit);

console.log(`${all.length} subscribers`);
python
limit = 100
offset = 0
all_subscribers = []

while True:
    response = requests.get(
        f"{BASE_URL}/api/v1/subscribers",
        headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
        params={"isp_id": isp_id, "branch_id": branch_id, "offset": offset, "limit": limit},
    )

    page = response.json()
    all_subscribers.extend(page)
    offset += limit
    if len(page) < limit:
        break

print(f"{len(all_subscribers)} subscribers")
go
const limit = 100
offset := 0
var all []map[string]any

for {
	q := url.Values{}
	q.Set("isp_id", strconv.Itoa(ispID))
	q.Set("branch_id", strconv.Itoa(branchID))
	q.Set("offset", strconv.Itoa(offset))
	q.Set("limit", strconv.Itoa(limit))

	req, _ := http.NewRequest("GET", baseURL+"/api/v1/subscribers?"+q.Encode(), nil)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}

	var page []map[string]any
	json.NewDecoder(resp.Body).Decode(&page)
	resp.Body.Close()

	all = append(all, page...)
	offset += limit
	if len(page) < limit {
		break
	}
}

fmt.Printf("%d subscribers\n", len(all))
rust
let limit = 100;
let mut offset = 0;
let mut all: Vec<Value> = Vec::new();

loop {
    let body: Value = client
        .get(format!("{BASE_URL}/api/v1/subscribers"))
        .header("Accept", "application/json")
        .bearer_auth(token)
        .query(&[
            ("isp_id", isp_id.to_string()),
            ("branch_id", branch_id.to_string()),
            ("offset", offset.to_string()),
            ("limit", limit.to_string()),
        ])
        .send()
        .await?
        .json()
        .await?;

    let page = body.as_array().cloned().unwrap_or_default();
    let count = page.len();
    all.extend(page);
    offset += limit;
    if count < limit {
        break;
    }
}

println!("{} subscribers", all.len());

7. Complete client ​

Everything above in one reusable piece: it logs in lazily, refreshes the token a day before expiry, retries once after a 401 by logging in again, and turns non-2xx responses into a typed error carrying the API's message and errors.

php
<?php

require 'vendor/autoload.php';

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

class ZalUltraException extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $body)
    {
        $message = $body['message'] ?? "HTTP {$status}";
        parent::__construct(is_array($message) ? implode(' ', $message) : (string) $message, $status);
    }

    /** Field-level validation errors from a 422 response */
    public function errors(): array
    {
        return $this->body['errors'] ?? [];
    }
}

class ZalUltraClient
{
    private Client $http;
    private ?string $token = null;
    private ?DateTimeImmutable $expiresAt = null;

    public function __construct(
        string $baseUrl,
        private string $email,
        private string $password,
    ) {
        $this->http = new Client(['base_uri' => $baseUrl, 'http_errors' => false]);
    }

    // --- Auth -------------------------------------------------------------

    /** @return array the logged-in user (id, name, email, profile_type, isp_id, branch_id) */
    public function login(): array
    {
        $response = $this->http->post('/api/v1/users/login', [
            'headers' => ['Accept' => 'application/json'],
            'json'    => ['email' => $this->email, 'password' => $this->password],
        ]);
        $body = $this->decode($response);

        $this->storeToken($body['data']);
        return $body['data']['user'];
    }

    public function refreshToken(): void
    {
        $response = $this->http->post('/api/v1/users/refresh-token', [
            'headers' => $this->authHeaders(),
        ]);

        if ($response->getStatusCode() === 200) {
            $this->storeToken(json_decode($response->getBody(), true)['data']);
        } else {
            $this->login(); // token already expired or revoked
        }
    }

    public function logout(): void
    {
        $this->http->post('/api/v1/users/logout', ['headers' => $this->authHeaders()]);
        $this->token = $this->expiresAt = null;
    }

    // --- Generic request with automatic token handling --------------------

    /**
     * @param array $options Guzzle options: 'query' for GET params, 'json' for a body
     */
    public function request(string $method, string $path, array $options = []): array
    {
        if ($this->token === null) {
            $this->login();
        } elseif ($this->expiresAt < new DateTimeImmutable('+1 day')) {
            $this->refreshToken();
        }

        $response = $this->send($method, $path, $options);

        if ($response->getStatusCode() === 401) {
            // Revoked, or clock drift: log in again and retry once
            $this->login();
            $response = $this->send($method, $path, $options);
        }

        return $this->decode($response);
    }

    // --- Convenience wrappers ---------------------------------------------

    public function profile(): array
    {
        return $this->request('GET', '/api/v1/users/profile')['data']['user'];
    }

    public function createSubscriber(array $subscriber): array
    {
        return $this->request('POST', '/api/v1/subscribers/create', ['json' => $subscriber])['data'];
    }

    /** Iterates over every subscriber matching $filters, fetching pages as needed. */
    public function subscribers(int $ispId, int $branchId, array $filters = [], int $limit = 100): Generator
    {
        $offset = 0;
        do {
            $page = $this->request('GET', '/api/v1/subscribers', [
                'query' => $filters + [
                    'isp_id' => $ispId, 'branch_id' => $branchId, 'offset' => $offset, 'limit' => $limit,
                ],
            ]);

            yield from $page;
            $offset += $limit;
        } while (count($page) === $limit);
    }

    // --- Internals ----------------------------------------------------------

    private function send(string $method, string $path, array $options): ResponseInterface
    {
        $options['headers'] = ($options['headers'] ?? []) + $this->authHeaders();
        return $this->http->request($method, $path, $options);
    }

    private function authHeaders(): array
    {
        return ['Accept' => 'application/json', 'Authorization' => "Bearer {$this->token}"];
    }

    private function storeToken(array $data): void
    {
        $this->token     = $data['token'];
        $this->expiresAt = new DateTimeImmutable($data['expires_at']);
    }

    private function decode(ResponseInterface $response): array
    {
        $body = json_decode((string) $response->getBody(), true) ?? [];
        if ($response->getStatusCode() >= 400) {
            throw new ZalUltraException($response->getStatusCode(), $body);
        }
        return $body;
    }
}

// --- Usage ------------------------------------------------------------------

$zal  = new ZalUltraClient('https://your-domain.com', '[email protected]', 'your-password');
$user = $zal->login();

try {
    $created = $zal->createSubscriber([
        'isp_id'         => $user['isp_id'],
        'branch_id'      => $user['branch_id'],
        'username'       => 'subscriber001',
        'fullname'       => 'John Doe',
        'password'       => 'pass123',
        'package_id'     => 1,
        'salesperson_id' => $user['id'],
    ]);
    echo "Created subscriber #{$created['id']}\n";
} catch (ZalUltraException $e) {
    echo "Failed ({$e->status}): {$e->getMessage()}\n";
    print_r($e->errors());
}

foreach ($zal->subscribers($user['isp_id'], $user['branch_id'], ['subscriber_type' => 2]) as $subscriber) {
    echo "{$subscriber['username']} expires {$subscriber['expiration_date']}\n";
}
javascript
// zal-ultra.js — works in Node.js 18+ (native fetch) and modern browsers/bundlers.

export class ZalUltraError extends Error {
  constructor(status, body) {
    const message = body?.message ?? `HTTP ${status}`;
    super(Array.isArray(message) ? message.join(' ') : String(message));
    this.name = 'ZalUltraError';
    this.status = status;
    /** Field-level validation errors from a 422 response */
    this.errors = body?.errors ?? {};
  }
}

export class ZalUltraClient {
  #token = null;
  #expiresAt = null;

  constructor(baseUrl, email, password) {
    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.email = email;
    this.password = password;
  }

  // --- Auth -------------------------------------------------------------

  /** @returns the logged-in user (id, name, email, profile_type, isp_id, branch_id) */
  async login() {
    const response = await fetch(`${this.baseUrl}/api/v1/users/login`, {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: this.email, password: this.password }),
    });
    const body = await this.#decode(response);
    this.#storeToken(body.data);
    return body.data.user;
  }

  async refreshToken() {
    const response = await fetch(`${this.baseUrl}/api/v1/users/refresh-token`, {
      method: 'POST',
      headers: this.#authHeaders(),
    });
    if (response.ok) {
      this.#storeToken((await response.json()).data);
    } else {
      await this.login(); // token already expired or revoked
    }
  }

  async logout() {
    await fetch(`${this.baseUrl}/api/v1/users/logout`, { method: 'POST', headers: this.#authHeaders() });
    this.#token = this.#expiresAt = null;
  }

  // --- Generic request with automatic token handling --------------------

  /**
   * @param {object} [options]
   * @param {object} [options.query]  URL query parameters
   * @param {object} [options.json]   JSON request body
   */
  async request(method, path, { query, json } = {}) {
    if (!this.#token) {
      await this.login();
    } else if (this.#expiresAt - Date.now() < 24 * 60 * 60 * 1000) {
      await this.refreshToken();
    }

    let response = await this.#send(method, path, query, json);

    if (response.status === 401) {
      // Revoked, or clock drift: log in again and retry once
      await this.login();
      response = await this.#send(method, path, query, json);
    }

    return this.#decode(response);
  }

  // --- Convenience wrappers ---------------------------------------------

  async profile() {
    return (await this.request('GET', '/api/v1/users/profile')).data.user;
  }

  async createSubscriber(subscriber) {
    return (await this.request('POST', '/api/v1/subscribers/create', { json: subscriber })).data;
  }

  /** Async-iterates over every subscriber matching `filters`, fetching pages as needed. */
  async *subscribers(ispId, branchId, filters = {}, limit = 100) {
    let offset = 0;
    let page;
    do {
      page = await this.request('GET', '/api/v1/subscribers', {
        query: { ...filters, isp_id: ispId, branch_id: branchId, offset, limit },
      });
      yield* page;
      offset += limit;
    } while (page.length === limit);
  }

  // --- Internals ----------------------------------------------------------

  #send(method, path, query, json) {
    const url = new URL(this.baseUrl + path);
    for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v);

    return fetch(url, {
      method,
      headers: { ...this.#authHeaders(), ...(json && { 'Content-Type': 'application/json' }) },
      body: json ? JSON.stringify(json) : undefined,
    });
  }

  #authHeaders() {
    return { Accept: 'application/json', Authorization: `Bearer ${this.#token}` };
  }

  #storeToken(data) {
    this.#token = data.token;
    this.#expiresAt = new Date(data.expires_at.replace(' ', 'T')).getTime();
  }

  async #decode(response) {
    const body = await response.json().catch(() => ({}));
    if (!response.ok) throw new ZalUltraError(response.status, body);
    return body;
  }
}

// --- Usage ------------------------------------------------------------------

const zal = new ZalUltraClient('https://your-domain.com', '[email protected]', 'your-password');
const user = await zal.login();

try {
  const created = await zal.createSubscriber({
    isp_id: user.isp_id,
    branch_id: user.branch_id,
    username: 'subscriber001',
    fullname: 'John Doe',
    password: 'pass123',
    package_id: 1,
    salesperson_id: user.id,
  });
  console.log(`Created subscriber #${created.id}`);
} catch (e) {
  if (e instanceof ZalUltraError) console.error(`Failed (${e.status}): ${e.message}`, e.errors);
  else throw e;
}

for await (const subscriber of zal.subscribers(user.isp_id, user.branch_id, { subscriber_type: 2 })) {
  console.log(`${subscriber.username} expires ${subscriber.expiration_date}`);
}
python
"""zal_ultra.py — a small client for the Zal Ultra API (requires `requests`)."""

from datetime import datetime, timedelta
from typing import Any, Iterator

import requests


class ZalUltraError(Exception):
    def __init__(self, status: int, body: dict):
        message = body.get("message", f"HTTP {status}")
        super().__init__(" ".join(message) if isinstance(message, list) else str(message))
        self.status = status
        self.errors: dict[str, list[str]] = body.get("errors", {})  # field errors from a 422


class ZalUltraClient:
    def __init__(self, base_url: str, email: str, password: str):
        self.base_url = base_url.rstrip("/")
        self.email = email
        self.password = password
        self._token: str | None = None
        self._expires_at: datetime | None = None
        self._http = requests.Session()
        self._http.headers["Accept"] = "application/json"

    # --- Auth -------------------------------------------------------------

    def login(self) -> dict:
        """Log in and return the user (id, name, email, profile_type, isp_id, branch_id)."""
        response = self._http.post(
            f"{self.base_url}/api/v1/users/login",
            json={"email": self.email, "password": self.password},
        )
        body = self._decode(response)
        self._store_token(body["data"])
        return body["data"]["user"]

    def refresh_token(self) -> None:
        response = self._http.post(f"{self.base_url}/api/v1/users/refresh-token", headers=self._auth())
        if response.status_code == 200:
            self._store_token(response.json()["data"])
        else:
            self.login()  # token already expired or revoked

    def logout(self) -> None:
        self._http.post(f"{self.base_url}/api/v1/users/logout", headers=self._auth())
        self._token = self._expires_at = None

    # --- Generic request with automatic token handling --------------------

    def request(self, method: str, path: str, *, params: dict | None = None, json: Any = None) -> Any:
        if self._token is None:
            self.login()
        elif self._expires_at - datetime.now() < timedelta(days=1):
            self.refresh_token()

        response = self._send(method, path, params, json)

        if response.status_code == 401:
            # Revoked, or clock drift: log in again and retry once
            self.login()
            response = self._send(method, path, params, json)

        return self._decode(response)

    # --- Convenience wrappers ---------------------------------------------

    def profile(self) -> dict:
        return self.request("GET", "/api/v1/users/profile")["data"]["user"]

    def create_subscriber(self, subscriber: dict) -> dict:
        return self.request("POST", "/api/v1/subscribers/create", json=subscriber)["data"]

    def subscribers(self, isp_id: int, branch_id: int, limit: int = 100, **filters) -> Iterator[dict]:
        """Iterate over every subscriber matching `filters`, fetching pages as needed."""
        offset = 0
        while True:
            page = self.request(
                "GET",
                "/api/v1/subscribers",
                params={**filters, "isp_id": isp_id, "branch_id": branch_id, "offset": offset, "limit": limit},
            )
            yield from page
            offset += limit
            if len(page) < limit:
                return

    # --- Internals ----------------------------------------------------------

    def _send(self, method: str, path: str, params: dict | None, json: Any) -> requests.Response:
        return self._http.request(method, f"{self.base_url}{path}", params=params, json=json, headers=self._auth())

    def _auth(self) -> dict:
        return {"Authorization": f"Bearer {self._token}"}

    def _store_token(self, data: dict) -> None:
        self._token = data["token"]
        self._expires_at = datetime.strptime(data["expires_at"], "%Y-%m-%d %H:%M:%S")

    @staticmethod
    def _decode(response: requests.Response) -> Any:
        try:
            body = response.json()
        except ValueError:
            body = {}
        if response.status_code >= 400:
            raise ZalUltraError(response.status_code, body if isinstance(body, dict) else {})
        return body


# --- Usage ------------------------------------------------------------------

if __name__ == "__main__":
    zal = ZalUltraClient("https://your-domain.com", "[email protected]", "your-password")
    user = zal.login()

    try:
        created = zal.create_subscriber({
            "isp_id": user["isp_id"],
            "branch_id": user["branch_id"],
            "username": "subscriber001",
            "fullname": "John Doe",
            "password": "pass123",
            "package_id": 1,
            "salesperson_id": user["id"],
        })
        print(f"Created subscriber #{created['id']}")
    except ZalUltraError as e:
        print(f"Failed ({e.status}): {e}", e.errors)

    for subscriber in zal.subscribers(user["isp_id"], user["branch_id"], subscriber_type=2):
        print(f"{subscriber['username']} expires {subscriber['expiration_date']}")
go
// zalultra.go — a small client for the Zal Ultra API (standard library only).
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

// APIError carries the API's status code, message and 422 field errors.
type APIError struct {
	Status  int                 `json:"-"`
	Message any                 `json:"message"`
	Errors  map[string][]string `json:"errors"`
}

func (e *APIError) Error() string { return fmt.Sprintf("HTTP %d: %v", e.Status, e.Message) }

type User struct {
	ID          int    `json:"id"`
	Name        string `json:"name"`
	Email       string `json:"email"`
	ProfileType int    `json:"profile_type"`
	IspID       int    `json:"isp_id"`
	BranchID    int    `json:"branch_id"`
}

type Client struct {
	BaseURL   string
	email     string
	password  string
	http      *http.Client
	token     string
	expiresAt time.Time
}

func New(baseURL, email, password string) *Client {
	return &Client{BaseURL: baseURL, email: email, password: password, http: &http.Client{Timeout: 30 * time.Second}}
}

// --- Auth ---------------------------------------------------------------------

// Login authenticates and returns the user (id, name, email, profile_type, isp_id, branch_id).
func (c *Client) Login() (*User, error) {
	var body struct {
		Data struct {
			User      User   `json:"user"`
			Token     string `json:"token"`
			ExpiresAt string `json:"expires_at"`
		} `json:"data"`
	}
	req := c.newRequest("POST", "/api/v1/users/login", nil, map[string]string{"email": c.email, "password": c.password})
	if err := c.do(req, &body); err != nil {
		return nil, err
	}
	c.storeToken(body.Data.Token, body.Data.ExpiresAt)
	return &body.Data.User, nil
}

func (c *Client) RefreshToken() error {
	var body struct {
		Data struct {
			Token     string `json:"token"`
			ExpiresAt string `json:"expires_at"`
		} `json:"data"`
	}
	req := c.newRequest("POST", "/api/v1/users/refresh-token", nil, nil)
	req.Header.Set("Authorization", "Bearer "+c.token)
	if err := c.do(req, &body); err != nil {
		_, err = c.Login() // token already expired or revoked
		return err
	}
	c.storeToken(body.Data.Token, body.Data.ExpiresAt)
	return nil
}

func (c *Client) Logout() error {
	req := c.newRequest("POST", "/api/v1/users/logout", nil, nil)
	req.Header.Set("Authorization", "Bearer "+c.token)
	err := c.do(req, nil)
	c.token, c.expiresAt = "", time.Time{}
	return err
}

// --- Generic request with automatic token handling ----------------------------

// Request performs an authenticated call and decodes the JSON response into out.
func (c *Client) Request(method, path string, query url.Values, body any, out any) error {
	if c.token == "" {
		if _, err := c.Login(); err != nil {
			return err
		}
	} else if time.Until(c.expiresAt) < 24*time.Hour {
		if err := c.RefreshToken(); err != nil {
			return err
		}
	}

	req := c.newRequest(method, path, query, body)
	req.Header.Set("Authorization", "Bearer "+c.token)
	err := c.do(req, out)

	if apiErr, ok := err.(*APIError); ok && apiErr.Status == http.StatusUnauthorized {
		// Revoked, or clock drift: log in again and retry once
		if _, err := c.Login(); err != nil {
			return err
		}
		req = c.newRequest(method, path, query, body)
		req.Header.Set("Authorization", "Bearer "+c.token)
		err = c.do(req, out)
	}
	return err
}

// --- Convenience wrappers -----------------------------------------------------

func (c *Client) Profile() (*User, error) {
	var body struct {
		Data struct {
			User User `json:"user"`
		} `json:"data"`
	}
	err := c.Request("GET", "/api/v1/users/profile", nil, nil, &body)
	return &body.Data.User, err
}

func (c *Client) CreateSubscriber(subscriber map[string]any) (int, error) {
	var body struct {
		Data struct {
			ID int `json:"id"`
		} `json:"data"`
	}
	err := c.Request("POST", "/api/v1/subscribers/create", nil, subscriber, &body)
	return body.Data.ID, err
}

// Subscribers fetches every subscriber matching filters, page by page.
func (c *Client) Subscribers(ispID, branchID int, filters url.Values) ([]map[string]any, error) {
	const limit = 100
	var all []map[string]any
	for offset := 0; ; offset += limit {
		q := url.Values{}
		for k, v := range filters {
			q[k] = v
		}
		q.Set("isp_id", strconv.Itoa(ispID))
		q.Set("branch_id", strconv.Itoa(branchID))
		q.Set("offset", strconv.Itoa(offset))
		q.Set("limit", strconv.Itoa(limit))

		var page []map[string]any
		if err := c.Request("GET", "/api/v1/subscribers", q, nil, &page); err != nil {
			return all, err
		}
		all = append(all, page...)
		if len(page) < limit {
			return all, nil
		}
	}
}

// --- Internals ------------------------------------------------------------------

func (c *Client) newRequest(method, path string, query url.Values, body any) *http.Request {
	endpoint := c.BaseURL + path
	if len(query) > 0 {
		endpoint += "?" + query.Encode()
	}
	var payload *bytes.Buffer
	if body != nil {
		b, _ := json.Marshal(body)
		payload = bytes.NewBuffer(b)
	} else {
		payload = &bytes.Buffer{}
	}
	req, _ := http.NewRequest(method, endpoint, payload)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	return req
}

func (c *Client) do(req *http.Request, out any) error {
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		apiErr := &APIError{Status: resp.StatusCode}
		json.NewDecoder(resp.Body).Decode(apiErr)
		return apiErr
	}
	if out != nil {
		return json.NewDecoder(resp.Body).Decode(out)
	}
	return nil
}

func (c *Client) storeToken(token, expiresAt string) {
	c.token = token
	c.expiresAt, _ = time.ParseInLocation("2006-01-02 15:04:05", expiresAt, time.Local)
}

// --- Usage ----------------------------------------------------------------------

func main() {
	zal := New("https://your-domain.com", "[email protected]", "your-password")

	user, err := zal.Login()
	if err != nil {
		panic(err)
	}

	id, err := zal.CreateSubscriber(map[string]any{
		"isp_id":         user.IspID,
		"branch_id":      user.BranchID,
		"username":       "subscriber001",
		"fullname":       "John Doe",
		"password":       "pass123",
		"package_id":     1,
		"salesperson_id": user.ID,
	})
	if apiErr, ok := err.(*APIError); ok {
		fmt.Printf("Failed (%d): %v %v\n", apiErr.Status, apiErr.Message, apiErr.Errors)
	} else if err != nil {
		panic(err)
	} else {
		fmt.Printf("Created subscriber #%d\n", id)
	}

	subscribers, err := zal.Subscribers(user.IspID, user.BranchID, url.Values{"subscriber_type": {"2"}})
	if err != nil {
		panic(err)
	}
	for _, s := range subscribers {
		fmt.Printf("%v expires %v\n", s["username"], s["expiration_date"])
	}
}
rust
// zal_ultra.rs — a small client for the Zal Ultra API.
//
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// chrono = "0.4"

use chrono::{Duration, Local, NaiveDateTime};
use reqwest::{Method, StatusCode};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;

#[derive(Debug)]
pub enum Error {
    Http(reqwest::Error),
    /// Non-2xx response: status, `message`, and 422 field `errors`
    Api { status: StatusCode, message: Value, errors: HashMap<String, Vec<String>> },
}

impl From<reqwest::Error> for Error {
    fn from(e: reqwest::Error) -> Self { Error::Http(e) }
}

#[derive(Debug, Clone, Deserialize)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub profile_type: i64,
    pub isp_id: i64,
    pub branch_id: i64,
}

pub struct Client {
    base_url: String,
    email: String,
    password: String,
    http: reqwest::Client,
    token: Option<String>,
    expires_at: Option<NaiveDateTime>,
}

impl Client {
    pub fn new(base_url: &str, email: &str, password: &str) -> Self {
        Self {
            base_url: base_url.trim_end_matches('/').to_string(),
            email: email.to_string(),
            password: password.to_string(),
            http: reqwest::Client::new(),
            token: None,
            expires_at: None,
        }
    }

    // --- Auth ---------------------------------------------------------------

    /// Log in and return the user (id, name, email, profile_type, isp_id, branch_id).
    pub async fn login(&mut self) -> Result<User, Error> {
        let body = self
            .send(Method::POST, "/api/v1/users/login", None, Some(json!({
                "email": self.email, "password": self.password
            })), false)
            .await?;
        self.store_token(&body["data"]);
        Ok(serde_json::from_value(body["data"]["user"].clone()).expect("user payload"))
    }

    pub async fn refresh_token(&mut self) -> Result<(), Error> {
        match self.send(Method::POST, "/api/v1/users/refresh-token", None, None, true).await {
            Ok(body) => { self.store_token(&body["data"]); Ok(()) }
            Err(_) => self.login().await.map(|_| ()), // token already expired or revoked
        }
    }

    pub async fn logout(&mut self) -> Result<(), Error> {
        self.send(Method::POST, "/api/v1/users/logout", None, None, true).await?;
        self.token = None;
        self.expires_at = None;
        Ok(())
    }

    // --- Generic request with automatic token handling ----------------------

    pub async fn request(
        &mut self,
        method: Method,
        path: &str,
        query: Option<&[(&str, String)]>,
        body: Option<Value>,
    ) -> Result<Value, Error> {
        match self.expires_at {
            None => self.login().await.map(|_| ())?,
            Some(exp) if exp - Local::now().naive_local() < Duration::days(1) => self.refresh_token().await?,
            _ => {}
        }

        match self.send(method.clone(), path, query, body.clone(), true).await {
            Err(Error::Api { status, .. }) if status == StatusCode::UNAUTHORIZED => {
                // Revoked, or clock drift: log in again and retry once
                self.login().await?;
                self.send(method, path, query, body, true).await
            }
            other => other,
        }
    }

    // --- Convenience wrappers -----------------------------------------------

    pub async fn profile(&mut self) -> Result<User, Error> {
        let body = self.request(Method::GET, "/api/v1/users/profile", None, None).await?;
        Ok(serde_json::from_value(body["data"]["user"].clone()).expect("user payload"))
    }

    pub async fn create_subscriber(&mut self, subscriber: Value) -> Result<Value, Error> {
        let body = self.request(Method::POST, "/api/v1/subscribers/create", None, Some(subscriber)).await?;
        Ok(body["data"].clone())
    }

    /// Fetch every subscriber matching `filters`, page by page.
    pub async fn subscribers(
        &mut self,
        isp_id: i64,
        branch_id: i64,
        filters: &[(&str, String)],
    ) -> Result<Vec<Value>, Error> {
        let limit = 100;
        let mut offset = 0;
        let mut all = Vec::new();
        loop {
            let mut query: Vec<(&str, String)> = filters.to_vec();
            query.extend([
                ("isp_id", isp_id.to_string()),
                ("branch_id", branch_id.to_string()),
                ("offset", offset.to_string()),
                ("limit", limit.to_string()),
            ]);
            let body = self.request(Method::GET, "/api/v1/subscribers", Some(&query[..]), None).await?;
            let page = body.as_array().cloned().unwrap_or_default();
            let count = page.len();
            all.extend(page);
            offset += limit;
            if count < limit {
                return Ok(all);
            }
        }
    }

    // --- Internals ------------------------------------------------------------

    async fn send(
        &self,
        method: Method,
        path: &str,
        query: Option<&[(&str, String)]>,
        body: Option<Value>,
        auth: bool,
    ) -> Result<Value, Error> {
        let mut req = self
            .http
            .request(method, format!("{}{}", self.base_url, path))
            .header("Accept", "application/json");
        if let Some(q) = query {
            req = req.query(q);
        }
        if let Some(b) = body {
            req = req.json(&b);
        }
        if auth {
            req = req.bearer_auth(self.token.as_deref().unwrap_or_default());
        }

        let response = req.send().await?;
        let status = response.status();
        let body: Value = response.json().await.unwrap_or(Value::Null);

        if status.is_success() {
            Ok(body)
        } else {
            Err(Error::Api {
                status,
                message: body["message"].clone(),
                errors: serde_json::from_value(body["errors"].clone()).unwrap_or_default(),
            })
        }
    }

    fn store_token(&mut self, data: &Value) {
        self.token = data["token"].as_str().map(str::to_string);
        self.expires_at = data["expires_at"]
            .as_str()
            .and_then(|s| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").ok());
    }
}

// --- Usage ----------------------------------------------------------------------

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut zal = Client::new("https://your-domain.com", "[email protected]", "your-password");
    let user = zal.login().await?;

    match zal
        .create_subscriber(json!({
            "isp_id": user.isp_id,
            "branch_id": user.branch_id,
            "username": "subscriber001",
            "fullname": "John Doe",
            "password": "pass123",
            "package_id": 1,
            "salesperson_id": user.id
        }))
        .await
    {
        Ok(created) => println!("Created subscriber #{}", created["id"]),
        Err(Error::Api { status, message, errors }) => eprintln!("Failed ({status}): {message} {errors:?}"),
        Err(e) => return Err(e),
    }

    let active = zal
        .subscribers(user.isp_id, user.branch_id, &[("subscriber_type", "2".to_string())])
        .await?;
    for s in active {
        println!("{} expires {}", s["username"], s["expiration_date"]);
    }
    Ok(())
}

Next steps ​

www.onezeroart.com