Developer Documentation

Vertext SMS API

Send single and bulk SMS messages to any Kenyan number. Simple REST API, real-time delivery, millisecond-fast responses — built for developers.

3 Endpoints
60 Req / min
10,000 Max bulk / batch
REST JSON over HTTPS
Overview

Everything you need to know before making your first request.

Base URL

All endpoints are relative to: https://api-sms.ultimatesolutions.co.ke/v1

Authentication

Pass your API key in every request:
X-API-Key: vt_your_key_here

Rate Limit

60 requests / minute per API key. Exceeding returns 429 with a retry_after field.

Phone number format Both 0712345678 (local) and 254712345678 (E.164) are accepted. The API normalises all numbers automatically.
Quick Start

Send your first SMS in under 2 minutes.

1

Get your API key

Log in to your Vertext dashboard and generate an API key from the Account page. Keys start with vt_.

2

Check your balance

Call GET /v1/sms/balance with your key to verify authentication and see your available SMS credits.

3

Send your first SMS

POST to /v1/sms/send with to and message. You'll get back a message_id and updated balance.

30-second cURL test

shell
# 1. Store credentials
export VT_KEY="vt_your_key_here"
export VT_BASE="https://api-sms.ultimatesolutions.co.ke/v1"

# 2. Check balance
curl -s "$VT_BASE/sms/balance" -H "X-API-Key: $VT_KEY" | jq .

# 3. Send a test SMS
curl -s -X POST "$VT_BASE/sms/send" \
  -H "X-API-Key: $VT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"0712345678","message":"Hello from Vertext!"}' | jq .

Expected response

json — success
{
  "success":    true,
  "message_id": "SC-20240601-00001",
  "balance":    142,
  "message":    "SMS queued"
}
Authentication

All requests must include your API key in the X-API-Key header.

http header
X-API-Key: vt_your_key_here
Keep your key secret. Never expose it in front-end JavaScript, mobile app bundles, or public repositories. Rotate it immediately from the dashboard if compromised.

Error responses

json — 401 missing key
{ "success": false, "error": "API key required" }
json — 403 invalid key
{ "success": false, "error": "Invalid or revoked API key" }
Endpoint Reference

Three endpoints cover every SMS use case.

GET /v1/sms/balance Account credit balance

Returns current credit balance, quota, and usage for the authenticated account. No request body required.

200 OK 401 Unauthorized 403 Forbidden 429 Too Many Requests
request
curl -s "https://api-sms.ultimatesolutions.co.ke/v1/sms/balance" \
  -H "X-API-Key: $VT_KEY"
json response
{
  "success": true,
  "balance": 150,
  "quota":   500,
  "used":    350
}
POST /v1/sms/send Single SMS — synchronous

Send a single SMS immediately. Deducts one credit on success and returns a message_id for delivery tracking.

Request body

FieldTypeRequiredDescription
tostringrequiredRecipient phone. 0712345678 or 254712345678
messagestringrequiredSMS text body (max 918 chars / 6 segments)
scheduled_atstringoptionalISO 8601 datetime to schedule. Omit to send immediately.
200 Sent 400 Bad Request 402 Insufficient Credits 429 Rate Limited
request body
{
  "to":      "0712345678",
  "message": "Your OTP is 482910. Expires in 5 min."
}
json response
{
  "success":    true,
  "message_id": "SC-20240601-00001",
  "balance":    149,
  "message":    "SMS sent"
}
POST /v1/sms/bulk Bulk SMS — async job

Enqueue an SMS campaign to an explicit list of numbers or a saved contact group. Returns a job_id immediately — messages are dispatched in the background.

Request body

FieldTypeRequiredDescription
tostring[]one ofArray of phone numbers (max 10,000 per request)
group_idintegerone ofID of a saved contact group (alternative to to)
messagestringrequiredSMS text body
campaignstringoptionalCampaign label for analytics (max 100 chars)
scheduled_atstringoptionalISO 8601 datetime to schedule delivery
Provide either to (number array) or group_id — not both.
202 Accepted 400 Bad Request 402 Insufficient Credits 429 Rate Limited
request — number list
{
  "to": ["0712345678","0723456789","254733000111"],
  "message":  "Flash sale — 30% off today!",
  "campaign": "Flash Sale June"
}
json response
{
  "success":  true,
  "job_id":   1042,
  "queued":   3,
  "balance":  147,
  "message":  "Bulk SMS job queued"
}
request — contact group
{
  "group_id": 5,
  "message":  "Your monthly statement is ready.",
  "campaign": "June Statements"
}
Client Libraries

