SMSPoP Zimbabwe bulk SMS REST API developer documentation

Developer docs

SMSPoP REST API

Integrate Zimbabwe bulk SMS into your applications. Send to Econet and NetOne subscribers in seconds.

Bearer token auth JSON · REST Generate API token

Authentication

Every request needs a Bearer token. Create one in your dashboard under API Tokens → Generate New Token. The token is shown once — copy it straight away.

Send it in the Authorization header of every request, and ask for JSON:

HTTP headers
Authorization: Bearer YOUR_API_TOKEN
Accept: application/json
Content-Type: application/json
Keep tokens on your server. Never put a token in a website, mobile app or public repository — anyone holding it can send SMS on your credit. If a token leaks, revoke it on the API Tokens page and create a new one. Always call the API over https.

Quick start

1

Get a sender ID and a token

Apply for a sender ID (or use the shared SMSPoP one while yours is reviewed), buy credits, then generate an API token.

2

Send a campaign

POST /api/campaigns with your message, sender ID and numbers. Messages go out immediately — no separate send call.

3

Read the response

Each number comes back with a message_id and a status: sent (accepted by the network), failed or skipped.

4

Receive delivery reports

Set a webhook URL and we POST the final result — delivered, undelivered, expired — for every message_id.

Test with Postman

The quickest way to see the API working before you write code. Any HTTP client (Postman, Insomnia, curl) works the same way.

Send a test message

  1. In Postman, click New → HTTP Request. Set the method to POST and the URL to https://smspop.co.zw/api/campaigns.
  2. Open the Authorization tab, choose Bearer Token and paste your API token.
  3. Open the Headers tab and add Accept = application/json.
  4. Open the Body tab, choose raw and JSON, and paste the body below — put your own phone number in manual_contacts and one of your approved sender IDs in sender_id.
  5. Click Send. You should get 200 with a message_id for your number, and the SMS on your phone a few seconds later.
Body (raw JSON)
{
  "name": "Postman test",
  "message": "Hello from the SMSPoP API",
  "sender_id": "YOURSENDER",
  "contact_import_method": "manual",
  "manual_contacts": "0771234567"
}

Then change the method to GET (same URL, no body) and send again to see your campaign listed with its delivered count.

See your delivery reports arrive

  1. Open a free request inspector such as webhook.site and copy the unique https URL it gives you.
  2. Paste it as your webhook URL on the API Tokens page and click Save URL, then Send test webhook — a webhook.test event appears in the inspector within seconds.
  3. Or, for one campaign only, add "callback_url": "https://webhook.site/your-id" to the Postman body above.
  4. Send a message to your own number. When the network confirms it, a message.delivery_report event arrives with the same message_id Postman showed you.
Test messages are real SMS and use your credits. Inspector URLs are public, so use them for testing only and switch to your own server's URL before going live.

API Endpoints

Base URL: https://smspop.co.zw/api · JSON in, JSON out.

POST /campaigns
Create and send a campaign

Creates a campaign and sends it straight away to every valid number. The response tells you what happened to each number.

Request body

FieldTypeDescription
name string Required Campaign name for your records. Max 255 characters.
message string Required The SMS text, up to 2,000 characters. 160 characters fit in one SMS; longer messages are split into 153-character parts, each billed as one SMS — see Message length & billing. Emojis are removed.
sender_id string Required One of your approved sender IDs (3–13 letters and numbers). Numbers on a network where it is not yet approved are skipped.
contact_import_method string Required Must be "manual".
manual_contacts string Required Numbers separated by commas, spaces, semicolons or new lines. Accepted formats: 0771234567, 771234567, 263771234567, +263771234567. Econet (077, 078) and NetOne (071) only. Duplicates are sent once; invalid numbers are ignored and counted in summary.invalid.
callback_url string Optional Webhook URL for this campaign's delivery reports, overriding your account webhook URL. Must be a public https:// address.
Request
{
  "name": "Order updates",
  "message": "Hi, your order #1042 is ready for collection at our Avondale branch.",
  "sender_id": "MYSHOP",
  "contact_import_method": "manual",
  "manual_contacts": "0771234567, 0712345678",
  "callback_url": "https://example.com/sms/delivery-reports"
}

Response (200)

