Send transactional and bulk SMS, manage contacts, run campaigns, and listen for delivery webhooks. Two auth modes: API keys for server-to-server and Sanctum bearer tokens for the dashboard.
Nuntio has two surfaces, both base URL https://nuntio.africa/api:
/api/v1/* — public REST API. Server-to-server, authenticated with X-API-Key + X-API-Secret headers. Designed for backend integrations./api/* — dashboard session API. Authenticated with a Sanctum bearer token from /api/auth/login. Used by the web UI and the iOS/Android apps.All responses are JSON. All write endpoints (POST, PUT, DELETE) require Accept: application/json and Content-Type: application/json (or multipart/form-data for file uploads).
Copy this curl — it sends a single SMS via the public API. Replace YOUR_API_KEY and YOUR_API_SECRET with values from your dashboard.
curl -X POST https://nuntio.africa/api/v1/sms/send \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-API-Secret: YOUR_API_SECRET" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"to": "+255712345678",
"message": "Hello from Nuntio!"
}'
Every /api/v1/* request must include your public key id and secret in two custom headers. The secret is verified against a bcrypt hash server-side; the key id alone is not enough.
X-API-Key: spk_live_xxxxxxxxxxxxxxxx
X-API-Secret: sps_live_xxxxxxxxxxxxxxxx
Generate keys from Dashboard → API Keys. The plain secret is shown exactly once at creation — store it in your secret manager immediately.
| Endpoint | Required scope |
|---|---|
POST /v1/sms/send, /v1/sms/send-bulk | sms:send |
GET /v1/sms/status/{external_id} | sms:read |
POST /v1/sms/estimate | sms:read |
GET /v1/pricing | pricing:read |
GET /v1/account | account:read |
If your key lacks the required scope, the API returns 403 insufficient_scope.
For browser sessions and the mobile app, log in with email and password. The response contains a long-lived bearer token; pass it as Authorization: Bearer … on every /api/* call (excluding the public auth endpoints).
POST https://nuntio.africa/api/auth/login
Content-Type: application/json
{
"email": "you@example.com",
"password": "your-password",
"device_name": "my-laptop"
}
{
"success": true,
"message": "Welcome back, Jane!",
"user": {
"id": 1,
"name": "Jane Doe",
"email": "jane@example.com",
"role": "user"
},
"token": "1|abc123def456..."
}
Then send:
curl https://nuntio.africa/api/contacts \
-H "Authorization: Bearer 1|abc123def456..." \
-H "Accept: application/json"
Every error response is JSON with this shape:
{
"success": false,
"error": "rate_limit_exceeded",
"message": "API rate limit of 60 requests per minute exceeded."
}
error code | HTTP | When |
|---|---|---|
missing_credentials | 401 | X-API-Key or X-API-Secret header missing |
invalid_credentials | 401 | Key/secret pair unknown, revoked, or expired |
insufficient_scope | 403 | API key lacks the required scope for this route |
account_inactive | 403 | Sanctum user account is deactivated |
validation_error | 422 | Body failed Laravel validation (see errors field) |
insufficient_balance | 402 | Wallet has no credits left |
rate_limit_exceeded | 429 | Per-minute or per-IP quota exhausted |
not_found | 404 | Resource id not found for this account |
GATEWAY_ERROR | 422 | Payment gateway rejected the request |
DEBIT_FAILED | 500 | SMS was sent but wallet debit failed (manual reconciliation) |
Two layers:
rate_limit_per_minute (default 60). Tracked via cache key api_rate:{key_id}:YmdHi. 429 on breach.List endpoints return up to per_page items (default 20 or 25, max 100). Filter arguments are preserved across page changes — the response includes a pagination block.
{
"success": true,
"contacts": [ ... ],
"pagination": {
"current_page": 1,
"last_page": 4,
"per_page": 25,
"total": 87
}
}
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient phone, max 20 chars. E.164 recommended (e.g. +255712345678). |
message | string | Yes | Body, max 1,600 chars. Long messages split into segments (153 chars per multi-part segment). |
from | string | No | Sender ID, max 11 chars. Defaults to your account's configured sender. |
callback_url | string | No | URL that receives sms.sent / sms.delivered / sms.failed events for this message (JSON, same shape as webhook events, not signed). Must resolve to a public IP; redirects are not followed. |
{
"success": true,
"message_id": 4231,
"external_id": "01HXY...",
"phone": "+255712345678",
"network": "Vodacom",
"segments": 1,
"cost": 25,
"status": "delivered"
}
On failure: HTTP 402 with the same shape and "success": false.
Send the same message to up to 1,000 recipients in a single call. Each recipient is a separate string in the to array.
{
"to": ["+255712345678", "+255713456789", "+255714567890"],
"message": "Reminder: Your appointment is tomorrow at 10am.",
"from": "Clinic",
"callback_url": "https://example.com/webhooks/sms"
}
{
"success": true,
"total": 3,
"successful": 3,
"failed": 0,
"total_cost": 75,
"messages": [
{ "phone": "+255712345678", "external_id": "01HX...", "success": true, "cost": 25, "segments": 1, "status": "delivered" },
{ "phone": "+255713456789", "external_id": "01HY...", "success": true, "cost": 25, "segments": 1, "status": "delivered" },
{ "phone": "+255714567890", "external_id": "01HZ...", "success": true, "cost": 25, "segments": 1, "status": "delivered" }
]
}
Look up a message by the external_id returned from /sms/send. The numeric message_id from the same response also works.
{
"success": true,
"message": {
"external_id": "01HXY...",
"phone": "+255712345678",
"network": "Vodacom",
"status": "delivered",
"cost": 25,
"segments": 1,
"sent_at": "2026-08-10T10:23:00Z",
"delivered_at": "2026-08-10T10:23:45Z"
}
}
If the id doesn't exist or belongs to another account: HTTP 404 not_found.
Calculate segments, network, and cost for a message without sending it. Useful for live cost previews in the UI.
{
"to": "+255712345678",
"message": "Hello world"
}
{
"success": true,
"to": "+255712345678",
"network": "Vodacom",
"segments": 1,
"characters": 11,
"cost": 25,
"currency": "TZS"
}
{
"success": true,
"currency": "TZS",
"base_rate": 25,
"tiers": [
{
"id": 1,
"tier_name": "Starter",
"slug": "starter",
"min_quantity": 100,
"max_quantity": 999,
"cost_per_sms": 25,
"savings_percent": 0
},
{
"id": 2,
"tier_name": "Growth",
"slug": "growth",
"min_quantity": 1000,
"max_quantity": 9999,
"cost_per_sms": 23,
"savings_percent": 8
}
]
}
base_rate is the default per-SMS cost in SMSm credits (TZS) for volumes below the first tier.
{
"success": true,
"account": {
"name": "Jane Doe",
"email": "jane@example.com",
"company": "Acme Ltd",
"country": "TZ"
},
"credits": {
"remaining": 600,
"currency": "TZS"
}
}
Create a new Nuntio account. Returns a Sanctum bearer token. Rate limited to 3 per IP per minute.
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | Yes | Max 80 chars. |
last_name | string | Yes | Max 80 chars. |
email | string | Yes | Valid email, unique, max 160 chars. |
phone | string | Yes | Max 20 chars. |
password | string | Yes | Min 8 chars, mixed case, at least one number. Must be confirmed with password_confirmation. |
company | string | No | Max 160 chars. |
country | string | No | 2-letter ISO code, default TZ. |
{
"success": true,
"message": "Account created successfully.",
"user": { "id": 1, "name": "Jane Doe", "email": "jane@example.com" },
"token": "2|xyz789..."
}
Rate limited to 5 attempts per email+IP per minute.
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address. |
password | string | Yes | Plain password. |
device_name | string | No | Label for this token (e.g. "work-laptop"). Max 120 chars. |
remember | bool | No | Reserved. |
Returns the same shape as /auth/register. On failure, returns 422 validation_error with {"errors":{"email":["Invalid email or password."]}}.
Revokes the bearer token used to make the request. Other tokens on the same account are not affected.
Returns the authenticated user and wallet summary.
{
"success": true,
"user": { "id": 1, "name": "Jane Doe", "email": "jane@example.com", "role": "user", "two_factor_enabled": false },
"wallet": { "sms_credits_remaining": 600, "lifetime_spend": 2500, "lifetime_topup": 20000 }
}
Aggregated stats, charts, and recent activity for the dashboard home screen.
{
"success": true,
"stats": { "messages_sent": 1234, "delivered": 1180, "delivery_rate": 95.6, "credits_remaining": 600 },
"charts": { "daily_volume": [...], "network_distribution": [...] },
"recent": { "campaigns": [...], "messages": [...], "invoices": [...] }
}
Query parameters: q (search), status (active/inactive), opted_in (bool), group_id (int), per_page (default 25, max 100), page.
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Yes | Max 20 chars. Must be unique per account. |
first_name, last_name | string | No | Max 80 chars each. |
email | string | No | Valid email. |
company | string | No | Max 160 chars. |
city, region, country | string | No | Free text. |
gender | string | No | One of male, female, other. |
date_of_birth | date | No | ISO date. |
is_opted_in | bool | No | Default true. Required to receive SMS. |
source | string | No | Provenance tag, e.g. "csv_import". |
custom_fields | object | No | Free-form key/value store. |
group_ids | int[] | No | Group ids to attach the contact to. |
Update accepts the same fields as POST /contacts (all optional). Both PUT and DELETE are scoped to the authenticated user's contacts — passing another user's id returns 404 not_found.
Two accepted formats:
JSON body — up to 10,000 rows:
{
"contacts": [
{ "phone": "+255712345678", "first_name": "Jane", "last_name": "Doe" },
{ "phone": "+255713456789", "email": "bob@example.com" }
],
"skip_duplicates": true
}
Multipart CSV upload — first row must be the header, max 5 MB, up to 10,000 rows. Recognised headers (case-insensitive): first_name, last_name, phone, email, company. phone is required.
curl -X POST https://nuntio.africa/api/contacts/bulk-import \
-H "Authorization: Bearer ..." \
-F "csv=@/path/to/contacts.csv" \
-F "skip_duplicates=true"
Response:
{
"success": true,
"imported": 487,
"skipped": 12,
"errors": []
}
{ "reason": "User requested via SMS" }
Sets is_opted_in = false, status = "opted_out", records timestamp and reason. The contact will be excluded from future campaigns and group sends.
Group payload: name (required, max 120), description (max 500), color (max 20), icon (max 50). The list endpoint returns each group with a contacts_count.
Template fields: name (required, max 120), category (max 80), content (required, max 1,600), variables (array of placeholder names), is_shared (bool — when true, the template is visible to all accounts on the same plan).
Query parameters: status (queued/sent/delivered/failed), phone (substring match), per_page (default 25, max 100).
Throttled to 30 sends per IP per minute. Same fields as the v1 endpoint, plus campaign_name (max 160) and schedule_at (ISO datetime in the future). callback_url must be SSRF-safe.
Returns segments, network, and per-recipient cost without sending.
Query: status, per_page (default 20, max 100).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Max 160. |
message | string | Yes | Max 1,600. |
type | string | Yes | sms, mms, or whatsapp. |
sender_id | string | No | Max 11 chars. |
contact_ids | int[] | No | Specific contact ids. |
group_ids | int[] | No | Group ids — all opted-in contacts in those groups are merged in. |
template_id | int | No | Template whose usage_count will be incremented. |
schedule_at | datetime | No | Future ISO datetime. When set, the campaign is created with status scheduled. |
callback_url | string | No | Delivery status URL. |
All endpoints accept a days parameter (default 30).
Generate a PDF report. Fields: name, type (delivery / campaign / engagement / billing / network / compliance), date_from, date_to (after or equal to date_from), format (pdf), frequency (once / daily / weekly / monthly). Reports expire after 30 days.
Lists all your keys. The hashed_secret is never returned. Each row shows secret_preview (last 4 chars), current scopes, rate_limit_per_minute, last-used timestamp and IP.
Body: name (required, max 120), expires_at (optional). Returns the new key plus plain_secret — the secret is shown exactly once. Store it immediately.
{
"success": true,
"message": "API key created.",
"api_key": { "id": 7, "key_id": "spk_live_abc...", "secret_preview": "wxyz", "scopes": ["sms:send","sms:read"], "rate_limit_per_minute": 60 },
"plain_secret": "sps_live_this-is-shown-only-once-abc123def456..."
}
Update name, scopes (array of strings), rate_limit_per_minute (1–10,000), or daily_limit (1+).
Sets revoked_at. The key stops authenticating immediately but the row is preserved for audit.
Permanently deletes the key. Revoke first if you need to keep the audit trail.
Nuntio can POST delivery status updates to a URL you control. Configure the URL and event subscriptions from the dashboard or the API below.
Body: name, url (must be public http(s) — SSRF-protected), environment (production / sandbox), events (array, e.g. ["sms.delivered","sms.failed"]). On create, returns the webhook row plus plain_secret shown once.
Throttled to 5 per user+IP per minute. Sends a synthetic webhook.test payload to the registered URL and returns the delivery result (HTTP status, latency, error if any).
Fields: type (card / mpesa / tigopesa / bank / crypto), brand, last_four, exp_month, exp_year, phone (mobile money), bank_name, account_last4, token (gateway token — never returned in API responses), is_default. Setting a new default demotes the previous one in a single DB transaction.
Returns lifetime spend, lifetime top-up, remaining credits, and the latest 20 wallet transactions.
Body: enabled (bool), threshold (TZS, ≥ 0), amount (TZS, ≥ 100). When enabled, the wallet auto-purchases amount SMSm credits when the credits drop below threshold..
Query: per_page (default 25, max 100).
Quote body: credits (int ≥ 1). Returns the matched tier, rate, subtotal, total, and savings vs. base rate.
{ "credits": 5000 }
{
"success": true,
"credits": 5000,
"currency": "TZS",
"rate_per_sms": 22,
"subtotal": 110000,
"total": 110000,
"savings_vs_base": 15000,
"tier": { "id": 2, "name": "Growth", "min_quantity": 1000, "max_quantity": 9999, "savings_percent": 12 }
}
| Field | Type | Required | Description |
|---|---|---|---|
credits | int | Yes | 1 – 1,000,000. |
phone | string | Yes | Mobile money phone (max 20 chars). |
customer_name | string | No | Max 100 chars. Defaults to the user's full name. |
Returns the created CreditPurchase and a poll_url. The user approves the USSD prompt on their phone. On callback, the wallet is credited and the purchase is marked completed. If FastLipa is not configured, the response includes "configured": false and the purchase stays pending.
Polling this endpoint while the purchase is pending will also poll FastLipa for a fresh status and update the row accordingly.
Only works on pending purchases. Returns 422 otherwise.
Body: any of first_name, last_name, phone, company, job_title, bio, country, timezone, locale. Upload an avatar as avatar in multipart/form-data (image, max 2 MB).
Body: current_password (required), email (optional, must be unique), new_password (optional, must be confirmed, mixed case, ≥ 8 chars, ≥ 1 number). Changing the password revokes all other Sanctum tokens for the account.
Body: any of sms_verification_enabled, low_balance_alert, delivery_report, campaign_completion, marketing_emails.
Heads up. The 2FA toggle currently flips a flag on the user row but does not enroll a TOTP secret. Use a password manager and a strong unique password until a TOTP library is wired.
Body: password (required). Marks the account inactive, revokes all Sanctum tokens, then deletes the user row. The associated wallet, contacts, and messages are removed by cascade.
Request a custom alphanumeric Sender ID (e.g. MYSHOP) instead of the default. Reviewed manually by the Nuntio team.
Create body: sender_name (3–11 alphanumeric chars), sample_message (max 320), business_justification (max 500). Only one open (pending / under-review) request per account.
When a subscribed event fires, Nuntio POSTs a JSON payload to your URL. Every request is signed — see Signing & verifying below.
sms.sentThe message was accepted by the SMS network. Delivery confirmation follows as sms.delivered or sms.failed.
{
"event": "sms.sent",
"occurred_at": "2026-08-10T10:23:40Z",
"data": {
"external_id": "01HXY...",
"phone": "255712345678",
"network": "Vodacom",
"status": "sent",
"segments": 1,
"cost": 25
}
}
sms.delivered{
"event": "sms.delivered",
"occurred_at": "2026-08-10T10:23:45Z",
"data": {
"external_id": "01HXY...",
"phone": "+255712345678",
"network": "Vodacom",
"segments": 1,
"cost": 25,
"delivered_at": "2026-08-10T10:23:45Z"
}
}
sms.failed{
"event": "sms.failed",
"occurred_at": "2026-08-10T10:23:50Z",
"data": {
"external_id": "01HXY...",
"phone": "+255712345678",
"error_code": "DELIVERY_TIMEOUT",
"error_message": "Provider did not confirm delivery within 60 seconds."
}
}
Every outgoing webhook includes an X-Webhook-Signature header containing an HMAC-SHA256 of the raw request body, hex-encoded, computed with your webhook's signing secret. Verify server-side using constant-time comparison.
The plain webhook secret is shown exactly once when the webhook is created. If you lose it, you must delete the webhook and create a new one.
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
$provided = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $provided)) {
http_response_code(401);
exit;
}
$payload = json_decode($rawBody, true);
// ... process $payload['event'] / $payload['data']
const crypto = require('crypto');
app.post('/webhooks/smspesa', (req, res) => {
const raw = req.rawBody; // capture raw body in your middleware
const expected = crypto
.createHmac('sha256', process.env.SMSPESA_WEBHOOK_SECRET)
.update(raw)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers['x-webhook-signature'] || '')
)) {
return res.status(401).send();
}
const { event, data } = JSON.parse(raw);
// ... process
res.status(200).send();
});
import hmac, hashlib
from flask import request, abort
@app.post('/webhooks/smspesa')
def handle():
raw = request.get_data()
expected = hmac.new(
WEBHOOK_SECRET.encode(),
raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Webhook-Signature', '')):
abort(401)
payload = request.get_json()
# ... process payload['event'] / payload['data']
return '', 200
// Install: composer require smspesa/smspesa-php
$client = new \SmsPesa\Client('YOUR_API_KEY', 'YOUR_API_SECRET');
$result = $client->send([
'to' => '+255712345678',
'message' => 'Hello from Nuntio!',
]);
echo $result['external_id'];
const res = await fetch('https://nuntio.africa/api/v1/sms/send', {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'X-API-Secret': 'YOUR_API_SECRET',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
to: '+255712345678',
message: 'Hello from Nuntio!',
}),
});
const json = await res.json();
console.log(json.external_id);
import requests
res = requests.post(
'https://nuntio.africa/api/v1/sms/send',
headers={
'X-API-Key': 'YOUR_API_KEY',
'X-API-Secret': 'YOUR_API_SECRET',
'Accept': 'application/json',
},
json={'to': '+255712345678', 'message': 'Hello!'},
)
print(res.json()['external_id'])
curl -X POST https://nuntio.africa/api/v1/sms/send \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-API-Secret: YOUR_API_SECRET" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"to":"+255712345678","message":"Hello from Nuntio!"}'
Need help? Email nuntio@nemotech.africa or chat with us in-app. We respond within an hour during business days.