Production-ready client classes for every major language. Copy, drop in, ship.

Ideal for testing, CI pipelines, and shell scripts. Pipe to jq for pretty JSON output.

Setup

bash
export VT_KEY="vt_your_key_here"
export VT_BASE="https://api-sms.ultimatesolutions.co.ke/v1"

Check balance

bash
curl -s "$VT_BASE/sms/balance" -H "X-API-Key: $VT_KEY" | jq .

Send a single SMS

bash
curl -s -X POST "$VT_BASE/sms/send" \
  -H "X-API-Key: $VT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"0712345678","message":"Your OTP is 482910. Expires in 5 minutes."}' | jq .

Bulk send — number list

bash
curl -s -X POST "$VT_BASE/sms/bulk" \
  -H "X-API-Key: $VT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to":       ["0712345678","0723456789","254733000111"],
    "message":  "Flash sale — 30% off today only!",
    "campaign": "Flash Sale June"
  }' | jq .

Bulk send — contact group

bash
curl -s -X POST "$VT_BASE/sms/bulk" \
  -H "X-API-Key: $VT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group_id":5,"message":"Your monthly statement is ready.","campaign":"June Statements"}' | jq .
PHP 8.1+ — no Composer needed. Uses the built-in curl_* functions. Compatible with Laravel, Symfony, CodeIgniter, and plain PHP.

Environment (.env)

dotenv
VT_API_KEY=vt_your_key_here

Client class

VertextClient.php
<?php
class VertextClient
{
    private string $base;
    private string $key;
    private ?\CurlHandle $ch = null;

    public function __construct(
        string $apiKey,
        string $baseUrl = 'https://api-sms.ultimatesolutions.co.ke/v1'
    ) {
        $this->key  = $apiKey;
        $this->base = rtrim($baseUrl, '/');
    }

    private function request(string $method, string $path, array $body = []): array
    {
        if ($this->ch === null) {
            $this->ch = curl_init();
            curl_setopt_array($this->ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 5,
                CURLOPT_TIMEOUT        => 30,
                CURLOPT_HTTPHEADER     => [
                    'X-API-Key: '    . $this->key,
                    'Content-Type: application/json',
                    'Accept: application/json',
                ],
            ]);
        }

        curl_setopt($this->ch, CURLOPT_URL, $this->base . $path);

        if ($method === 'POST') {
            curl_setopt($this->ch, CURLOPT_POST,       true);
            curl_setopt($this->ch, CURLOPT_POSTFIELDS,  json_encode($body));
        } else {
            curl_setopt($this->ch, CURLOPT_HTTPGET, true);
        }

        $raw  = curl_exec($this->ch);
        $code = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($this->ch);

        if ($err) throw new \RuntimeException("cURL error: $err");

        $data = json_decode($raw, true);
        if ($data === null) throw new \RuntimeException("Invalid JSON (HTTP $code)");

        $data['_httpCode'] = $code;
        return $data;
    }

    public function balance(): array
    {
        return $this->request('GET', '/sms/balance');
    }

    public function send(string $to, string $message): array
    {
        return $this->request('POST', '/sms/send', compact('to', 'message'));
    }

    public function bulk(array $to, string $message, string $campaign = ''): array
    {
        $p = compact('to', 'message');
        if ($campaign !== '') $p['campaign'] = $campaign;
        return $this->request('POST', '/sms/bulk', $p);
    }

    public function bulkToGroup(int $groupId, string $message, string $campaign = ''): array
    {
        $p = ['group_id' => $groupId, 'message' => $message];
        if ($campaign !== '') $p['campaign'] = $campaign;
        return $this->request('POST', '/sms/bulk', $p);
    }

    public function __destruct()
    {
        if ($this->ch !== null) curl_close($this->ch);
    }
}

// Usage
$sms = new VertextClient($_ENV['VT_API_KEY']);

// Check balance
$bal = $sms->balance();
echo "Balance: {$bal['balance']} credits\n";

