Base URL https://omniflex.co.zw/api
⚡ REST API

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.

📱
Networks
Econet & NetOne
🔒
Auth
JWT & API Keys
✓
Format
JSON / REST

Quick Start — Send your first SMS

Select your language above, then copy the snippet below.

Send SMS
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.

HTTP 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)

POST /api/auth/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');
Response
{
  "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 shape
{
  "error":  "Insufficient credits",
  "detail": "You need 150 credits but only have 42."
}
200OK — request succeeded
201Created — resource created
400Bad Request — invalid payload
401Unauthorized — missing/invalid token
402Payment Required — insufficient credits
403Forbidden — insufficient role
404Not Found
409Conflict — duplicate entry
422Unprocessable Entity — sender ID not approved for this send
429Too Many Requests — rate limited
502Bad Gateway — SMS gateway error

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.

Response
{
  "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 approved for the network the recipient is on (Econet: 077/078/079 numbers; NetOne: 071 numbers), not pending, rejected, or revoked.
  • If you haven't registered a Sender ID at all, omit senderId (or leave your campaign's sender unset) to send under the default OmniFlex identity, 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

LimitWindowScope
300 requests1 minuteAll endpoints (per IP)
5 OTP sends10 minutesPer identifier (phone/email)
5 wrong verificationsPer codeCode locked after 5 failures

SMS Campaigns

Campaigns organise bulk sends with a shared template, sender ID, and schedule. Recipients are resolved at dispatch time.

GET
/api/campaigns
List all campaigns
🔒 Auth
POST
/api/campaigns
Create a campaign
🔒 Auth + Permission
GET
/api/campaigns/:id
Get campaign details
🔒 Auth
PATCH
/api/campaigns/:id
Update campaign
🔒 Auth + Permission
DELETE
/api/campaigns/:id
Delete campaign
🔒 Auth + Permission

Request body — Create campaign

FieldTypeDescription
name*stringCampaign label
typeoptstring"SMS" (default) or "OTP"
message_templateoptstringMessage body. Use {{name}} for personalisation
sender_idoptstringApproved sender ID e.g. "MYBRAND"
statusoptstringdraft · active · scheduled · completed
scheduled_dateoptISO stringAuto-send at this UTC time (requires status: "scheduled")
recipientsoptarray[{phone, name?, message?}]

Create an SMS Campaign

POST /api/campaigns
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();
Response 201
{
  "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.

POST
/api/campaigns/:id/send-otp
Send an OTP to a phone or email under this campaign
🔒 Auth
FieldTypeDescription
identifier*stringPhone (263771234567) or email address
nameoptstringRecipient name for template personalisation

Send OTP via Campaign

POST /api/campaigns/:id/send-otp
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

POST /api/otp/verify
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

RuleValue
Code length6 digits
Code TTL5 minutes
Send rate limit5 per identifier per 10 minutes
Max failed verifications5 (code invalidated after)
ChannelsSMS (Econet / NetOne) or email

Send SMS

Dispatch to one recipient, an array of numbers, or a full recipient objects array with per-contact personalisation.

POST
/api/sms/send
Send SMS to one or many recipients
🔒 Auth
FieldTypeDescription
phonestringSingle number e.g. 263771234567
phonesstring[]Array of numbers (max 100)
recipientsobject[][{phone, name?, message?}] — enables personalisation
message*stringMessage body. Use {{name}} as placeholder
senderIdoptstringA 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_idoptstringLink to a campaign for DLR reporting
scheduleoptbooleanSet true to use sendingTime
sendingTimeoptISO stringUTC 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.

EconetNetOne
Sender headerAlphanumeric sender ID (when Econet-approved), else a short codeOriginating address (OA) — only when the sender ID is NetOne-approved; otherwise omitted
Campaign routingBy sender ID / short codeBaked into the campaign name on NetOne's side; the API handles this for you
Delivery reportsPull — 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 probeGET /api/sms/econet-pingGET /api/sms/netone-ping (checks the VPN link)

Response

FieldTypeDescription
okbooleanRequest accepted
econetnumberEconet recipients dispatched to the gateway
netonenumberNetOne recipients dispatched over the VPN XML API
netone_skippednumberNetOne 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.
suppressednumberRecipients removed by the org's suppression list before sending
Response
{ "ok": true, "econet": 2, "netone": 1, "netone_skipped": 0, "suppressed": 0 }

Single recipient

POST /api/sms/send — single
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

POST /api/sms/send — bulk
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

POST /api/sms/send — personalised
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!'],
        ],
    ]);
