Omniflex Developer API
Programmable SMS and OTP campaigns for Zimbabwe. Send personalised bulk SMS, run OTP verification flows, and track delivery in real time — from any language or platform.
Quick Start — Send your first SMS
Select your language above, then copy the snippet below.
const res = await fetch('https://omniflex.co.zw/api/sms/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: '263771234567',
message: 'Hello from Omniflex!',
senderId: 'MYBRAND',
}),
});
const data = await res.json();
console.log(data); // { ok: true, econet: {...}, netone: {...} }
const axios = require('axios');
const { data } = await axios.post(
'https://omniflex.co.zw/api/sms/send',
{ phone: '263771234567', message: 'Hello from Omniflex!', senderId: 'MYBRAND' },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
console.log(data); // { ok: true, econet: {...} }
import requests
resp = requests.post(
'https://omniflex.co.zw/api/sms/send',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'phone': '263771234567',
'message': 'Hello from Omniflex!',
'senderId': 'MYBRAND',
},
)
print(resp.json()) # {'ok': True, 'econet': {...}}
use Illuminate\Support\Facades\Http;
$response = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/sms/send', [
'phone' => '263771234567',
'message' => 'Hello from Omniflex!',
'senderId' => 'MYBRAND',
]);
$data = $response->json(); // ['ok' => true, 'econet' => [...]]
Authentication
Pass your API key or JWT token in every request using the Authorization header.
Authorization: Bearer omf_live_<your_api_key>
Your account also has a stable Account ID, visible in Settings → Developer Keys. It isn't a credential — requests are still authenticated with the API key or JWT above — it's just a reference identifier for support requests or when discussing your account with the OmniFlex team.
Obtain a JWT (password login)
const res = await fetch('https://omniflex.co.zw/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: 'you@example.com', password: 'secret' }),
});
const { token } = await res.json();
const axios = require('axios');
const { data } = await axios.post('https://omniflex.co.zw/api/auth/login', {
identifier: 'you@example.com',
password: 'secret',
});
const { token } = data;
import requests
resp = requests.post('https://omniflex.co.zw/api/auth/login',
json={'identifier': 'you@example.com', 'password': 'secret'})
token = resp.json()['token']
use Illuminate\Support\Facades\Http;
$token = Http::post('https://omniflex.co.zw/api/auth/login', [
'identifier' => 'you@example.com',
'password' => 'secret',
])->json('token');
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "usr_...", "name": "Tino Moyo", "role": "admin" },
"org": { "id": "org_...", "name": "My Company" }
}
Keep API keys secret. Never embed them in client-side code. Revoke compromised keys immediately from Settings → Developer Keys.
Errors & Status Codes
Errors return JSON with an error field. Successful responses include "ok": true.
{
"error": "Insufficient credits",
"detail": "You need 150 credits but only have 42."
}
Sender ID not approved (422)
Every send is checked against your approved Sender IDs at the moment it goes out — a send fails this way if the senderId you passed (or your campaign's configured sender) isn't approved for the specific network the recipient is on, or doesn't belong to your organisation. This applies even if the Sender ID is approved on the other network, or was approved and later revoked.
{
"error": "sender_id_not_approved",
"detail": "Sender ID 'MyBrand' is not approved for NetOne (status: pending)"
}
How to fix it:
- Check your Sender ID's status per network under Settings → Sender IDs — it must show
approvedfor the network the recipient is on (Econet: 077/078/079 numbers; NetOne: 071 numbers), notpending,rejected, orrevoked. - If you haven't registered a Sender ID at all, omit
senderId(or leave your campaign's sender unset) to send under the defaultOmniFlexidentity, which needs no approval. - Sending a batch that mixes both networks under one Sender ID approved on only one of them is rejected as a whole, naming the missing network in
detail— split the send by network, or wait for the remaining approval. - A blocked send is never charged — no credits are deducted for a
422.
Rate Limits
| Limit | Window | Scope |
|---|---|---|
| 300 requests | 1 minute | All endpoints (per IP) |
| 5 OTP sends | 10 minutes | Per identifier (phone/email) |
| 5 wrong verifications | Per code | Code locked after 5 failures |
SMS Campaigns
Campaigns organise bulk sends with a shared template, sender ID, and schedule. Recipients are resolved at dispatch time.
Request body — Create campaign
| Field | Type | Description |
|---|---|---|
| name* | string | Campaign label |
| typeopt | string | "SMS" (default) or "OTP" |
| message_templateopt | string | Message body. Use {{name}} for personalisation |
| sender_idopt | string | Approved sender ID e.g. "MYBRAND" |
| statusopt | string | draft · active · scheduled · completed |
| scheduled_dateopt | ISO string | Auto-send at this UTC time (requires status: "scheduled") |
| recipientsopt | array | [{phone, name?, message?}] |
Create an SMS Campaign
const res = await fetch('https://omniflex.co.zw/api/campaigns', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'June Promo',
type: 'SMS',
message_template: 'Hi {{name}}, enjoy 20% off this June!',
sender_id: 'MYBRAND',
status: 'scheduled',
scheduled_date: '2026-06-25T08:00:00Z',
recipients: [
{ phone: '263771234567', name: 'Tino' },
{ phone: '263712345678', name: 'Rudo' },
],
}),
});
const campaign = await res.json();
const axios = require('axios');
const client = axios.create({
baseURL: 'https://omniflex.co.zw/api',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const { data: campaign } = await client.post('/campaigns', {
name: 'June Promo',
type: 'SMS',
message_template: 'Hi {{name}}, enjoy 20% off this June!',
sender_id: 'MYBRAND',
status: 'scheduled',
scheduled_date: '2026-06-25T08:00:00Z',
recipients: [
{ phone: '263771234567', name: 'Tino' },
{ phone: '263712345678', name: 'Rudo' },
],
});
import requests
resp = requests.post(
'https://omniflex.co.zw/api/campaigns',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'name': 'June Promo',
'type': 'SMS',
'message_template': 'Hi {{name}}, enjoy 20% off this June!',
'sender_id': 'MYBRAND',
'status': 'scheduled',
'scheduled_date': '2026-06-25T08:00:00Z',
'recipients': [
{'phone': '263771234567', 'name': 'Tino'},
{'phone': '263712345678', 'name': 'Rudo'},
],
},
)
campaign = resp.json()
use Illuminate\Support\Facades\Http;
$campaign = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/campaigns', [
'name' => 'June Promo',
'type' => 'SMS',
'message_template' => 'Hi {{name}}, enjoy 20% off this June!',
'sender_id' => 'MYBRAND',
'status' => 'scheduled',
'scheduled_date' => '2026-06-25T08:00:00Z',
'recipients' => [
['phone' => '263771234567', 'name' => 'Tino'],
['phone' => '263712345678', 'name' => 'Rudo'],
],
])->json();
{
"id": "cmp_abc123",
"name": "June Promo",
"type": "SMS",
"status": "scheduled",
"total_recipients": 2,
"sent_count": 0,
"cost_estimate": 2,
"scheduled_date": "2026-06-25T08:00:00.000Z",
"created_at": "2026-06-24T10:30:00.000Z"
}
Use {{name}} in message_template — it is replaced with each recipient's name at send time. Set recipients[].message to override per contact.
OTP Campaigns
OTP campaigns track delivery and verified counts for one-time code flows. Create one with "type": "OTP", then call /send-otp per recipient.
| Field | Type | Description |
|---|---|---|
| identifier* | string | Phone (263771234567) or email address |
| nameopt | string | Recipient name for template personalisation |
Send OTP via Campaign
const campaignId = 'cmp_abc123';
const res = await fetch(
`https://omniflex.co.zw/api/campaigns/${campaignId}/send-otp`,
{
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({ identifier: '263771234567', name: 'Tino' }),
}
);
// { ok: true }
const axios = require('axios');
const campaignId = 'cmp_abc123';
await axios.post(
`https://omniflex.co.zw/api/campaigns/${campaignId}/send-otp`,
{ identifier: '263771234567', name: 'Tino' },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
import requests
campaign_id = 'cmp_abc123'
resp = requests.post(
f'https://omniflex.co.zw/api/campaigns/{campaign_id}/send-otp',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={'identifier': '263771234567', 'name': 'Tino'},
)
# resp.json() == {'ok': True}
use Illuminate\Support\Facades\Http;
$campaignId = 'cmp_abc123';
Http::withToken('YOUR_API_KEY')
->post("https://omniflex.co.zw/api/campaigns/{$campaignId}/send-otp", [
'identifier' => '263771234567',
'name' => 'Tino',
]);
Verify the OTP
const res = await fetch('https://omniflex.co.zw/api/otp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: '263771234567', code: '483921' }),
});
const { ok } = await res.json(); // true = verified
const axios = require('axios');
const { data } = await axios.post('https://omniflex.co.zw/api/otp/verify', {
identifier: '263771234567',
code: '483921',
});
// data.ok === true
import requests
resp = requests.post(
'https://omniflex.co.zw/api/otp/verify',
json={'identifier': '263771234567', 'code': '483921'},
)
assert resp.json()['ok'] is True
use Illuminate\Support\Facades\Http;
$ok = Http::post('https://omniflex.co.zw/api/otp/verify', [
'identifier' => '263771234567',
'code' => '483921',
])->json('ok'); // true
OTP Rules
| Rule | Value |
|---|---|
| Code length | 6 digits |
| Code TTL | 5 minutes |
| Send rate limit | 5 per identifier per 10 minutes |
| Max failed verifications | 5 (code invalidated after) |
| Channels | SMS (Econet / NetOne) or email |
Send SMS
Dispatch to one recipient, an array of numbers, or a full recipient objects array with per-contact personalisation.
| Field | Type | Description |
|---|---|---|
| phone | string | Single number e.g. 263771234567 |
| phones | string[] | Array of numbers (max 100) |
| recipients | object[] | [{phone, name?, message?}] — enables personalisation |
| message* | string | Message body. Use {{name}} as placeholder |
| senderIdopt | string | A sender name from GET /api/sender-ids. Applied as the Econet alphanumeric header when its econet_status = "approved", and as the NetOne originating address (OA) when its netone_status = "approved". Omit it and the account's active default sender ID (is_active) is used automatically — only the platform fallback (a generic Econet short code / the OmniFlex NetOne OA) applies when the account has no active sender ID at all. An unapproved name never fails the send: on Econet it isn't sent to the gateway, on NetOne the OA is just omitted. |
| campaign_idopt | string | Link to a campaign for DLR reporting |
| scheduleopt | boolean | Set true to use sendingTime |
| sendingTimeopt | ISO string | UTC datetime for scheduled delivery |
Networks & routing
Each recipient is routed automatically by MSISDN prefix — Econet or NetOne. You do not choose the network; you never send to a specific carrier. Telecel is not carried.
Sender ID: you don't need to pass senderId on every call. Omit it and the account's is_active sender ID is resolved and used per network (as the Econet header / NetOne OA where it's approved). Set one as the default once via PATCH /api/sender-ids/:id/activate. Pass senderId explicitly only to override that default for a specific send.
| Econet | NetOne | |
|---|---|---|
| Sender header | Alphanumeric sender ID (when Econet-approved), else a short code | Originating address (OA) — only when the sender ID is NetOne-approved; otherwise omitted |
| Campaign routing | By sender ID / short code | Baked into the campaign name on NetOne's side; the API handles this for you |
| Delivery reports | Pull — GET /api/sms/dlr?campaign_id=… | Push — NetOne calls POST /api/sms/netone-dlr; results appear in SMS Logs and fire message.delivered / message.failed webhooks |
| Health probe | GET /api/sms/econet-ping | GET /api/sms/netone-ping (checks the VPN link) |
Response
| Field | Type | Description |
|---|---|---|
| ok | boolean | Request accepted |
| econet | number | Econet recipients dispatched to the gateway |
| netone | number | NetOne recipients dispatched over the VPN XML API |
| netone_skipped | number | NetOne recipients that could not be dispatched (e.g. VPN link down). They are not charged and not retried automatically — resend later. Econet has no equivalent field: a gateway rejection fails the whole batch with 502. |
| suppressed | number | Recipients removed by the org's suppression list before sending |
{ "ok": true, "econet": 2, "netone": 1, "netone_skipped": 0, "suppressed": 0 }
Single recipient
const res = await fetch('https://omniflex.co.zw/api/sms/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: '263771234567',
message: 'Your order #4821 has been shipped!',
}),
});
const data = await res.json();
const axios = require('axios');
const { data } = await axios.post(
'https://omniflex.co.zw/api/sms/send',
{ phone: '263771234567', message: 'Your order #4821 has been shipped!' },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
import requests
resp = requests.post(
'https://omniflex.co.zw/api/sms/send',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={'phone': '263771234567', 'message': 'Your order #4821 has been shipped!'},
)
use Illuminate\Support\Facades\Http;
$data = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/sms/send', [
'phone' => '263771234567',
'message' => 'Your order #4821 has been shipped!',
])->json();
Bulk — multiple numbers
const res = await fetch('https://omniflex.co.zw/api/sms/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
phones: ['263771234567', '263712345678', '263782345678'],
message: 'Flash sale: 30% off all products today!',
campaign_id: 'cmp_abc123',
}),
});
const axios = require('axios');
await axios.post(
'https://omniflex.co.zw/api/sms/send',
{
phones: ['263771234567', '263712345678', '263782345678'],
message: 'Flash sale: 30% off all products today!',
campaign_id: 'cmp_abc123',
},
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
import requests
requests.post(
'https://omniflex.co.zw/api/sms/send',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'phones': ['263771234567', '263712345678', '263782345678'],
'message': 'Flash sale: 30% off all products today!',
'campaign_id': 'cmp_abc123',
},
)
use Illuminate\Support\Facades\Http;
Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/sms/send', [
'phones' => ['263771234567', '263712345678', '263782345678'],
'message' => 'Flash sale: 30% off all products today!',
'campaign_id' => 'cmp_abc123',
]);
Personalised — per-recipient messages
await fetch('https://omniflex.co.zw/api/sms/send', {
method: 'POST',
headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Hi {{name}}, your loyalty reward is waiting!',
recipients: [
{ phone: '263771234567', name: 'Tino' },
{ phone: '263712345678', name: 'Rudo' },
{ phone: '263782345678', name: 'Farai', message: 'Farai — your VIP gift is ready!' },
],
}),
});
await axios.post(
'https://omniflex.co.zw/api/sms/send',
{
message: 'Hi {{name}}, your loyalty reward is waiting!',
recipients: [
{ phone: '263771234567', name: 'Tino' },
{ phone: '263712345678', name: 'Rudo' },
{ phone: '263782345678', name: 'Farai', message: 'Farai — your VIP gift is ready!' },
],
},
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
requests.post(
'https://omniflex.co.zw/api/sms/send',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'message': 'Hi {{name}}, your loyalty reward is waiting!',
'recipients': [
{'phone': '263771234567', 'name': 'Tino'},
{'phone': '263712345678', 'name': 'Rudo'},
{'phone': '263782345678', 'name': 'Farai', 'message': 'Farai — your VIP gift is ready!'},
],
},
)
Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/sms/send', [
'message' => 'Hi {{name}}, your loyalty reward is waiting!',
'recipients' => [
['phone' => '263771234567', 'name' => 'Tino'],
['phone' => '263712345678', 'name' => 'Rudo'],
['phone' => '263782345678', 'name' => 'Farai',
'message' => 'Farai — your VIP gift is ready!'],
],
]);
{ "ok": true, "econet": 2, "netone": 1, "netone_skipped": 0, "suppressed": 0 }
Send OTP (Standalone)
Use these endpoints for OTP flows outside a campaign — phone verification, login, transaction confirmation.
// SMS OTP
await fetch('https://omniflex.co.zw/api/otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: '263771234567', method: 'sms' }),
});
// Email OTP
await fetch('https://omniflex.co.zw/api/otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: 'tino@example.com', method: 'email' }),
});
const axios = require('axios');
// SMS OTP
await axios.post('https://omniflex.co.zw/api/otp/send', {
identifier: '263771234567', method: 'sms',
});
// Email OTP
await axios.post('https://omniflex.co.zw/api/otp/send', {
identifier: 'tino@example.com', method: 'email',
});
import requests
# SMS OTP
requests.post('https://omniflex.co.zw/api/otp/send',
json={'identifier': '263771234567', 'method': 'sms'})
# Email OTP
requests.post('https://omniflex.co.zw/api/otp/send',
json={'identifier': 'tino@example.com', 'method': 'email'})
use Illuminate\Support\Facades\Http;
// SMS OTP
Http::post('https://omniflex.co.zw/api/otp/send', [
'identifier' => '263771234567', 'method' => 'sms',
]);
// Email OTP
Http::post('https://omniflex.co.zw/api/otp/send', [
'identifier' => 'tino@example.com', 'method' => 'email',
]);
Contacts
Store recipients in your address book and assign them to groups for targeted campaigns.
const res = await fetch('https://omniflex.co.zw/api/contacts', {
method: 'POST',
headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Tino Moyo', phone: '263771234567', group_ids: ['grp_vip'] }),
});
const contact = await res.json();
const { data: contact } = await axios.post(
'https://omniflex.co.zw/api/contacts',
{ name: 'Tino Moyo', phone: '263771234567', group_ids: ['grp_vip'] },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
contact = requests.post(
'https://omniflex.co.zw/api/contacts',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={'name': 'Tino Moyo', 'phone': '263771234567', 'group_ids': ['grp_vip']},
).json()
$contact = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/contacts', [
'name' => 'Tino Moyo',
'phone' => '263771234567',
'group_ids' => ['grp_vip'],
])->json();
Contact Groups
Organise contacts into named groups (e.g. VIP Customers, June Leads) for targeted campaign sends.
{ "name": "VIP" }Sender IDs
An alphanumeric sender name (1–11 characters, letters and digits only, no spaces). One sender ID record is approved separately on each network — econet_status and netone_status are independent. A record can be live on Econet while still pending or rejected on NetOne.
pending)sender_id / description only (approval status is staff-controlled)Register — request body
| Field | Type | Description |
|---|---|---|
| sender_id* | string | 1–11 alphanumeric characters, no spaces or symbols. Unique per org (case-insensitive) |
| descriptionopt | string | Free-text note shown to the approver |
| loa_documentopt | object | { name, data } — a Letter of Authority (base64), required by the networks when the name matches a protected brand |
Record fields (in list / get responses)
| Field | Type | Description |
|---|---|---|
| id | string | Record id (use this in /:id paths — not the sender name) |
| sender_id | string | The alphanumeric name |
| econet_status | string | pending · approved · rejected — approval for Econet |
| netone_status | string | pending · approved · rejected — approval for NetOne (independent of Econet) |
| econet_rejection_reason netone_rejection_reason | string, nullable | Machine code when that network rejected; *_rejection_reason_label gives the human-readable text |
| is_active | 0 | 1 | Whether this is the org's default sender ID for sends that omit senderId |
| requires_brand_verification | boolean | Name matched a protected brand — an LoA is needed |
How the two networks use it
| On send… | Econet | NetOne |
|---|---|---|
*_status = "approved" | Name is used as the alphanumeric header | Name is used as the originating address (OA) |
| not approved | Falls back to a numeric short code | OA is omitted; the message still delivers, routed by campaign name |
Because the send never fails on an unapproved NetOne sender ID, always check netone_status if branded NetOne traffic matters to you — a 200 response does not imply the OA was applied.
Campaign Files
Upload a parsed recipient list (from CSV/spreadsheet) as JSON. Reference the file ID when building campaigns.
const res = await fetch('https://omniflex.co.zw/api/campaign-files', {
method: 'POST',
headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: 'june_leads.csv',
columns: ['phone', 'name', 'city'],
recipients: [
{ phone: '263771234567', name: 'Tino', city: 'Harare' },
{ phone: '263712345678', name: 'Rudo', city: 'Bulawayo' },
],
}),
});
// { id: 'file_abc', columns: [...], count: 2 }
const { data } = await axios.post(
'https://omniflex.co.zw/api/campaign-files',
{
filename: 'june_leads.csv',
columns: ['phone', 'name', 'city'],
recipients: [
{ phone: '263771234567', name: 'Tino', city: 'Harare' },
{ phone: '263712345678', name: 'Rudo', city: 'Bulawayo' },
],
},
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
resp = requests.post(
'https://omniflex.co.zw/api/campaign-files',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'filename': 'june_leads.csv',
'columns': ['phone', 'name', 'city'],
'recipients': [
{'phone': '263771234567', 'name': 'Tino', 'city': 'Harare'},
{'phone': '263712345678', 'name': 'Rudo', 'city': 'Bulawayo'},
],
},
)
# {'id': 'file_abc', 'columns': [...], 'count': 2}
$file = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/campaign-files', [
'filename' => 'june_leads.csv',
'columns' => ['phone', 'name', 'city'],
'recipients' => [
['phone' => '263771234567', 'name' => 'Tino', 'city' => 'Harare'],
['phone' => '263712345678', 'name' => 'Rudo', 'city' => 'Bulawayo'],
],
])->json();
SMS Logs & DLR
Query per-message delivery logs filtered by campaign, status, or date. Each log row carries either an econet_message_id or a netone_message_id, so you can tell which network handled it.
Econet delivery statuses are fetched on demand by GET /api/sms/dlr. NetOne pushes statuses to POST /api/sms/netone-dlr as they occur, so NetOne rows update on their own — no polling needed — and each update fires a message.delivered or message.failed webhook.
| Query param | Description |
|---|---|
| campaign_id | Filter by campaign |
| dlr_status | delivered · failed · pending |
| limit | Max records (default 1000) |
| export | true — remove limit for full export |
const res = await fetch(
'https://omniflex.co.zw/api/sms-logs?campaign_id=cmp_abc123&dlr_status=delivered',
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
);
const logs = await res.json();
const { data: logs } = await axios.get(
'https://omniflex.co.zw/api/sms-logs',
{ params: { campaign_id: 'cmp_abc123', dlr_status: 'delivered' },
headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
logs = requests.get(
'https://omniflex.co.zw/api/sms-logs',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
params={'campaign_id': 'cmp_abc123', 'dlr_status': 'delivered'},
).json()
$logs = Http::withToken('YOUR_API_KEY')
->get('https://omniflex.co.zw/api/sms-logs', [
'campaign_id' => 'cmp_abc123',
'dlr_status' => 'delivered',
])->json();
Balance & Credits
1 credit = 1 SMS part (160 characters). Multi-part messages consume 1 credit per 153-character segment.
If you send via the API with your own campaign_id, use credit-ledger to confirm what was actually deducted — don't rely on that campaign's own report alone to account for usage from an external system.
const res = await fetch('https://omniflex.co.zw/api/account-balance',
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } });
const [balance] = await res.json();
console.log(balance.balance); // 842
const { data } = await axios.get('https://omniflex.co.zw/api/account-balance',
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } });
console.log(data[0].balance); // 842
data = requests.get('https://omniflex.co.zw/api/account-balance',
headers={'Authorization': 'Bearer YOUR_API_KEY'}).json()
balance = data[0]['balance'] # 842
$data = Http::withToken('YOUR_API_KEY')
->get('https://omniflex.co.zw/api/account-balance')->json();
$balance = $data[0]['balance']; // 842
GET /api/credit-ledger
| Param | Type | Description |
|---|---|---|
| sinceopt | ISO date | Only entries at or after this time |
| beforeopt | ISO date | Only entries strictly before this time — pass back the previous response's next_before to page further |
| reasonopt | string | purchase · trial · manual_send · weighting_cutoff |
| limitopt | integer | Default 100, max 500 |
const res = await fetch('https://omniflex.co.zw/api/credit-ledger?reason=manual_send&limit=50',
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } });
const { entries, next_before } = await res.json();
console.log(entries[0]);
// { id: 'ledger_...', credits: -1.5, reason: 'manual_send',
// bucket: 'weighted', campaign_id: 'camp_...', campaign_name: 'Reminder',
// balance_after: 847, created_at: '2026-10-07T10:47:48.000Z' }
const { data } = await axios.get('https://omniflex.co.zw/api/credit-ledger', {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
params: { reason: 'manual_send', limit: 50 },
});
console.log(data.entries[0].credits); // -1.5
data = requests.get('https://omniflex.co.zw/api/credit-ledger',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
params={'reason': 'manual_send', 'limit': 50}).json()
entries = data['entries']
$data = Http::withToken('YOUR_API_KEY')
->get('https://omniflex.co.zw/api/credit-ledger', ['reason' => 'manual_send', 'limit' => 50])->json();
$entries = $data['entries'];
| Package | Credits | USD | ZWG |
|---|---|---|---|
| SMS500 | 500 | $15.00 | 406.25 |
| SMS1000 | 1,000 | $25.00 | 812.50 |
| SMS2500 | 2,500 | $50.00 | 975.00 |
| SMS5000 | 5,000 | $75.00 | 2,437.50 |
API Keys
Generate programmatic keys from Settings → Developer Keys. Keys are prefixed omf_live_ and shown only once at creation.
{ "name": "...", "scope"?: "..." }Request body — Generate key
| Field | Type | Description |
|---|---|---|
| name* | string | Descriptive label, e.g. "CRM Integration" |
| scopeopt | string | admin · manager · operator (default) · viewer — caps the key to that role's permissions, see Roles & Permissions |
Scope a key down for third-party integrations that only need to read data — e.g. scope: "viewer" for a reporting dashboard that should never be able to send campaigns or change billing.
const res = await fetch('https://omniflex.co.zw/api/api-keys', {
method: 'POST',
headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'My Integration', scope: 'viewer' }),
});
const { key_value } = await res.json(); // save this — shown once
const { data } = await axios.post(
'https://omniflex.co.zw/api/api-keys',
{ name: 'My Integration', scope: 'viewer' },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
const { key_value } = data; // save this — shown once
resp = requests.post(
'https://omniflex.co.zw/api/api-keys',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={'name': 'My Integration', 'scope': 'viewer'},
)
key_value = resp.json()['key_value'] # save this — shown once
$keyValue = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/api-keys', [
'name' => 'My Integration',
'scope' => 'viewer',
])->json('key_value'); // save this — shown once
key_value is returned only once. Store it securely. Revoke and regenerate from the dashboard if lost.
Webhooks
Subscribe an HTTPS endpoint to receive real-time delivery events instead of polling SMS Logs. Manage subscriptions from Settings → Webhooks or the API below.
Request body — Create subscription
| Field | Type | Description |
|---|---|---|
| url* | string | Must be https:// — events are POSTed here |
| events* | string[] | One or more of message.delivered, message.failed |
Register a webhook
const res = await fetch('https://omniflex.co.zw/api/webhooks', {
method: 'POST',
headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://yourapp.com/webhooks/omniflex',
events: ['message.delivered', 'message.failed'],
}),
});
const { secret } = await res.json(); // save this — shown once, used to verify signatures
const { data } = await axios.post(
'https://omniflex.co.zw/api/webhooks',
{ url: 'https://yourapp.com/webhooks/omniflex', events: ['message.delivered', 'message.failed'] },
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
const { secret } = data; // save this — shown once, used to verify signatures
resp = requests.post(
'https://omniflex.co.zw/api/webhooks',
headers={'Authorization': 'Bearer YOUR_API_KEY'},
json={
'url': 'https://yourapp.com/webhooks/omniflex',
'events': ['message.delivered', 'message.failed'],
},
)
secret = resp.json()['secret'] # save this — shown once, used to verify signatures
$secret = Http::withToken('YOUR_API_KEY')
->post('https://omniflex.co.zw/api/webhooks', [
'url' => 'https://yourapp.com/webhooks/omniflex',
'events' => ['message.delivered', 'message.failed'],
])->json('secret'); // save this — shown once, used to verify signatures
secret is returned only once, at creation. Store it securely — it's required to verify that incoming webhook requests genuinely came from OmniFlex.
Event payload
Every delivery is a POST with this JSON body:
{
"event": "message.delivered",
"data": {
"campaign_id": "cmp_abc123",
"phone": "263771234567",
"log_id": "log_xyz789"
},
"timestamp": "2026-07-08T10:30:00.000Z"
}
Verifying the signature
Each request includes an X-Omniflex-Signature header — a hex-encoded HMAC-SHA256 of the raw request body, signed with your webhook's secret. Recompute it and compare before trusting the payload.
import { createHmac, timingSafeEqual } from 'crypto';
function isValid(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
const { createHmac, timingSafeEqual } = require('crypto');
// rawBody must be the exact, unparsed request body (e.g. express.raw())
function isValid(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
import hmac, hashlib
def is_valid(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
$expected = hash_hmac('sha256', $rawBody, $secret);
$isValid = hash_equals($expected, $signatureHeader);
Retries
| Attempt | Delay | Timeout |
|---|---|---|
| 1 | Immediate | 8 seconds |
| 2 | +2 seconds | 8 seconds |
| 3 | +5 seconds | 8 seconds |
Your endpoint must return a 2xx status within 8 seconds to count as delivered. After 3 failed attempts the delivery is logged as failed and not retried further — inspect delivery history from the dashboard.
Roles & Permissions
Every team member has one of four roles. API keys inherit the permissions of the admin who created them.
| Permission | Admin | Manager | Operator | Viewer |
|---|---|---|---|---|
| campaigns.view | ✓ | ✓ | ✓ | ✓ |
| campaigns.create | ✓ | ✓ | ✓ | ✗ |
| campaigns.edit | ✓ | ✓ | ✗ | ✗ |
| campaigns.delete | ✓ | ✓ | ✗ | ✗ |
| campaigns.send | ✓ | ✓ | ✓ | ✗ |
| contacts.create / edit | ✓ | ✓ | ✗ | ✗ |
| settings.billing | ✓ | ✗ | ✗ | ✗ |
| settings.devkeys | ✓ | ✗ | ✗ | ✗ |
| users.manage | ✓ | ✗ | ✗ | ✗ |
Reseller API white-label partners
A separate, server-to-server API for white-label partners who resell Omniflex under their own brand at <slug>.omniflex.co.zw. It manages client workspaces, credit allocation, pricing and billing — it does not send SMS (a client workspace uses the normal APIs above for that). If you are not a reseller, skip this section.
| Base URL | https://omniflex.co.zw/api |
| Auth | Authorization: Bearer omf_live_… — a reseller-scoped key. Create it in the reseller portal → API keys (not Settings → Developer Keys). Server-side only; never ship it to a browser. |
| Scope | The key acts for the whole reseller. All /api/reseller/* routes require it; a normal customer key gets 403. |
Concepts
| Term | Meaning |
|---|---|
| Pool | Credits you have bought from Omniflex, not yet handed to a client. |
| Client | An org (org_…) that belongs to you. Each has its own balance and users and sends exactly like a direct customer. |
| Allocation | Moving credits pool → a client's balance. Reclaim is the reverse, capped at the client's unspent balance. |
| Platform fee | What Omniflex bills you monthly, postpaid — per registered client, per active client, or per user (set on your reseller record). |
Endpoints
client_price_per_credit override; status = active | suspended{ label, credits, price, currency?, active?, sort_order? }default_price_per_credit + active bundles, keyed by the Host header — for a browser storefrontCreate a client workspace
POST /api/reseller/clients creates the org and its first admin user (emailed a sign-in link). Returns the org row — response.id is what you allocate to. 409 if admin_email already has an Omniflex account.
| Field | Type | Description |
|---|---|---|
| name* | string | The client company / workspace name |
| admin_email* | string | First admin's email |
| admin_nameopt | string | First admin's name |
| industryopt | string | Free text |
| starter_creditsopt | number | Seed this many credits from the pool immediately |
Allocate credits
POST /api/reseller/clients/:orgId/allocate — body { "credits": 500, "reference": "order 1234" }. Returns { ok, pool_balance, client_balance }. 409 { "code": "pool_insufficient" } if the pool is short — nothing is moved, nothing is charged. Check the pool first with GET /api/reseller/overview before taking payment from your customer.
Sign a client user in (SSO)
POST /api/reseller/clients/:orgId/login-link — body { "email", "name"?, "role"?, "returnTo"? }. Returns a one-time URL valid ~120 s that drops the user's browser straight into their workspace on your subdomain, signed in. The user is JIT-created on first call; passing role (admin | manager | operator | viewer) on later calls re-syncs it, so your app stays the source of truth. Redirect to the URL immediately; never store or log it. 409 { "code": "email_taken" } if that email belongs to an Omniflex account in another workspace.
Buy pool credits
POST /api/reseller/pool/purchase — body { "credits": 10000, "reference": "EFT 12 Mar", "payment_method": "eft" }. Submits a pending payment; Omniflex staff approve it, then the pool is credited. 400 { "code": "below_min_purchase" } if below your min_pool_purchase. Priced at your omniflex_price_per_credit.
Example — provision, fund, sign in
const BASE = 'https://omniflex.co.zw/api';
const H = { 'Authorization': 'Bearer ' + process.env.OMNIFLEX_RESELLER_API_KEY,
'Content-Type': 'application/json' };
// 1. Pool must cover the bundle — check BEFORE charging the customer
const { pool } = await (await fetch(BASE + '/reseller/overview', { headers: H })).json();
if (pool.balance < 500) throw new Error('Pool low — top up before selling');
// 2. Provision the client workspace once, store its id
let orgId = exhibitor.omniflex_org_id;
if (!orgId) {
const r = await fetch(BASE + '/reseller/clients', { method: 'POST', headers: H,
body: JSON.stringify({ name: exhibitor.company, admin_email: exhibitor.email, admin_name: exhibitor.contact }) });
if (r.status === 409) throw new Error('email already registered with Omniflex');
orgId = (await r.json()).id; // persist this
}
// 3. Take payment in your own system, THEN allocate
const a = await fetch(BASE + `/reseller/clients/${orgId}/allocate`, { method: 'POST', headers: H,
body: JSON.stringify({ credits: 500, reference: `order ${order.id}` }) });
if (a.status === 409) { /* pool_insufficient — refund, alert ops */ }
// 4. Hand the exhibitor a one-time sign-in link to their SMS workspace
const { url } = await (await fetch(BASE + `/reseller/clients/${orgId}/login-link`, { method: 'POST', headers: H,
body: JSON.stringify({ email: exhibitor.email, name: exhibitor.contact, role: 'admin' }) })).json();
res.redirect(url); // 302 — never render or store `url`
Error codes
| HTTP | code | When |
|---|---|---|
| 409 | pool_insufficient | Allocate exceeds the pool. Nothing moved — don't charge; top up. |
| 409 | email_taken | login-link email already belongs to another workspace. |
| 409 | — | POST /reseller/clients — admin_email already has an Omniflex account. |
| 400 | below_min_purchase | Pool purchase under your minimum. |
| 403 | reseller_managed | A client workspace tried to use a customer billing endpoint (e.g. POST /api/payments). Client credit only comes from your allocations. |
| 403 | — | A non-reseller key called /api/reseller/*. |