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