// Single send
$res = $sms->send('0712345678', 'Your OTP is 482910. Expires in 5 minutes.');
if ($res['success']) {
    echo "Sent! ID: {$res['message_id']}, balance: {$res['balance']}\n";
} else {
    echo "Error ({$res['_httpCode']}): {$res['error']}\n";
    if ($res['_httpCode'] === 402) {
        echo "Top up needed. Balance: {$res['balance']}\n";
    }
}

// Bulk send to explicit numbers
$res = $sms->bulk(
    ['0712345678', '0723456789', '0733000111'],
    'Flash sale — 30% off today only!',
    'Flash Sale June'
);
echo "Queued {$res['queued']} messages (job #{$res['job_id']})\n";

// Bulk send to a saved contact group
$res = $sms->bulkToGroup(5, 'Your invoice is ready.', 'June Invoices');
Browser security: Never hardcode your API key in front-end JavaScript. Make calls from your backend and proxy results to the browser, or use a short-lived token pattern.

Client class — Browser / Node 18+ (native fetch)

Vertext.js
class VertextClient {
  constructor(apiKey, baseUrl = 'https://api-sms.ultimatesolutions.co.ke/v1') {
    this.apiKey  = apiKey;
    this.baseUrl = baseUrl.replace(/\/$/, '');
  }

  async #request(method, path, body = null) {
    const res = await fetch(this.baseUrl + path, {
      method,
      headers: {
        'X-API-Key':    this.apiKey,
        'Content-Type': 'application/json',
        'Accept':       'application/json',
      },
      ...(body ? { body: JSON.stringify(body) } : {}),
    });

    const data = await res.json();
    data._httpCode = res.status;

    if (!res.ok && !data.success) {
      const err = new Error(data.error ?? `HTTP ${res.status}`);
      err.httpCode = res.status;
      err.response = data;
      throw err;
    }
    return data;
  }

  balance()                              { return this.#request('GET',  '/sms/balance'); }
  send(to, message)                      { return this.#request('POST', '/sms/send',  { to, message }); }
  bulk(to, message, campaign = '')       { return this.#request('POST', '/sms/bulk',  { to, message, ...(campaign ? { campaign } : {}) }); }
  bulkToGroup(groupId, message, campaign = '') {
    return this.#request('POST', '/sms/bulk', {
      group_id: groupId, message, ...(campaign ? { campaign } : {})
    });
  }
}

// Usage
const sms = new VertextClient(process.env.VT_API_KEY);

const { balance } = await sms.balance();
console.log(`Balance: ${balance} credits`);

try {
  const res = await sms.send('0712345678', 'Your OTP is 482910. Expires in 5 minutes.');
  console.log(`Sent! ID: ${res.message_id}, balance: ${res.balance}`);
} catch (err) {
  if (err.httpCode === 402) console.error(`Out of credits. Balance: ${err.response.balance}`);
  else if (err.httpCode === 429) console.error(`Rate limited. Retry in ${err.response.retry_after ?? 60}s`);
  else console.error(`Error (${err.httpCode}): ${err.message}`);
}

const result = await sms.bulk(
  ['0712345678', '0723456789'],
  'Flash sale — 30% off today!',
  'Flash Sale June'
);
console.log(`Job #${result.job_id} - ${result.queued} messages queued`);
Node 14+ — built-in https — zero npm packages. Uses Node's native https module — no axios, node-fetch, or got required.

Client module

Vertext.js
'use strict';
const https = require('https');
const { URL } = require('url');

class VertextClient {
  constructor(apiKey, baseUrl = 'https://api-sms.ultimatesolutions.co.ke/v1') {
    this.apiKey  = apiKey;
    this.baseUrl = baseUrl.replace(/\/$/, '');
  }

  _request(method, path, body = null) {
    return new Promise((resolve, reject) => {
      const url     = new URL(this.baseUrl + path);
      const payload = body ? JSON.stringify(body) : null;

      const req = https.request({
        hostname: url.hostname,
        port:     url.port || 443,
        path:     url.pathname,
        method,
        timeout:  30_000,
        headers: {
          'X-API-Key':    this.apiKey,
          'Content-Type': 'application/json',
          'Accept':       'application/json',
          ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
        },
      }, (res) => {
        let raw = '';
        res.on('data', chunk => raw += chunk);
        res.on('end', () => {
          try {
            const data = JSON.parse(raw);
            data._httpCode = res.statusCode;
            resolve(data);
          } catch {
            reject(new Error(`Non-JSON response (HTTP ${res.statusCode})`));
          }
        });
      });

      req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
      req.on('error', reject);
      if (payload) req.write(payload);
      req.end();
    });
  }