JSON
{
  "success": true,
  "message": "Campaign processed. 2 sent, 0 failed, 0 skipped.",
  "data": {
    "campaign": {
      "id": 8691,
      "name": "Order updates",
      "sender_id": "MYSHOP",
      "status": "approved",
      "callback_url": "https://example.com/sms/delivery-reports"
    },
    "summary": {
      "sent": 2,
      "failed": 0,
      "skipped": 0,
      "invalid": 0,
      "credits_used": 2,
      "credits_remaining": 498
    },
    "contacts": [
      { "message_id": 102071, "phone": "263771234567", "status": "sent" },
      { "message_id": 102072, "phone": "263712345678", "status": "sent" }
    ]
  }
}
contacts[].statusMeaning
sentAccepted by the network and charged. The final result arrives later as a delivery report.
failedThe network did not accept it. Not charged. reason says why.
skippedNot attempted — unsupported network, or your sender ID isn't approved on that network yet. Not charged. reason says why.

Keep each message_id: it is how you match delivery reports to the messages you sent.

GET /campaigns
List your campaigns

Your campaigns, newest first, 15 per page with send and delivery counts. Use ?page=2 for the next page.

JSON
{
  "success": true,
  "data": {
    "campaigns": [
      {
        "id": 8691,
        "name": "Order updates",
        "sender_id": "MYSHOP",
        "status": "approved",
        "total_contacts": 2,
        "sent": 2,
        "failed": 0,
        "delivered": 1,
        "undelivered": 0,
        "created_at": "2026-10-11T09:15:02+02:00"
      }
    ],
    "meta": { "total": 1, "per_page": 15, "current_page": 1, "last_page": 1 }
  }
}

sent − delivered − undelivered = messages still waiting for a delivery report.

Message length & billing

You are charged one credit per SMS part, per number. One SMS holds 160 characters. A longer message is split into 153-character parts — the remaining 7 carry the information that joins the parts back together on the phone — so the credits jump at 161, not 320:

Characters (standard alphabet)SMS parts = credits per number
1 – 1601
161 – 3062
307 – 4593
460 – 6124
  • Extended characters ^ { } [ ] ~ \ | € count as two.
  • Unicode: one character outside the standard alphabet — accented letters (é, ü), curly quotes (“ ” ’), non-Latin scripts — switches the whole message to Unicode: 70 characters in one SMS, 67 per part. Straight quotes ( " ' ) keep you on the cheaper standard alphabet.
  • Emojis are removed before sending.
  • Keep it to 160. One clear, single-part message is cheapest and reads best on every phone.

The credits_used field in the response is exactly what was charged.

Sender IDs

The sender ID is the name recipients see instead of a phone number. Apply under Sender IDs in your dashboard; Econet and NetOne each approve it separately, which can take up to 7 working days. Until then you can send with the shared SMSPoP sender ID.

Format

  • 3 to 13 letters and numbers (A–Z, 0–9)
  • No spaces, dots, dashes or symbols — FARLNUXLABS, not Farlnux Labs

Approved

  • Your registered business, brand or product name
  • One recognisable name per brand

Rejected

  • Generic words: INFO, ALERT, PROMO, SMS, BANK
  • Names of other companies, banks, mobile networks (Econet, NetOne, EcoCash, OneMoney), government bodies or brands you don't own
  • Anything misleading, offensive or made to look like an official or personal number

A sender ID approved on only one network sends to that network's numbers; numbers on the other network come back as skipped until it is approved there too.

Delivery reports & webhooks

sent in the API response means the network accepted the message. Whether it reached the phone is reported by Econet or NetOne afterwards — usually within seconds, sometimes hours later if the phone is off. We forward each result to your server as a webhook: an HTTPS POST we make to a URL you choose.

1. Set your webhook URL

  • For all campaigns: on the API Tokens page under Delivery Report Webhooks. This covers every campaign on your account — sent through the API or the dashboard.
  • For one campaign: pass callback_url when you create it. It overrides the account URL for that campaign.
  • The URL must be a public https:// address. Saving it creates your signing secret, shown on the same page.

2. What we send

One message.delivery_report event per message, as soon as its delivery result is known:

POST https://your-server/… · JSON
{
  "event": "message.delivery_report",
  "webhook_id": 1042,
  "message_id": 102071,
  "campaign_id": 8691,
  "source": "api",
  "phone": "263771234567",
  "network": "econet",
  "status": "delivered",
  "error": null,
  "delivered_at": "2026-10-11T09:15:09+02:00",
  "created_at": "2026-10-11T09:15:11+02:00"
}
FieldDescription
webhook_idUnique per event. Retries resend the same id — use it to ignore duplicates.
message_idMatches contacts[].message_id from POST /campaigns.
sourceapi or web — which channel created the campaign.
networkeconet or netone.
status delivered reached the phone · undelivered the network gave up · expired the phone stayed unreachable until the message expired · rejected refused by the network · unknown the network couldn't say
errorThe network's code when not delivered, e.g. UNDELIV (err 001); otherwise null.
delivered_atWhen the network reached its final result (ISO 8601).

You can also receive a webhook.test event — from the Send test webhook button — which carries only a message field.

3. Verify the signature

Every request carries three headers. Check the signature before trusting the body, so nobody else can post fake reports to your URL:

X-SmsPop-EventThe event name, e.g. message.delivery_report
X-SmsPop-TimestampUnix time when we signed it
X-SmsPop-Signaturesha256= + hex HMAC-SHA256 of "{timestamp}.{raw request body}" using your signing secret

Use the raw body exactly as received (not re-encoded JSON), compare in constant time, and reject timestamps older than 5 minutes.

PHP
<?php
$secret    = getenv('SMSPOP_WEBHOOK_SECRET');
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SMSPOP_TIMESTAMP'] ?? '';
$expected  = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (!hash_equals($expected, $_SERVER['HTTP_X_SMSPOP_SIGNATURE'] ?? '')
    || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

$report = json_decode($body, true);
// Save it (ignore a webhook_id you have already processed), then reply fast:
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();

// express.raw keeps the exact bytes we signed
app.post('/sms/delivery-reports', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-SmsPop-Timestamp') || '';
  const received  = req.get('X-SmsPop-Signature') || '';
  const expected  = 'sha256=' + crypto
    .createHmac('sha256', process.env.SMSPOP_WEBHOOK_SECRET)
    .update(timestamp + '.' + req.body)
    .digest('hex');

  const valid = received.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
    && Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
  if (!valid) return res.sendStatus(401);

  const report = JSON.parse(req.body);
  // Save it (ignore a webhook_id you have already processed)
  res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/sms/delivery-reports")
def delivery_report():
    body      = request.get_data()                      # raw bytes
    timestamp = request.headers.get("X-SmsPop-Timestamp", "")
    expected  = "sha256=" + hmac.new(
        os.environ["SMSPOP_WEBHOOK_SECRET"].encode(),
        timestamp.encode() + b"." + body,
        hashlib.sha256,
    ).hexdigest()

    if not timestamp.isdigit() \
            or not hmac.compare_digest(expected, request.headers.get("X-SmsPop-Signature", "")) \
            or abs(time.time() - int(timestamp)) > 300:
        abort(401)

    report = request.get_json()
    # Save it (ignore a webhook_id you have already processed)
    return "", 200

4. Reply quickly — retries

  • Reply with any 2xx within 10 seconds. Save the report and do slow work afterwards.
  • Anything else — an error status, a timeout, a redirect — is retried after 1 min, 5 min, 15 min, 1 h and 3 h (6 attempts in total), with the same webhook_id. Redirects are never followed.
  • Reports for different messages can arrive in any order, and occasionally more than once. Make your handler safe to run twice for the same webhook_id.
  • The API Tokens page lists your last deliveries with the status your server returned — the first place to look if reports aren't arriving.
  • If you rotate your signing secret, your server must start using the new one immediately.

Rate limits

EndpointLimit (per account)
POST /campaigns10 requests per minute
GET /campaigns60 requests per minute

Over the limit you get 429 with a Retry-After header (seconds). Send many numbers in one campaign rather than one request per number.

Acceptable use — zero tolerance for fraud

SMSPoP has zero tolerance for fraud. Where we suspect fraudulent use we block the messages, suspend the account and revoke its sender IDs immediately, without prior notice, and may report the activity — with the account details and message records — to the mobile network operators, the regulator and law enforcement.

Prohibited, among others:

  • Scams and phishing — asking recipients for PINs, passwords, one-time codes, card or bank details, or money
  • Impersonating a bank, mobile network, mobile-money service (EcoCash, OneMoney), government body, company or person
  • Fake prizes, winnings, jobs or investment offers
  • Money-transfer and remittance scams (e.g. "your transfer is ready to collect, bring ID")
  • Political, sexual, violent, drug-related or other harmful content, and messages to people who haven't agreed to hear from you

Every message is screened automatically before it is sent. A blocked API request returns 403 with the reason, and nothing is sent or charged. Legitimate account notices — sign-up confirmations, OTPs for your own service, password-reset links to your own site — are allowed. Full terms: Terms & Conditions, section 2.6.

Code examples

JavaScript / Node.js

JavaScript
async function sendSMS(token) {
  const response = await fetch('https://smspop.co.zw/api/campaigns', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify({
      name: 'Order updates',
      message: 'Hi, your order #1042 is ready for collection.',
      sender_id: 'MYSHOP',
      contact_import_method: 'manual',
      manual_contacts: '0771234567, 0712345678',
    }),
  });

  const result = await response.json();
  if (!response.ok) throw new Error(result.message);

  const { summary, contacts } = result.data;
  console.log(`Sent ${summary.sent}, failed ${summary.failed}, credits used ${summary.credits_used}`);
  // Store contacts[i].message_id to match delivery reports later
  return contacts;
}

sendSMS(process.env.SMSPOP_API_TOKEN);

PHP

PHP
<?php

function sendSMS(string $token): array
{
    $ch = curl_init('https://smspop.co.zw/api/campaigns');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_POSTFIELDS     => json_encode([
            'name'                  => 'Order updates',
            'message'               => 'Hi, your order #1042 is ready for collection.',
            'sender_id'             => 'MYSHOP',
            'contact_import_method' => 'manual',
            'manual_contacts'       => '0771234567, 0712345678',
        ]),
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Accept: application/json',
            'Authorization: Bearer ' . $token,
        ],
    ]);

    $result = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException($result['message'] ?? "HTTP $status");
    }

    // Store each contacts[i]['message_id'] to match delivery reports later
    return $result['data'];
}

