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:
Authorization: Bearer YOUR_API_TOKEN
Accept: application/json
Content-Type: application/json
Quick start
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.
Send a campaign
POST /api/campaigns with your message, sender ID and numbers. Messages go out immediately — no separate send call.
Read the response
Each number comes back with a message_id and a status: sent (accepted by the network), failed or skipped.
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
- In Postman, click New → HTTP Request. Set the method to POST and the URL to https://smspop.co.zw/api/campaigns.
- Open the Authorization tab, choose Bearer Token and paste your API token.
- Open the Headers tab and add Accept = application/json.
- 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.
- Click Send. You should get 200 with a message_id for your number, and the SMS on your phone a few seconds later.
{
"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
- Open a free request inspector such as webhook.site and copy the unique https URL it gives you.
- 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.
- Or, for one campaign only, add "callback_url": "https://webhook.site/your-id" to the Postman body above.
- 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.
API Endpoints
Base URL: https://smspop.co.zw/api · JSON in, JSON out.
Creates a campaign and sends it straight away to every valid number. The response tells you what happened to each number.
Request body
| Field | Type | Description | |
|---|---|---|---|
| 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. |
{
"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)
{
"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[].status | Meaning |
|---|---|
| sent | Accepted by the network and charged. The final result arrives later as a delivery report. |
| failed | The network did not accept it. Not charged. reason says why. |
| skipped | Not 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.
Your campaigns, newest first, 15 per page with send and delivery counts. Use ?page=2 for the next page.
{
"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 – 160 | 1 |
| 161 – 306 | 2 |
| 307 – 459 | 3 |
| 460 – 612 | 4 |
- 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:
{
"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"
}
| Field | Description |
|---|---|
| webhook_id | Unique per event. Retries resend the same id — use it to ignore duplicates. |
| message_id | Matches contacts[].message_id from POST /campaigns. |
| source | api or web — which channel created the campaign. |
| network | econet 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 |
| error | The network's code when not delivered, e.g. UNDELIV (err 001); otherwise null. |
| delivered_at | When 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-Event | The event name, e.g. message.delivery_report |
| X-SmsPop-Timestamp | Unix time when we signed it |
| X-SmsPop-Signature | sha256= + 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
$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);
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);
});
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
| Endpoint | Limit (per account) |
|---|---|
| POST /campaigns | 10 requests per minute |
| GET /campaigns | 60 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
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
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
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.
Missing, mistyped or revoked token. Check the Authorization header, or create a new token.
{"message":"Unauthenticated."}
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}}
The message failed content screening — see Acceptable use.
{"success":false,"message":"Campaign rejected: Message contains money-transfer scam indicators."}
Your account is deactivated. Contact support.
{"success":false,"message":"Your account is inactive. Please contact support."}
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."]}}
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."}
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"]}}
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.