  balance()                              { return this._request('GET',  '/sms/balance'); }
  send(to, message)                      { return this._request('POST', '/sms/send',  { to, message }); }
  bulk(to, message, campaign = '')       { return this._request('POST', '/sms/bulk',  { to, message, ...(campaign ? { campaign } : {}) }); }
  bulkToGroup(groupId, message, campaign = '') {
    return this._request('POST', '/sms/bulk', { group_id: groupId, message, ...(campaign ? { campaign } : {}) });
  }
}

module.exports = VertextClient;

Usage

index.js
require('dotenv').config();
const VertextClient = require('./Vertext');

const sms = new VertextClient(process.env.VT_API_KEY);

(async () => {
  const { balance } = await sms.balance();
  console.log(`Balance: ${balance} credits`);

  const res = await sms.send('0712345678', 'Hello from Node.js!');
  console.log(`Sent! ID: ${res.message_id}`);
})();
Python 3.8+. Requires requests (pip install requests). Session reuses the TCP connection and retries automatically on server errors.

Install

bash
pip install requests python-dotenv

Client class

Vertext.py
import os
from typing import Optional
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class VertextError(Exception):
    def __init__(self, http_code: int, error: str, response: dict):
        super().__init__(f"HTTP {http_code}: {error}")
        self.http_code = http_code
        self.response  = response


class VertextClient:
    def __init__(
        self,
        api_key:  str,
        base_url: str = "https://api-sms.ultimatesolutions.co.ke/v1",
        timeout:  int = 30,
    ):
        self.base_url = base_url.rstrip("/")
        self.timeout  = timeout

        retry = Retry(
            total=3, backoff_factor=1,
            status_forcelist=[500, 502, 503, 504],
            allowed_methods=["GET", "POST"],
        )
        self._session = requests.Session()
        self._session.mount("https://", HTTPAdapter(max_retries=retry))
        self._session.headers.update({
            "X-API-Key":    api_key,
            "Content-Type": "application/json",
            "Accept":       "application/json",
        })

    def _request(self, method: str, path: str, json: Optional[dict] = None) -> dict:
        resp = self._session.request(method, self.base_url + path, json=json, timeout=self.timeout)
        data = resp.json()
        data["_http_code"] = resp.status_code
        if not data.get("success") and resp.status_code >= 400:
            raise VertextError(resp.status_code, data.get("error", "Unknown error"), data)
        return data

    def balance(self) -> dict:
        return self._request("GET", "/sms/balance")

    def send(self, to: str, message: str) -> dict:
        return self._request("POST", "/sms/send", {"to": to, "message": message})

    def bulk(self, to: list[str], message: str, campaign: str = "") -> dict:
        payload = {"to": to, "message": message}
        if campaign: payload["campaign"] = campaign
        return self._request("POST", "/sms/bulk", payload)

    def bulk_to_group(self, group_id: int, message: str, campaign: str = "") -> dict:
        payload = {"group_id": group_id, "message": message}
        if campaign: payload["campaign"] = campaign
        return self._request("POST", "/sms/bulk", payload)

    def close(self): self._session.close()
    def __enter__(self): return self
    def __exit__(self, *_): self.close()


# Usage
if __name__ == "__main__":
    from dotenv import load_dotenv
    load_dotenv()

    with VertextClient(os.environ["VT_API_KEY"]) as sms:
        bal = sms.balance()
        print(f"Balance: {bal['balance']} credits")

        try:
            res = sms.send("0712345678", "Your OTP is 482910. Expires in 5 minutes.")
            print(f"Sent! ID: {res['message_id']}, balance: {res['balance']}")
        except VertextError as e:
            if e.http_code == 402: print(f"Out of credits: {e.response['balance']} remaining")
            elif e.http_code == 429: print(f"Rate limited. Retry in {e.response.get('retry_after', '?')}s")
            else: print(f"Error: {e}")

        res = sms.bulk(["0712345678", "0723456789"], "Flash sale — 30% off today!", "Flash Sale June")
        print(f"Job #{res['job_id']} - {res['queued']} messages queued")