Response
{ "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.

POST
/api/otp/send
Send a 6-digit OTP code
Public
POST
/api/otp/verify
Verify the code the user entered
Public
POST /api/otp/send
// 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.

GET
/api/contacts
List all contacts with group memberships
🔒 Auth
POST
/api/contacts
Create a contact
🔒 Auth + Permission
PATCH
/api/contacts/:id
Update contact or group memberships
🔒 Auth + Permission
DELETE
/api/contacts/:id
Delete a contact
🔒 Auth + Permission
POST /api/contacts
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.

GET
/api/contact-groups
List groups with contact counts
🔒 Auth
POST
/api/contact-groups
Create a group — body: { "name": "VIP" }
🔒 Auth + Permission
PATCH
/api/contact-groups/:id
Rename a group
🔒 Auth + Permission
DELETE
/api/contact-groups/:id
Delete group (contacts not deleted)
🔒 Auth + Permission

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.

GET
/api/sender-ids
List sender IDs with per-network approval status
🔒 Auth
POST
/api/sender-ids
Register a new sender ID (both networks start pending)
🔒 Auth
GET
/api/sender-ids/:id
Retrieve a single sender ID
🔒 Auth
PATCH
/api/sender-ids/:id
Update sender_id / description only (approval status is staff-controlled)
🔒 Auth
PATCH
/api/sender-ids/:id/activate
Set as the org's active default sender ID
🔒 Auth
DELETE
/api/sender-ids/:id
Delete a sender ID
🔒 Auth

Register — request body

FieldTypeDescription
sender_id*string1–11 alphanumeric characters, no spaces or symbols. Unique per org (case-insensitive)
descriptionoptstringFree-text note shown to the approver
loa_documentoptobject{ name, data } — a Letter of Authority (base64), required by the networks when the name matches a protected brand

Record fields (in list / get responses)

FieldTypeDescription
idstringRecord id (use this in /:id paths — not the sender name)
sender_idstringThe alphanumeric name
econet_statusstringpending · approved · rejected — approval for Econet
netone_statusstringpending · approved · rejected — approval for NetOne (independent of Econet)
econet_rejection_reason
netone_rejection_reason
string, nullableMachine code when that network rejected; *_rejection_reason_label gives the human-readable text
is_active0 | 1Whether this is the org's default sender ID for sends that omit senderId
requires_brand_verificationbooleanName matched a protected brand — an LoA is needed

How the two networks use it

On send…EconetNetOne
*_status = "approved"Name is used as the alphanumeric headerName is used as the originating address (OA)
not approvedFalls back to a numeric short codeOA 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.

POST
/api/campaign-files
Upload recipient list
🔒 Auth
GET
/api/campaign-files/:id
Retrieve uploaded file
🔒 Auth
POST /api/campaign-files
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.

GET
/api/sms-logs
List delivery logs
🔒 Auth
GET
/api/sms/dlr
Fetch DLR status for a campaign (pulls fresh statuses from Econet; NetOne statuses are already stored from its push callback)
🔒 Auth

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 paramDescription
campaign_idFilter by campaign
dlr_statusdelivered · failed · pending
limitMax records (default 1000)
exporttrue — remove limit for full export
GET /api/sms-logs
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.

GET
/api/account-balance
Get current credit balance
🔒 Auth
GET
/api/transactions
List credit purchases (top-ups) — not deductions, see credit-ledger below
🔒 Auth
GET
/api/credit-ledger?since&before&reason&limit
Every credit movement — purchases, trial grants, and send deductions — with the reason, which network/legacy bucket it drew from, and the campaign it belongs to if any
🔒 Auth
💡

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.

GET /api/account-balance
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

ParamTypeDescription
sinceoptISO dateOnly entries at or after this time
beforeoptISO dateOnly entries strictly before this time — pass back the previous response's next_before to page further
reasonoptstringpurchase · trial · manual_send · weighting_cutoff
limitoptintegerDefault 100, max 500
GET /api/credit-ledger
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'];
PackageCreditsUSDZWG
SMS500500$15.00406.25
SMS10001,000$25.00812.50
SMS25002,500$50.00975.00
SMS50005,000$75.002,437.50

API Keys

Generate programmatic keys from Settings → Developer Keys. Keys are prefixed omf_live_ and shown only once at creation.

GET
/api/api-keys
List API keys
🔒 Admin
POST
/api/api-keys
Generate a new key — body: { "name": "...", "scope"?: "..." }
🔒 Admin
DELETE
/api/api-keys/:id
Revoke a key immediately
🔒 Admin

Request body — Generate key

FieldTypeDescription
name*stringDescriptive label, e.g. "CRM Integration"
scopeoptstringadmin · 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.

POST /api/api-keys
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.

GET
/api/webhooks
List webhook subscriptions
🔒 Admin
POST
/api/webhooks
Create a subscription
🔒 Admin
PATCH
/api/webhooks/:id
Update URL, events, or active state
🔒 Admin
DELETE
/api/webhooks/:id
Remove a subscription
🔒 Admin

Request body — Create subscription

FieldTypeDescription
url*stringMust be https:// — events are POSTed here
events*string[]One or more of message.delivered, message.failed

Register a webhook

POST /api/webhooks
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:

JSON
{
  "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.

Verify X-Omniflex-Signature
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

AttemptDelayTimeout
1Immediate8 seconds
2+2 seconds8 seconds
3+5 seconds8 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.

PermissionAdminManagerOperatorViewer
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 URLhttps://omniflex.co.zw/api
AuthAuthorization: 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.
ScopeThe key acts for the whole reseller. All /api/reseller/* routes require it; a normal customer key gets 403.

Concepts

TermMeaning
PoolCredits you have bought from Omniflex, not yet handed to a client.
ClientAn org (org_…) that belongs to you. Each has its own balance and users and sends exactly like a direct customer.
AllocationMoving credits pool → a client's balance. Reclaim is the reverse, capped at the client's unspent balance.
Platform feeWhat Omniflex bills you monthly, postpaid — per registered client, per active client, or per user (set on your reseller record).

Endpoints

GET
/api/reseller/overview
Pool balance, client count, credits used this month, low-pool flag
🔒 Reseller key
GET
/api/reseller/clients
List your client orgs with balance, user & campaign counts, month usage
🔒 Reseller key
POST
/api/reseller/clients
Create a client workspace + its first admin user
🔒 Reseller key
PATCH
/api/reseller/clients/:orgId
client_price_per_credit override; status = active | suspended
🔒 Reseller key
POST
/api/reseller/clients/:orgId/allocate
Move credits pool → client balance
🔒 Reseller key
POST
/api/reseller/clients/:orgId/reclaim
Pull unspent credits back into the pool
🔒 Reseller key
POST
/api/reseller/clients/:orgId/login-link
One-time URL that signs a named client user into their workspace (SSO)
🔒 Reseller key
GET
/api/reseller/bundles
Your credit-pack catalogue (active + inactive)
🔒 Reseller key
POST
/api/reseller/bundles
Create a bundle — { label, credits, price, currency?, active?, sort_order? }
🔒 Reseller key
PATCH
/api/reseller/bundles/:id
Edit a bundle
🔒 Reseller key
DELETE
/api/reseller/bundles/:id
Remove a bundle
🔒 Reseller key
GET
/api/reseller/pool/ledger
Pool movement history (purchases, allocations, clawbacks)
🔒 Reseller key
POST
/api/reseller/pool/purchase
Submit a pending payment for pool credits (Omniflex staff approve it)
🔒 Reseller key
GET
/api/reseller/billing?month=YYYY-MM
What you owe Omniflex: platform fee + pool purchases
🔒 Reseller key
GET
/api/reseller/statement?month=YYYY-MM&format=csv
Per-client usage × your list price — your own statement to issue clients
🔒 Reseller key
GET
/api/tenant/by-host
Public. Your branding + default_price_per_credit + active bundles, keyed by the Host header — for a browser storefront
Public

Create 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.

FieldTypeDescription
name*stringThe client company / workspace name
admin_email*stringFirst admin's email
admin_nameoptstringFirst admin's name
industryoptstringFree text
starter_creditsoptnumberSeed 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

Node — one exhibitor buys a 500-credit bundle
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

HTTPcodeWhen
409pool_insufficientAllocate exceeds the pool. Nothing moved — don't charge; top up.
409email_takenlogin-link email already belongs to another workspace.
409—POST /reseller/clients — admin_email already has an Omniflex account.
400below_min_purchasePool purchase under your minimum.
403reseller_managedA 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/*.
Omniflex API — © 2026 Tyflex Investments
support@omniflex.co.zw omniflex.co.zw