Overview
Every call is HTTPS, takes and returns JSON, and carries an API key as a bearer token. There is no SDK to install: an HTTP client you already have is enough.
- Sending is asynchronous.
POST /smsanswers202 Acceptedonce the gateway owns the message. It never calls the mobile operator inside your request, so your checkout or signup never waits on a telecom. - Outcomes arrive by webhook (§5), or you read them back (§4).
- Retries are safe.
Idempotency-Keyis required on every send, so a timeout and a retry cannot charge your customer twice (§3). - You can build before anything is approved. A test key runs the whole gateway and stops short of the operator (§6).
What you need before you start
| Thing | How you get it |
|---|---|
| A JamboSender account | Register; an administrator approves the organisation. |
| An API key | Yourself, in the portal under API keys (§1). |
| An approved sender name | Request it in the portal. Registration with the operators takes a few days, and a test key does not wait for it. |
| Credits | Top up in the portal. One credit is one SMS part (§7). |
Building software that sends for many organisations rather than only your own? Read §10 as well — you get a partner key and provision a key per organisation instead of pasting keys by hand.
1. Keys and authentication
Sign in to the portal, open API keys and create one. The token is shown once — only a hash is stored, so it cannot be recovered. Lose it and you revoke it and make another, or regenerate it.
The prefix tells you which kind you are holding:
jsk_test_a1b2c3d4e5f6XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ← simulates, charges nothing
jsk_live_a1b2c3d4e5f6XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ← sends for real
Build against a test key first (§6). Going live is a token swap; nothing else in your code changes.
The header
Authorization: Bearer jsk_test_a1b2c3d4e5f6…
Keep the token on your server. It can spend the organisation's credits, so it does not belong in a mobile app, a browser bundle, or a git repository.
Two limits make a leaked key cost less. A daily credit cap you set yourself: enter
one when you create the key on API keys, and the key cannot spend more than that in
a day (429 daily_cap_exceeded). An IP allowlist, which accepts the key
only from your servers' addresses, we set on request.
Scopes
Each key carries scopes, and a call outside them gets 403 insufficient_scope.
| Scope | Allows |
|---|---|
sms:send | POST /sms, POST /sms/estimate |
sms:read | GET /sms/{id}, GET /messages |
balance:read | GET /balance |
sender_names:read | GET /sender-names |
partner:provision | /partner/* — provisioning only, never sending (§10) |
Every endpoint is limited to 120 requests a minute per key by default.
2. Send a message
curl https://portal.jambosender.com/api/v1/sms \
-H "Authorization: Bearer $JAMBOSENDER_KEY" \
-H "Idempotency-Key: receipt-4821" \
-H "Content-Type: application/json" \
-d '{
"to": ["255713965677"],
"sender_id": "DUKA LA ALI",
"message": "Asante kwa manunuzi. Risiti #4821. Jumla 15,000 TZS.",
"reference": "receipt-4821",
"callback_url": "https://your-app.example/webhooks/sms"
}'
$response = Http::withToken(config('services.jambosender.key'))
// Derived from the receipt, so a retry is the same request.
->withHeader('Idempotency-Key', 'receipt-'.$receipt->id)
->acceptJson()
->post('https://portal.jambosender.com/api/v1/sms', [
'to' => $receipt->customer_phone,
'sender_id' => 'DUKA LA ALI',
'message' => $text,
'reference' => 'receipt-'.$receipt->id,
]);
if ($response->status() === 402) {
// Out of credits: tell the merchant rather than losing the receipt.
NotifyMerchantToTopUp::dispatch($receipt->tenant);
}
const response = await fetch('https://portal.jambosender.com/api/v1/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.JAMBOSENDER_KEY}`,
// Derived from the receipt, so a retry is the same request.
'Idempotency-Key': `receipt-${receipt.id}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: receipt.customerPhone,
sender_id: 'DUKA LA ALI',
message: text,
reference: `receipt-${receipt.id}`,
}),
});
const result = await response.json();
if (response.status === 402) await notifyMerchantToTopUp(receipt.tenantId);
import os, requests
response = requests.post(
"https://portal.jambosender.com/api/v1/sms",
headers={
"Authorization": f"Bearer {os.environ['JAMBOSENDER_KEY']}",
# Derived from the receipt, so a retry is the same request.
"Idempotency-Key": f"receipt-{receipt.id}",
},
json={
"to": receipt.customer_phone,
"sender_id": "DUKA LA ALI",
"message": text,
"reference": f"receipt-{receipt.id}",
},
timeout=15,
)
if response.status_code == 402:
notify_merchant_to_top_up(receipt.tenant_id)
The request
POST /api/v1/sms
Authorization: Bearer jsk_test_…
Idempotency-Key: 6f1a9c3e-5c2b-4a2e-9f0e-2b7c1d8a4e55
Content-Type: application/json
{
"to": ["255713965677"],
"sender_id": "DUKA LA ALI",
"message": "Asante kwa manunuzi. Risiti #4821. Jumla 15,000 TZS.",
"reference": "receipt-4821",
"callback_url": "https://your-app.example/webhooks/sms"
}
| Field | Required | Notes |
|---|---|---|
to | yes | A string or an array of them. Local (0713…) and international (255713…) both work, and JSON numbers are accepted. Max 32 characters each. |
sender_id | yes | An approved sender name for the organisation. Any value works on a test key. Max 20. |
message | yes | Up to 1600 characters. Longer messages cost more credits — see §7. |
reference | no | Your own id, returned on every response and webhook. Use the thing you are messaging about (an order or receipt number). Unique per organisation: with several recipients we append -1, -2, … and each of those must be unused, or the whole request gets 409 reference_reused. |
callback_url | no | Overrides the standing callback for this request only. |
The response
{
"test": true,
"accepted": 1,
"rejected": 0,
"credits_charged": 0,
"balance_after": 4000,
"messages": [
{ "id": "412", "to": "255713965677", "parts": 1, "credits": 0, "status": "pending" }
],
"rejected_recipients": [],
"request_id": "req_01m4h4pd7jz7req4bq069frwjx"
}
202 means accepted, not delivered. Store each message
idagainst your own record, then track the outcome with a webhook (§5) or by reading it back (§4).
Several recipients at once
{ "to": ["255713965677", "0765432100", "255712345678"], "sender_id": "DUKA LA ALI", "message": "…" }
Each recipient becomes its own message with its own id and status. An unusable number is rejected
individually, listed in rejected_recipients and not charged; the rest still go. If
none of the numbers is usable the whole request gets 422 no_valid_recipients.
3. Idempotency
Idempotency-Key is required on POST /sms. Send a value
you generate per logical send — a UUID, or something derived from your own record.
The reason is simple: one day your request will time out, and a blind retry would send a second batch of SMS and charge the customer twice. With the key, the gateway replays the original response instead:
HTTP/1.1 202 Accepted
Idempotent-Replay: true
| What you send | What you get |
|---|---|
| Same key, same body | The original response, verbatim, including its request_id. |
| Same key, different body | 409 idempotency_key_reused — a bug on your side: you reused a key for a genuinely different send. |
| Same key while the first request is still running | 409 idempotency_conflict. Retry shortly. |
| No key | 400 idempotency_key_required. |
Keys are scoped to the organisation and do not expire: every answer below 500,
success or error, is replayed for as long as you repeat its key. Only a 5xx is
forgotten, so a retry after a server error really retries.
Generate the key once, store it with whatever you are sending about, and reuse it on every retry of that same send. Do not generate a fresh one inside a retry loop — that defeats the whole mechanism.
4. Read messages back
GET /api/v1/sms/412
GET /api/v1/messages?reference=receipt-4821
GET /api/v1/messages?status=failed&from=2026-10-01&limit=100
{
"id": "412",
"to": "255713965677",
"status": "delivered",
"credits": 1,
"reference": "receipt-4821",
"sent_at": "2026-10-18T09:12:03+00:00",
"delivered_at": "2026-10-18T09:12:11+00:00",
"error_code": null
}
GET /messages answers { "count": n, "messages": [ … ] }, newest first,
and takes status, reference, source, from,
to and limit (1–200, default 50).
Statuses
| Status | Meaning |
|---|---|
pending | Accepted, not yet picked up. |
queued | With a worker. |
sent | The operator accepted it. |
delivered | Confirmed on the handset. |
failed / rejected / expired | Did not arrive; error_code says why, and the credits were refunded. |
cancelled | Withdrawn before sending. |
Treat sent as "on its way", not "arrived". Only delivered is confirmation.
5. Delivery callbacks
Give us a URL and the gateway posts status changes to it. Prefer this to polling.
Where a message's events go, most specific first: the request's callback_url, then a
callback set on the key, then the application's standing webhook_url. With none of
them, no webhook is sent — read the status back instead (§4).
{
"event": "message.delivered",
"sent_at": "2026-10-18T09:12:11+00:00",
"data": {
"id": "412",
"to": "255713965677",
"status": "delivered",
"reference": "receipt-4821",
"credits": 1,
"error_code": null,
"provider_status": "DELIVRD",
"sent_at": "2026-10-18T09:12:03+00:00",
"delivered_at": "2026-10-18T09:12:11+00:00"
}
}
| Event | When |
|---|---|
message.sent | The operator accepted the message. |
message.delivered | Delivery confirmed on the handset. |
message.failed | Failed, rejected or expired; status says which, error_code why. Credits refunded. |
balance.low | Partner applications only: a customer fell below its threshold (§10). |
tenant.connected | Partner applications only: an owner approved a link (§10). |
provider_status is the operator's own word for the outcome, kept for support. Branch on
status and error_code, never on it. Only messages sent through the API
produce events; campaigns and test sends from the portal do not.
Verify the signature
Every callback carries a signature header:
X-JamboSender-Signature: t=1792282341,v1=6cca08264707018c…
v1 is HMAC-SHA256(secret, "<t>.<raw request body>"), where the
secret is your application's webhook secret (whsec_…), given to you once when the
application was registered. Verify against the raw body, before any JSON parsing —
re-encoding changes the bytes and the signature will not match.
[$t, $v1] = sscanf($request->header('X-JamboSender-Signature'), 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t.'.'.$request->getContent(), $secret);
abort_unless(hash_equals($expected, $v1), 403);
abort_if(abs(time() - $t) > 300, 403); // reject replays
// express.raw({ type: 'application/json' }) so rawBody is the exact bytes.
const [, t, v1] = req.get('X-JamboSender-Signature').match(/t=(\d+),v1=([a-f0-9]+)/);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(403);
if (Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(403);
import hmac, hashlib, re, time
t, v1 = re.match(r"t=(\d+),v1=([a-f0-9]+)", request.headers["X-JamboSender-Signature"]).groups()
expected = hmac.new(secret.encode(), f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(403)
if abs(time.time() - int(t)) > 300: # reject replays
abort(403)
A key that belongs to no application — an organisation's own key from API keys — has no webhook secret, so callbacks for its messages are signed with an empty secret and cannot be trusted. If you rely on callbacks, ask us to register your application and use a key it holds; otherwise read statuses back with §4.
Answer 2xx quickly (within 15 seconds) and do your work afterwards.
Anything else counts as a failure, and the delivery is tried 5 times in all: at
once, then after 1 minute, 5 minutes, 30 minutes and 2 hours. After that it is left as failed for an
administrator to send again. Callbacks can arrive more than once, so make handling them idempotent —
key on data.id.
6. Test mode
A jsk_test_… key runs the whole gateway — validation, credits arithmetic, queueing,
statuses, webhooks — and stops short of the telecom.
- Nothing is sent. A test key cannot reach a real provider; that is enforced where the provider is chosen, not by a flag in your request.
- Nothing is charged.
credits_chargedis0and the balance does not move. - Any
sender_idworks, so you are not blocked waiting for sender-name approval. - Test traffic is hidden from the organisation's message log unless someone asks for it, so your integration work does not pollute their reports.
Everything else behaves normally, including delivery callbacks — so you can build and test your webhook handler end to end.
Magic numbers
The last four digits of a recipient decide the outcome, so you can exercise your error handling deliberately. Keep your own country code and prefix:
| Recipient ends in | Outcome | error_code |
|---|---|---|
| anything else | accepted, then delivered | — |
0001 | rejected | invalid_number |
0002 | rejected | sender_not_approved |
0003 | rejected | operator_rejected |
0004 | rejected | insufficient_provider_credit |
0005 | rejected | rate_limited |
0006 | rejected | connection_error |
0007 | rejected | provider_error |
0008 | accepted, then undelivered | — |
0009 | accepted, never reports | — |
So 255713970001 simulates an invalid number, and 255713970008 simulates a
message the operator accepts and then fails to deliver — the case most integrations forget.
Going live
Swap the token. Nothing else changes. Before you do, check that the organisation has an approved sender name and credits, because a live key enforces both.
7. Credits and cost
{
"credits": 4000,
"rate": 25,
"currency": "TZS",
"estimated_value": 100000,
"low_balance": false,
"low_balance_threshold": 100,
"topup_url": "https://portal.jambosender.com/top-up",
"daily_credit_cap": null,
"remaining_today": null,
"request_id": "req_…"
}
One credit is one SMS part. A plain-GSM message is 160 characters per part; adding any non-GSM character (an emoji, a curly quote, many accents) drops that to 70 and makes a long message several times more expensive. Price it before you commit:
{ "to": ["255713965677", "0765432100"], "message": "…" }
→ {
"recipients": 2,
"rejected": 0,
"credits": 2,
"balance": 4000,
"sufficient": true,
"rejected_recipients": [],
"request_id": "req_…"
}
Credits are reserved when the gateway accepts your request and refunded automatically if a message
fails terminally. remaining_today is non-null only when the key has a daily cap.
Watch
low_balance. Show it in your own UI and point attopup_url— a merchant whose balance hits zero stops getting receipts out, and your application is where they will notice.
8. Sender names
A sender name is what the recipient sees instead of a phone number (SHULE SMS,
DUKA LA ALI). The organisation requests one in the portal and the mobile operators
approve it, which takes a few days. A live send under an unapproved name gets
422 sender_not_approved; a test key accepts any name.
{
"sender_names": [
{ "id": 7, "name": "DUKA LA ALI", "status": "approved", "usable": true, "provider_status": "ACTIVE" },
{ "id": 9, "name": "ALI STORE", "status": "pending", "usable": false, "provider_status": null }
],
"request_id": "req_…"
}
Read this at startup or before a first send and use a name whose usable is
true. If your application serves many organisations, it is how you tell whether one is
ready to send without trying and failing.
9. Errors
Every error has the same shape:
{
"error": {
"code": "sender_not_approved",
"message": "Sender name [DUKA LA ALI] is not approved for this organisation.",
"details": { "sender_id": "DUKA LA ALI" }
},
"request_id": "req_01m4h4pwbssb50r2xv5z50nbqb"
}
Branch on code, never on message.
| Status | code | What to do |
|---|---|---|
| 400 | idempotency_key_required | Send one. |
| 401 | unauthorized | Key missing, wrong, expired or revoked. |
| 402 | insufficient_credits | The organisation must top up. Nothing was sent and nothing charged — it is all or nothing, never a partial send. |
| 403 | insufficient_scope | The key lacks the scope; check its permissions (§1). |
| 403 | tenant_inactive | The organisation is pending approval, suspended or rejected. |
| 403 | app_inactive | The application holding this key has been turned off; contact us. |
| 403 | ip_not_allowed | The key has an IP allowlist and your address is not on it. |
| 404 | not_found | No message with that id belongs to this organisation. |
| 409 | idempotency_key_reused | You reused a key for a different body. |
| 409 | idempotency_conflict | The first request with this key is still running; retry shortly. |
| 409 | reference_reused | Another message from this organisation already has this reference (details.reference says which). Nothing was sent or charged. Send under a new reference; to repeat the same send, reuse its Idempotency-Key instead. |
| 422 | sender_not_approved | Pick a different sender_id, or get it approved (§8). |
| 422 | no_valid_recipients | None of the numbers was usable. |
| 429 | — | Rate limited; back off. The default is 120 requests a minute per key. |
| 429 | daily_cap_exceeded | The key has a daily credit cap and this send would pass it. |
Retry 429 and 5xx with backoff, using the same
Idempotency-Key. Do not retry a 4xx — fix the request.
Include request_id whenever you report a problem; it is on every response and every
error, and it is how support finds your request.
10. Partner applications
Everything above assumes one organisation sending its own messages. If you are building software that sends on behalf of many organisations — a property-management system, a school platform, a point of sale — you become a partner application instead, and you get:
- a partner key, which can create and link your customers but cannot send and cannot read any customer's data, so a leaked one costs provisioning noise rather than a drained balance;
- a webhook secret, so one endpoint of yours receives signed events for every customer;
- one customer key per organisation, issued to you by the API rather than pasted by hand.
Each organisation stays its own customer here: its own balance, its own sender names, its own message log, and its own sign-in to the portal. Every message is charged to the organisation whose key sent it.
What follows is the short version. The partner guide is the complete one: provisioning, linking an owner who already has an account, what has to happen before a customer can send, webhooks, keeping statuses in step, revocation, testing, a go-live checklist and a worked end-to-end example.
Create a customer
POST /api/v1/partner/tenants
Authorization: Bearer <partner key>
Idempotency-Key: yourapp-org-12-3f9a1c0b7d2e
{
"name": "Levolosi Estate Association",
"email": "mwenyekiti@levolosi.co.tz",
"owner_name": "Hassan Omari",
"phone": "255712000001",
"reference": "yourapp-org-12"
}
→ 201 Created
{
"tenant": { "id": "41", "name": "Levolosi Estate Association", "status": "pending", "can_send": false, "credits": 0 },
"api_key": { "id": 88, "prefix": "a1b2c3d4e5f6", "scopes": ["sms:send", "sms:read", "balance:read", "sender_names:read"] },
"token": "jsk_live_a1b2c3d4e5f6…",
"owner": {
"name": "Hassan Omari",
"email": "mwenyekiti@levolosi.co.tz",
"invitation_url": "https://portal.jambosender.com/accept-invitation?token=…"
},
"notice": "The token is shown once. The organisation cannot send until we approve it and a sender name is registered.",
"request_id": "req_…"
}
Do three things with that response, in this order:
- Store
tokenagainst your customer, encrypted, withtenant.idandtenant.status. It is shown once. - Give
owner.invitation_urlto the owner. JamboSender does not email it. The owner opens it to set a password and sign in, where they request their sender name, top up and see their messages. It is valid for 14 days and works once. - Treat the customer as not ready.
statusispendingandcan_sendisfalseuntil an administrator approves the organisation and a sender name is registered.
Idempotency-Key is optional here but use it, derived from your own record and the
email: if the response is lost on the way back, a retry replays the original answer — token
included — instead of failing with "email already exists".
One email belongs to exactly one JamboSender customer. If the owner already has an account you get
409 account_exists with a details.link_url: send the owner there, and when
they approve the link we post tenant.connected to your webhook with the key, and with
the reference you created them under so you can match it to your record.
GET /partner/link-url gives you such a link directly.
The partner endpoints
| Method and path | Purpose |
|---|---|
POST /partner/tenants | Create a customer and get its key. |
GET /partner/tenants | The customers you serve, with status, credits and whether each can send. |
POST /partner/tenants/{id}/keys | Another key for a customer you serve (a rotation, or a test key). |
GET /partner/link-url | A link that connects an existing account to your application. |
All of them need partner:provision and the partner key. Using a customer key here —
or the partner key on /sms — gets 403 insufficient_scope.
Sending for a customer is §2 unchanged, with that customer's key. Watch
balance.low on your webhook so you can warn a customer inside your own interface
before their sending stops.
Getting set up as a partner is a conversation, not a form. Read the partner guide, then email us or message us on WhatsApp with your application's name, a contact email and your webhook URL, and we will issue the partner key and the webhook secret.
11. Endpoint reference
| Method and path | Scope | Purpose |
|---|---|---|
POST /api/v1/sms | sms:send | Send to one or many recipients. Idempotency-Key required. |
POST /api/v1/sms/estimate | sms:send | Price a send without charging for it. |
GET /api/v1/sms/{id} | sms:read | One message, with its timestamps and error code. |
GET /api/v1/messages | sms:read | Messages, filtered by status, reference, source or date. |
GET /api/v1/balance | balance:read | Credits, rate, low-balance flag and top-up link. |
GET /api/v1/sender-names | sender_names:read | Which sender names are usable. |
POST /api/v1/partner/tenants | partner:provision | Create a customer and get its key. |
GET /api/v1/partner/tenants | partner:provision | Customers you serve. |
POST /api/v1/partner/tenants/{id}/keys | partner:provision | Another key for one of them. |
GET /api/v1/partner/link-url | partner:provision | Link an existing account. |
Every response carries a request_id. Every endpoint is limited to 120 requests a minute
per key by default.
12. Before you go live
- The token lives in server-side configuration, not in the repository.
Idempotency-Keyis derived from your own record, not random per attempt.- Webhook signatures are verified against the raw body, with a timestamp check.
- Webhook handling is idempotent — the same event can arrive twice.
402 insufficient_creditsis handled and surfaced to the organisation.low_balanceis shown somewhere they will see it.- You have exercised the magic numbers, especially
0008(accepted, then undelivered). - The organisation has an approved sender name and credits.
Support
Stuck on a response you did not expect? Send us the request_id — it identifies the
exact request in our logs, and it saves a round of guessing.