Go 1.18+ — stdlib only. Goroutine-safe. The http.Transport reuses idle connections across requests — create one client per process.

Client package

vertext/client.go
package vertext

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

const DefaultBase = "https://api-sms.ultimatesolutions.co.ke/v1"

type Client struct {
	apiKey  string
	baseURL string
	http    *http.Client
}

func New(apiKey string) *Client {
	return &Client{
		apiKey:  apiKey,
		baseURL: DefaultBase,
		http: &http.Client{
			Timeout: 30 * time.Second,
			Transport: &http.Transport{
				MaxIdleConns:    10,
				IdleConnTimeout: 90 * time.Second,
			},
		},
	}
}

type APIError struct {
	HTTPCode int
	Message  string
	Raw      map[string]any
}

func (e *APIError) Error() string {
	return fmt.Sprintf("vertext: HTTP %d - %s", e.HTTPCode, e.Message)
}

func (c *Client) request(ctx context.Context, method, path string, body any) (map[string]any, error) {
	var r io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil { return nil, err }
		r = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, r)
	if err != nil { return nil, err }
	req.Header.Set("X-API-Key",    c.apiKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept",       "application/json")

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

	var result map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err }
	result["_httpCode"] = float64(resp.StatusCode)

	if success, _ := result["success"].(bool); !success && resp.StatusCode >= 400 {
		msg, _ := result["error"].(string)
		return nil, &APIError{HTTPCode: resp.StatusCode, Message: msg, Raw: result}
	}
	return result, nil
}

func (c *Client) Balance(ctx context.Context) (map[string]any, error) {
	return c.request(ctx, http.MethodGet, "/sms/balance", nil)
}

func (c *Client) Send(ctx context.Context, to, message string) (map[string]any, error) {
	return c.request(ctx, http.MethodPost, "/sms/send", map[string]string{"to": to, "message": message})
}

func (c *Client) Bulk(ctx context.Context, to []string, message, campaign string) (map[string]any, error) {
	body := map[string]any{"to": to, "message": message}
	if campaign != "" { body["campaign"] = campaign }
	return c.request(ctx, http.MethodPost, "/sms/bulk", body)
}

func (c *Client) BulkToGroup(ctx context.Context, groupID int, message, campaign string) (map[string]any, error) {
	body := map[string]any{"group_id": groupID, "message": message}
	if campaign != "" { body["campaign"] = campaign }
	return c.request(ctx, http.MethodPost, "/sms/bulk", body)
}

Usage

main.go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

	"yourmodule/vertext"
)

func main() {
	c   := vertext.New(os.Getenv("VT_API_KEY"))
	ctx := context.Background()

	bal, err := c.Balance(ctx)
	if err != nil { log.Fatal(err) }
	fmt.Println("Balance:", bal["balance"])

	res, err := c.Send(ctx, "0712345678", "Your OTP is 482910.")
	if err != nil {
		var apiErr *vertext.APIError
		if errors.As(err, &apiErr) {
			log.Printf("API error %d: %s", apiErr.HTTPCode, apiErr.Message)
		}
		return
	}
	fmt.Println("Sent! ID:", res["message_id"])
}
Java 11+ — Jackson — java.net.http.HttpClient. Add com.fasterxml.jackson.core:jackson-databind to your build (Maven / Gradle).

Client class

VertextClient.java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.*;

public class VertextClient {

    public static class ApiException extends Exception {
        public final int httpCode;
        public final Map<String, Object> response;

        public ApiException(int code, String msg, Map<String, Object> resp) {
            super("HTTP " + code + ": " + msg);
            this.httpCode = code;
            this.response = resp;
        }
    }

    private static final String DEFAULT_BASE = "https://api-sms.ultimatesolutions.co.ke/v1";
    private final String     apiKey;
    private final String     baseUrl;
    private final HttpClient http;
    private final ObjectMapper mapper = new ObjectMapper();

    public VertextClient(String apiKey) { this(apiKey, DEFAULT_BASE); }

