Any language (raw HTTP)

There is one SDK, and it is Node. Everything it does is plain HTTP against /v1/*, so a Python, Go, PHP or Ruby consumer loses nothing but the types. This page is the whole contract.

The request

Header Value When
Authorization Bearer sk_… — or Bearer <sessionId> for a user session Always
x-api-key sk_… Alternative to Authorization for service keys
X-Axis-Tenant ws_<accountsUserGroupId> Every tenant-scoped route. Omit it only to reach the operator surface
Content-Type application/json Whenever you send a body
Idempotency-Key A UUID Every POST, PUT, PATCH, DELETE — see below

Do not sign your own requests. Authentication is Bearer (or x-api-key) and nothing else. X-Axis-Signature and X-Axis-Timestamp are headers Inbox sends to you on outbound webhook deliveries; they are not part of the inbound contract.

INBOX=https://inbox.example.com
KEY=sk_live_…
TENANT=ws_01J8Z…
AUTH=(-H "authorization: Bearer $KEY" -H "x-axis-tenant: $TENANT")

Worked examples

Confirm your credential resolves

curl -sS "${AUTH[@]}" $INBOX/v1/whoami
{
  "data": { "appId": "cmq…", "mode": "bearer", "scopes": ["inbox","connections"], "tenantId": "cmr…" },
  "meta": { "requestId": "5f2c…" }
}

Do this first, always. A key that authenticates but resolves the wrong app, or a tenant that silently provisioned empty, both look like success until someone wonders where the data went.

List threads

curl -sS "${AUTH[@]}" "$INBOX/v1/inbox/threads?status=open&limit=25"

Reply

curl -sS "${AUTH[@]}" \
  -H 'content-type: application/json' \
  -H "idempotency-key: $(uuidgen)" \
  -X POST "$INBOX/v1/inbox/threads/$THREAD_ID/reply" \
  -d '{"message":"On it — thanks for waiting."}'

The field is message, not body. Optional fields: subject, html, plainText, attachmentUrl, attachmentType, quickReplies.

Send a template

The only way to open a conversation with someone who has not messaged you. A free-form reply outside the 24-hour window returns messaging_window_expired (422).

curl -sS "${AUTH[@]}" \
  -H 'content-type: application/json' \
  -H "idempotency-key: $(uuidgen)" \
  -X POST "$INBOX/v1/connections/$CONNECTION_ID/send-template" \
  -d '{
        "phone": "254712345678",
        "template": { "name": "appointment_reminder", "language": "en" }
      }'

Responses

Success:

{ "data": … , "meta": { "requestId": "5f2c…" } }

Error:

{ "error": { "code": "insufficient_scope", "message": "The credential lacks the required scope.", "requestId": "5f2c…" } }

error may also carry rateLimit (on a 429) and details. Every response carries an x-request-id header echoing meta.requestId; quote it when reporting a problem.

Unwrap on the presence of data, not its truthiness. {"data": null} is a legitimate answer from several endpoints, and treating it as "no envelope" hands you the whole object. In Python:

payload = response.json()
data = payload["data"] if isinstance(payload, dict) and "data" in payload else payload

The full code list is on Error codes.

Idempotency

Idempotency-Key is required by HTTP verb, not per route. Every POST, PUT, PATCH and DELETE needs one unless the route opts out — including several POSTs that only read (/v1/templates/verify-media-url, the /v1/email-domains/* and /v1/email-validation/* routes, the flow simulators, /v1/realtime/tokens). Miss it and you get idempotency_key_required (400) before the handler runs.

Reuse the same key to replay: you get the first call's body and its original status code. Reuse it with a different body and you get idempotency_conflict (409). Reservations live 24 hours. Details on Idempotency.

Pagination

Two shapes, and which you get depends on the route.

Cursor — the tenant-scoped lists. Default 25, max 100.

{ "data": [...], "meta": { "nextCursor": "MTcxOTg0…" } }

Pass it back as ?cursor=…. The cursor is a base64url epochMs:id; treat it as opaque. A null or absent nextCursor means you are at the end.

Offset — the operator lists. Default 50, max 200. (GET /v1/operator/tenants is the one exception, allowing up to 1000; it still defaults to 50.)

{ "data": [...], "meta": { "page": 1, "limit": 50, "total": 4212, "hasMore": true } }

total spans the whole filtered dataset, not the page. If meta is missing entirely, treat it as a fault — do not assume one page is the whole set.

Retries

Retry on 429 and 5xx only. On a 429, honour Retry-After but cap it — the SDK caps at 60 seconds, and you should too, or a misconfigured proxy answering Retry-After: 86400 parks your worker for a day. Otherwise back off exponentially.

Crucially: reuse the same Idempotency-Key across retries of the same logical write. That is what makes a retry a replay rather than a duplicate. Generate the key once, outside your retry loop.

The rate limit is 6000 requests per 60 seconds per credential. See Rate limits.

Verifying an outbound webhook without the SDK

Inbox signs every outbound delivery with HMAC-SHA256 over the canonical string ${timestamp}.${rawBody}, sent as sha256=<hex>:

Header Contents
x-axis-timestamp The timestamp used in the canonical string
x-axis-signature sha256=<hex>

The secret is returned exactly once, when you register the endpoint with POST /v1/webhooks. Store it then; it is never returned again.

import hmac, hashlib

def verify(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
    canonical = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), canonical, hashlib.sha256).hexdigest()
    provided = signature if signature.startswith("sha256=") else f"sha256={signature}"
    return hmac.compare_digest(expected, provided)

Or in shell:

CANONICAL="${TIMESTAMP}.${RAW_BODY}"
EXPECTED="sha256=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"
[ "$EXPECTED" = "$SIGNATURE" ] && echo ok

Two rules that account for most verification failures:

  1. Use the raw body bytes as received. Parsing the JSON and re-serialising it changes key order and whitespace, which changes the bytes, which changes the digest. Capture the body before your framework touches it.
  2. Compare in constant time. hmac.compare_digest, not ==.

Inbound provider webhooks (Meta, Infobip and the rest) use each provider's own scheme, not this one — see Signature verification.