$data = sendSMS(getenv('SMSPOP_API_TOKEN'));
echo "Sent: {$data['summary']['sent']}\n";

Python

Python
import os
import requests

def send_sms(token: str) -> dict:
    response = requests.post(
        "https://smspop.co.zw/api/campaigns",
        json={
            "name": "Order updates",
            "message": "Hi, your order #1042 is ready for collection.",
            "sender_id": "MYSHOP",
            "contact_import_method": "manual",
            "manual_contacts": "0771234567, 0712345678",
        },
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
        },
        timeout=120,
    )
    result = response.json()
    if response.status_code != 200:
        raise RuntimeError(result.get("message", f"HTTP {response.status_code}"))

    data = result["data"]
    print(f"Sent {data['summary']['sent']}, failed {data['summary']['failed']}")
    # Store each contacts[i]["message_id"] to match delivery reports later
    return data

send_sms(os.environ["SMSPOP_API_TOKEN"])

Large campaigns are sent while the request is open, so allow a generous timeout (the examples use 120 seconds).

Error reference

Errors are JSON with a human-readable message. Field validation errors (422) also list every problem under errors. Nothing is sent or charged when a request fails.

401 Unauthenticated

Missing, mistyped or revoked token. Check the Authorization header, or create a new token.

{"message":"Unauthenticated."}
402 Insufficient credits

Not enough credits for every part of every message. Buy credits, or send to fewer numbers.

{"success":false,"message":"Insufficient credits. Please purchase more SMS credits.","data":{"required":4,"available":1}}
403 Campaign rejected

The message failed content screening — see Acceptable use.

{"success":false,"message":"Campaign rejected: Message contains money-transfer scam indicators."}
403 Account inactive

Your account is deactivated. Contact support.

{"success":false,"message":"Your account is inactive. Please contact support."}
422 Validation error

A field is missing or invalid — e.g. sender_id longer than 13 characters, contact_import_method not "manual", or a callback_url that isn't a public https:// address.

{"message":"The sender id field must not be greater than 13 characters.","errors":{"sender_id":["The sender id field must not be greater than 13 characters."]}}
422 Invalid sender ID

The sender_id isn't yours or isn't approved on any network yet.

{"success":false,"message":"Invalid sender ID. Ensure the sender ID is registered and approved for at least one carrier."}
422 No valid numbers

None of the numbers is a valid Econet (077/078) or NetOne (071) number. data.rejected lists them.

{"success":false,"message":"No valid contacts provided. Numbers must be NetOne (071) or Econet (077/078) Zimbabwean numbers.","data":{"rejected":["0242123456"]}}
429 Too many requests

Rate limit reached. Wait for the number of seconds in the Retry-After header.

{"message":"Too Many Attempts."}

Get help

Need integration support?

Our Harare team is happy to help with API integration, webhooks, troubleshooting, or custom requirements.