#Suki Inbox API
Suki Inbox is the messaging platform for Suki Africa products. You hand it a message for a customer; it picks a provider (currently WhatsApp via Zernio), sends it, tracks delivery, retries transient failures, and enforces opt-in so we never message a number that has not messaged us first.
- Base URL:
https://inbox.suki.co.tz - Format: JSON request and response bodies, UTF-8. All times are ISO 8601 UTC.
- Dashboard:
https://inbox.suki.co.tz(password protected) shows every message, its timeline, and the opt-in state of every number.
#Quick start
# 1. Is the customer allowed to receive messages?
curl "https://inbox.suki.co.tz/api/contacts?msisdn=%2B255712345678" \
-H "Authorization: Bearer $INBOX_API_KEY"
# 2. Send a till receipt
curl -X POST https://inbox.suki.co.tz/api/messages \
-H "Authorization: Bearer $INBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"kind": "transaction",
"to": { "msisdn": "+255712345678" },
"idempotencyKey": "TX-8F3A21",
"content": {
"type": "template",
"templateId": "till_receipt_v1",
"params": ["R-000123","Selcom","Mama Ntilie","T-00421","Asha Juma","0712345678","Vodacom","TX-8F3A21","26/08/2026 15:30","25,000"]
}
}'
Response 202 Accepted:
{ "id": "772997aa-0542-4485-8ae4-aa86460b318a", "status": "queued", "deduplicated": false, "bypassed": false }
The message is now in the outbox. A worker sends it within about one second; delivery receipts arrive over the following seconds and are visible in the dashboard and via GET /api/messages.
#Authentication
Every /api/* endpoint except /api/health requires an API key. Keys are issued per integrating system by the Inbox administrator and are shown once at creation.
Send it as a bearer token (preferred) or in X-API-Key:
Authorization: Bearer sk_inbox_…
| Result | Meaning |
|---|---|
401 {"error":"unauthorized"} |
Missing, unknown, or revoked key. |
Keep keys server-side. Never embed them in mobile apps or browser code.
#Opt-in: the rule that protects our numbers
WhatsApp bans numbers that message people who did not ask to be messaged. Suki Inbox therefore enforces one rule:
A number can only receive messages after it has sent us a WhatsApp message.
Lifecycle of a number:
| State | How it gets there | Can we send? |
|---|---|---|
| not activated | Default. We have never heard from this number. | No — 403 number_not_activated |
| active | The number sent any message to our WhatsApp line. Happens automatically, usually within seconds. | Yes |
| opted out | The number replied STOP, unsubscribe, acha or sitaki. |
Never, not even with a bypass |
| blocked | An administrator blocked it in the dashboard. | No |
What this means for your integration:
- Ask the customer to message the line first. For example, at till registration: "Send Hi to +1 573 601 7179 on WhatsApp to receive your receipts." As soon as the message lands, the number is active.
- Check before you build the receipt with
GET /api/contacts?msisdn=…if you want to avoid a rejected call. This is optional; sending to an inactive number is safe, it is just refused. - Handle
403 number_not_activatedas a normal, expected outcome, not an error to retry. Log it, and if you have a UI, tell the operator the customer has not opted in. Every refused attempt is recorded and visible under Contacts → Not activated in the dashboard so the team can follow up.
Bypassing opt-in is possible only for keys explicitly flagged by the administrator, and every bypass is audited with the actor and reason. Ask for it only for genuinely exceptional flows.
#Endpoints
#POST /api/messages — queue a message
Queues one outbound message. Returns immediately; nothing waits on the provider.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel |
"whatsapp" |
yes | Only WhatsApp today. sms and email are reserved. |
kind |
string | no | transaction (default when omitted: notification), notification, marketing, reply. Used for reporting. |
to.msisdn |
string | yes | Recipient in E.164: + then 9–15 digits, e.g. +255712345678. Local formats like 0712… are rejected. |
to.customerId |
string | no | Your own customer id, stored with the message for reconciliation. |
content |
object | yes | See Content types below. |
idempotencyKey |
string | strongly recommended | Your unique id for this message, e.g. the transaction id. Same key twice = one message. Max 255 chars. |
metadata |
object | no | Any JSON you want stored with the message (order id, branch, etc.). Returned on GET. |
bypassOptIn |
{ "reason": string } |
no | Only honoured for keys with bypass permission; otherwise 403 bypass_not_permitted. |
Content types
Template (the normal case — required for any message the customer did not just prompt):
{ "type": "template", "templateId": "till_receipt_v1", "params": ["…", "…"] }
params is a flat array of strings in the order listed in the Templates section. All values are strings; format numbers and dates yourself ("25,000", "26/08/2026 15:30"). Wrong count → PERMANENT failure, visible in the dashboard.
Plain text (only deliverable inside WhatsApp's 24-hour window after the customer's last message):
{ "type": "text", "body": "Karibu! Your till is ready." }
Media is reserved and currently refused by the WhatsApp provider.
Responses
| Status | Body | Meaning |
|---|---|---|
202 |
{ id, status: "queued", deduplicated: false, bypassed } |
Queued. id is the message id for tracking. |
200 |
{ id, status, deduplicated: true, … } |
A message with this idempotencyKey already exists; nothing new was queued. id and status are of the existing message. |
400 |
{ error: "invalid json" } |
Body was not valid JSON. |
401 |
{ error: "unauthorized" } |
Bad API key. |
403 |
{ error: "number_not_activated", address, hint } |
Recipient has not messaged us. Not queued; attempt recorded. |
403 |
{ error: "number_opted_out", address } |
Recipient replied STOP. Do not try again. |
403 |
{ error: "number_blocked", address } |
Blocked by an administrator. |
403 |
{ error: "bypass_not_permitted" } |
Your key may not use bypassOptIn. |
422 |
{ error: "<validation message>" } |
A field is missing or malformed; the message says which. |
Validation messages you may see: channel must be whatsapp|sms|email, to.msisdn or to.email required, to.msisdn must be E.164 (+255…), content required, content.templateId and string[] params required, content.body required, idempotencyKey must be a string, bypassOptIn must be { reason }.
#GET /api/messages — recent messages and counts
Returns the 50 most recent messages for your tenant plus counts by status.
{
"stats": { "queued": 0, "sent": 2, "delivered": 40, "read": 31, "failed": 1 },
"messages": [
{
"id": "772997aa-…", "channel": "whatsapp", "kind": "transaction",
"recipient": { "msisdn": "+255745652666" },
"content": { "type": "template", "templateId": "till_receipt_v1", "params": ["…"] },
"metadata": { "caller": "api:tills" },
"status": "read", "provider_id": "zernio", "provider_message_id": "wamid.HBg…",
"attempts": 1, "last_error": null,
"created_at": "2026-08-26T12:23:42.126Z", "updated_at": "2026-08-26T12:24:10.001Z"
}
]
}
Use it for reconciliation dashboards. For one message, use the per-id endpoint below.
#GET /api/messages/{id} — one message with its timeline
Look up the id returned by POST /api/messages.
{
"id": "772997aa-…", "status": "read", "channel": "whatsapp", "kind": "transaction",
"recipient": { "msisdn": "+255745652666" }, "contactName": "Chui Shopping",
"content": { "type": "template", "templateId": "till_receipt_v1", "params": ["…"] },
"idempotencyKey": "TX-8F3A21:receipt", "metadata": { "caller": "api:core-service" },
"provider": "zernio", "providerMessageId": "wamid.HBg…", "attempts": 1, "lastError": null,
"createdAt": "2026-08-26T12:23:42.126Z", "updatedAt": "2026-08-26T12:24:10.001Z",
"timeline": [
{ "status": "queued", "at": "2026-08-26T12:23:42.126Z", "provider": null, "error": null },
{ "status": "sent", "at": "2026-08-26T12:23:44.516Z", "provider": "zernio", "error": null },
{ "status": "delivered", "at": "2026-08-26T12:23:57.574Z", "provider": "zernio", "error": null }
]
}
404 if the id does not exist or belongs to another tenant. Poll no more than once every few seconds; delivery typically lands within 10 s, read whenever the customer opens it.
#GET /api/contacts?msisdn=+255… — opt-in status of a number
{ "address": "+255712345678", "active": true, "status": "active", "activatedAt": "2026-08-26T09:12:00.000Z", "source": "inbound" }
status is one of active, not_activated, opted_out, blocked. source is inbound (the customer messaged us), manual (administrator), or test. Remember to URL-encode the + as %2B.
#GET /api/health — liveness
No auth. 200 when the database is reachable and the background worker has reported in within the last 60 s; otherwise 503 with a reason:
{ "ok": true, "db": "up", "worker": { "lastBeatAgeSeconds": 3, "stale": false }, "ts": "…" }
Point your uptime monitor at it — a 503 means messages are not being sent even if the website is up.
#Message lifecycle
queued ──▶ sending ──▶ sent ──▶ delivered ──▶ read
│ │
│ └──▶ queued (retry scheduled) ──▶ … ──▶ failed
└──────────────────────────────────────────────────▶ failed
| Status | Meaning |
|---|---|
queued |
In the outbox, waiting for the worker (or for its next retry time). |
sending |
A worker has claimed it and is calling the provider. |
sent |
The provider accepted it and returned a provider message id. |
delivered |
WhatsApp reports it reached the customer's phone. |
read |
The customer opened it (blue ticks). |
failed |
Given up. last_error explains why. |
Statuses only move forward: a late delivered receipt will not downgrade a message that is already read.
Retries. Provider errors are normalised into a small set and handled automatically:
| Error class | Behaviour |
|---|---|
RETRYABLE (timeouts, 5xx, network) |
Exponential backoff, up to 6 attempts over ~20 minutes. |
RATE_LIMITED |
Same, with a longer wait. |
UNSUPPORTED |
Immediately tried on the next configured provider, if any. |
INVALID_RECIPIENT (not on WhatsApp, opted out at Meta, malformed) |
Fails immediately. |
PERMANENT (bad template, bad params, auth) |
Fails immediately. |
You never need to retry a 202. If your own call to POST /api/messages timed out, simply repeat it with the same idempotencyKey.
#Idempotency
idempotencyKey is scoped to your tenant. Sending the same key again returns 200 with deduplicated: true and the original message's id — even if the content differs. Use a key that is unique per message, not per customer: a transaction id is ideal; if one transaction can produce several messages (receipt + reminder), suffix it (TX-8F3A21:receipt).
#Templates
WhatsApp requires pre-approved templates for business-initiated messages. Each template has a fixed number of positional parameters. All values are strings.
#till_receipt_v1 — till payment receipt
Approved name on WhatsApp: kinetic_receipt (UTILITY, English). 10 parameters, header first:
| # | Name | Example | Notes |
|---|---|---|---|
| 1 | receiptNo | R-000123 |
Appears in the header: Kin Pay | Receipt no R-000123 |
| 2 | tillNetwork | Selcom |
|
| 3 | tillName | Mama Ntilie |
|
| 4 | tillNumber | T-00421 |
|
| 5 | payerName | Asha Juma |
Rendered bold |
| 6 | payerMsisdn | 0712345678 |
Display only; any format |
| 7 | payerNetwork | Vodacom |
|
| 8 | transId | TX-8F3A21 |
|
| 9 | time | 26/08/2026 15:30 |
Pre-formatted, local time |
| 10 | amount | 25,000 |
Pre-formatted, no currency (template adds Tsh) |
Rendered message:
*Kin Pay | Receipt no R-000123*
Till Network - Selcom
Mama Ntilie - T-00421
---------- *Payer* ----------
Name : *Asha Juma*
MSISDN : 0712345678
Network : Vodacom
Trans ID : TX-8F3A21
Time : 26/08/2026 15:30
------------------------
Tsh *25,000*
------------------------
Need another template? Ask the Inbox administrator: it must be approved with WhatsApp first, then registered in the platform; you will receive its templateId and parameter list.
#Code examples
#Node.js
const INBOX = "https://inbox.suki.co.tz";
const headers = { authorization: `Bearer ${process.env.INBOX_API_KEY}`, "content-type": "application/json" };
export async function sendReceipt(tx) {
const res = await fetch(`${INBOX}/api/messages`, {
method: "POST", headers,
body: JSON.stringify({
channel: "whatsapp", kind: "transaction",
to: { msisdn: tx.payerMsisdnE164, customerId: tx.customerId },
idempotencyKey: `${tx.id}:receipt`,
content: { type: "template", templateId: "till_receipt_v1",
params: [tx.receiptNo, tx.tillNetwork, tx.tillName, tx.tillNumber, tx.payerName, tx.payerMsisdn, tx.payerNetwork, tx.id, tx.timeLocal, tx.amountFormatted] },
}),
});
const body = await res.json();
if (res.status === 403 && body.error === "number_not_activated") return { skipped: "not_activated" };
if (!res.ok) throw new Error(`inbox ${res.status}: ${body.error}`);
return { messageId: body.id, deduplicated: body.deduplicated };
}
#PHP
function sendReceipt(array $tx): array {
$payload = [
'channel' => 'whatsapp', 'kind' => 'transaction',
'to' => ['msisdn' => $tx['payerMsisdnE164']],
'idempotencyKey' => $tx['id'] . ':receipt',
'content' => ['type' => 'template', 'templateId' => 'till_receipt_v1',
'params' => [$tx['receiptNo'], $tx['tillNetwork'], $tx['tillName'], $tx['tillNumber'], $tx['payerName'],
$tx['payerMsisdn'], $tx['payerNetwork'], $tx['id'], $tx['timeLocal'], $tx['amountFormatted']]],
];
$ch = curl_init('https://inbox.suki.co.tz/api/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('INBOX_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($status === 403 && ($body['error'] ?? '') === 'number_not_activated') return ['skipped' => 'not_activated'];
if ($status >= 400) throw new RuntimeException("inbox $status: " . ($body['error'] ?? '?'));
return ['messageId' => $body['id'], 'deduplicated' => $body['deduplicated']];
}
#Python
import os, requests
def send_receipt(tx):
r = requests.post("https://inbox.suki.co.tz/api/messages", timeout=10,
headers={"Authorization": f"Bearer {os.environ['INBOX_API_KEY']}"},
json={"channel": "whatsapp", "kind": "transaction", "to": {"msisdn": tx["payer_msisdn_e164"]},
"idempotencyKey": f"{tx['id']}:receipt",
"content": {"type": "template", "templateId": "till_receipt_v1",
"params": [tx["receipt_no"], tx["till_network"], tx["till_name"], tx["till_number"], tx["payer_name"],
tx["payer_msisdn"], tx["payer_network"], tx["id"], tx["time_local"], tx["amount_formatted"]]}})
body = r.json()
if r.status_code == 403 and body.get("error") == "number_not_activated":
return {"skipped": "not_activated"}
r.raise_for_status()
return {"message_id": body["id"], "deduplicated": body["deduplicated"]}
#Integration checklist
- Store the API key in server-side configuration, never in client code.
- Convert phone numbers to E.164 (
0712345678→+255712345678) before calling. - Use a unique
idempotencyKeyper message, derived from your transaction id. - Treat
403 number_not_activatedas a normal outcome; show the operator how the customer can opt in. - Never retry on
4xx; retry on network failure or5xxwith the sameidempotencyKey. - Call with a short timeout (5–10 s); the endpoint only writes to a queue.
- Watch
https://inbox.suki.co.tz/api/healthfrom your monitoring.
#Limits and behaviour
- No hard rate limit today; be reasonable (bursts of a few hundred per minute are fine). WhatsApp itself limits new numbers to a daily conversation cap that grows with quality.
- Plain-text messages outside the 24-hour customer-service window will be rejected by WhatsApp — use a template.
- Messages are retained indefinitely with their full delivery timeline.
- One tenant per API key; you only ever see your own messages and contacts.
#Changelog
- 2026-08-26 —
GET /api/messages/{id};/api/healthnow reflects worker liveness. - 2026-08-26 — Initial public API:
POST/GET /api/messages,GET /api/contacts,GET /api/health; opt-in enforcement; templatetill_receipt_v1(10 params, header + body).