    public VertextClient(String apiKey, String baseUrl) {
        this.apiKey  = apiKey;
        this.baseUrl = baseUrl;
        this.http    = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5)).build();
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> request(String method, String path, Object body) throws Exception {
        String json = body != null ? mapper.writeValueAsString(body) : "";
        HttpRequest.Builder req = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + path))
            .timeout(Duration.ofSeconds(30))
            .header("X-API-Key",    apiKey)
            .header("Content-Type", "application/json")
            .header("Accept",       "application/json");

        if ("POST".equals(method)) req.POST(HttpRequest.BodyPublishers.ofString(json));
        else                       req.GET();

        HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
        Map<String, Object> data  = mapper.readValue(resp.body(), Map.class);
        data.put("_httpCode", resp.statusCode());

        Boolean success = (Boolean) data.get("success");
        if ((success == null || !success) && resp.statusCode() >= 400) {
            String msg = (String) data.getOrDefault("error", "Unknown error");
            throw new ApiException(resp.statusCode(), msg, data);
        }
        return data;
    }

    public Map<String, Object> balance() throws Exception { return request("GET", "/sms/balance", null); }

    public Map<String, Object> send(String to, String message) throws Exception {
        return request("POST", "/sms/send", Map.of("to", to, "message", message));
    }

    public Map<String, Object> bulk(List<String> to, String message, String campaign) throws Exception {
        Map<String, Object> body = new HashMap<>();
        body.put("to", to); body.put("message", message);
        if (campaign != null && !campaign.isEmpty()) body.put("campaign", campaign);
        return request("POST", "/sms/bulk", body);
    }

    public Map<String, Object> bulkToGroup(int groupId, String message, String campaign) throws Exception {
        Map<String, Object> body = new HashMap<>();
        body.put("group_id", groupId); body.put("message", message);
        if (campaign != null && !campaign.isEmpty()) body.put("campaign", campaign);
        return request("POST", "/sms/bulk", body);
    }

    public static void main(String[] args) throws Exception {
        var sms = new VertextClient(System.getenv("VT_API_KEY"));
        var bal = sms.balance();
        System.out.println("Balance: " + bal.get("balance"));

        try {
            var res = sms.send("0712345678", "Your OTP is 482910.");
            System.out.println("Sent! ID: " + res.get("message_id"));
        } catch (ApiException e) {
            System.err.println("Error " + e.httpCode + ": " + e.getMessage());
            if (e.httpCode == 429)
                System.err.println("Retry after: " + e.response.get("retry_after") + "s");
        }
    }
}
.NET 6+ — HttpClient — System.Text.Json — no NuGet packages. Register as a singleton in your DI container — never create a new instance per request.

Client class

VertextClient.cs
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Vertext;

public class ApiException : Exception
{
    public int HttpCode { get; }
    public Dictionary<string, JsonElement>? Response { get; }
    public ApiException(int code, string message, Dictionary<string, JsonElement>? resp = null)
        : base($"HTTP {code}: {message}") { HttpCode = code; Response = resp; }
}

public sealed class VertextClient : IDisposable
{
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        PropertyNamingPolicy   = JsonNamingPolicy.SnakeCaseLower,
    };
    private readonly HttpClient _http;

    public VertextClient(string apiKey, string baseUrl = "https://api-sms.ultimatesolutions.co.ke/v1")
    {
        _http = new HttpClient
        {
            BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'),
            Timeout     = TimeSpan.FromSeconds(30),
        };
        _http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
        _http.DefaultRequestHeaders.Add("Accept",    "application/json");
    }

    private async Task<Dictionary<string, JsonElement>> RequestAsync(
        HttpMethod method, string path, object? body = null, CancellationToken ct = default)
    {
        using var req = new HttpRequestMessage(method, path)
        {
            Content = body is null ? null : JsonContent.Create(body, options: JsonOpts),
        };
        using var res = await _http.SendAsync(req, ct);
        var data = await res.Content
            .ReadFromJsonAsync<Dictionary<string, JsonElement>>(JsonOpts, ct)
            ?? throw new InvalidOperationException("Empty response");

        if (!res.IsSuccessStatusCode)
        {
            var msg = data.TryGetValue("error", out var e) ? e.GetString() : "Unknown error";
            throw new ApiException((int)res.StatusCode, msg ?? "Unknown error", data);
        }
        return data;
    }

    public Task<Dictionary<string, JsonElement>> BalanceAsync(CancellationToken ct = default)
        => RequestAsync(HttpMethod.Get, "sms/balance", ct: ct);

    public Task<Dictionary<string, JsonElement>> SendAsync(string to, string message, CancellationToken ct = default)
        => RequestAsync(HttpMethod.Post, "sms/send", new { to, message }, ct);

    public Task<Dictionary<string, JsonElement>> BulkAsync(
        string[] to, string message, string? campaign = null, CancellationToken ct = default)
        => RequestAsync(HttpMethod.Post, "sms/bulk", new { to, message, campaign }, ct);

    public Task<Dictionary<string, JsonElement>> BulkToGroupAsync(
        int groupId, string message, string? campaign = null, CancellationToken ct = default)
        => RequestAsync(HttpMethod.Post, "sms/bulk", new { group_id = groupId, message, campaign }, ct);

    public void Dispose() => _http.Dispose();
}

