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

#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:

  1. 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.
  2. 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.
  3. Handle 403 number_not_activated as 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

#Limits and behaviour

#Changelog