// Usage (Program.cs)
// using Vertext;
// using var sms = new VertextClient(Environment.GetEnvironmentVariable("VT_API_KEY")!);
// var bal = await sms.BalanceAsync();
// Console.WriteLine($"Balance: {bal["balance"]}");
// try {
//     var res = await sms.SendAsync("0712345678", "Your OTP is 482910.");
//     Console.WriteLine($"Sent! ID: {res["message_id"]}");
// } catch (ApiException ex) when (ex.HttpCode == 429) {
//     Console.WriteLine($"Rate limited. Retry in {ex.Response?["retry_after"].GetInt32() ?? 60}s");
// }
Ruby 2.7+ — stdlib Net::HTTP — no gems required. The HTTP connection is kept alive across calls via Net::HTTP#start.

Client class

vertext_client.rb
require 'net/http'
require 'json'
require 'uri'

module Vertext
  class ApiError < StandardError
    attr_reader :http_code, :response

    def initialize(http_code, message, response = {})
      super("HTTP #{http_code}: #{message}")
      @http_code = http_code
      @response  = response
    end
  end

  class Client
    DEFAULT_BASE = 'https://api-sms.ultimatesolutions.co.ke/v1'.freeze

    def initialize(api_key, base_url = DEFAULT_BASE)
      @api_key  = api_key
      @base_uri = URI.parse(base_url)
      @http              = Net::HTTP.new(@base_uri.host, @base_uri.port)
      @http.use_ssl      = @base_uri.scheme == 'https'
      @http.open_timeout = 5
      @http.read_timeout = 30
      @http.start
    end

    def balance                           = request(:get,  '/sms/balance')
    def send_sms(to:, message:)           = request(:post, '/sms/send',  { to: to, message: message })

    def bulk(to:, message:, campaign: nil)
      body = { to: to, message: message }
      body[:campaign] = campaign if campaign
      request(:post, '/sms/bulk', body)
    end

    def bulk_to_group(group_id:, message:, campaign: nil)
      body = { group_id: group_id, message: message }
      body[:campaign] = campaign if campaign
      request(:post, '/sms/bulk', body)
    end

    def close = @http.finish if @http.started?

    private

    def request(method, path, body = nil)
      full_path = @base_uri.path.chomp('/') + path
      headers   = { 'X-API-Key' => @api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
      req = case method
            when :get  then Net::HTTP::Get.new(full_path, headers)
            when :post then Net::HTTP::Post.new(full_path, headers).tap { |r| r.body = body.to_json }
            end
      resp = @http.request(req)
      data = JSON.parse(resp.body)
      data['_http_code'] = resp.code.to_i
      raise ApiError.new(resp.code.to_i, data['error'] || 'Unknown error', data) unless data['success']
      data
    end
  end
end

# Usage
if __FILE__ == $PROGRAM_NAME
  sms = Vertext::Client.new(ENV.fetch('VT_API_KEY'))

  puts "Balance: #{sms.balance['balance']} credits"

  begin
    res = sms.send_sms(to: '0712345678', message: 'Your OTP is 482910.')
    puts "Sent! ID: #{res['message_id']}, balance: #{res['balance']}"
  rescue Vertext::ApiError => e
    case e.http_code
    when 402 then puts "Out of credits: #{e.response['balance']} remaining"
    when 429 then puts "Rate limited. Retry in #{e.response['retry_after']}s"
    else          puts "Error: #{e}"
    end
  end

  res = sms.bulk(to: ['0712345678', '0723456789'], message: 'Flash sale!', campaign: 'June')
  puts "Job ##{res['job_id']} - #{res['queued']} messages queued"
  sms.close
end