J JamboSender

Integration guide · API v1

Send SMS from your own application

JamboSender is a bulk SMS gateway for Tanzania. This guide is everything a developer needs to send messages from their own software: one REST call to send, signed callbacks for delivery reports, idempotent retries, and a test mode that behaves like the real thing without touching a telecom.

Base URL
https://portal.jambosender.com/api/v1

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 /sms answers 202 Accepted once 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-Key is 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

ThingHow you get it
A JamboSender accountRegister; an administrator approves the organisation.
An API keyYourself, in the portal under API keys (§1).
An approved sender nameRequest it in the portal. Registration with the operators takes a few days, and a test key does not wait for it.
CreditsTop 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:

Token
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

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

ScopeAllows
sms:sendPOST /sms, POST /sms/estimate
sms:readGET /sms/{id}, GET /messages
balance:readGET /balance
sender_names:readGET /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
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"
  }'

The request

HTTP
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"
}
FieldRequiredNotes
toyesA string or an array of them. Local (0713…) and international (255713…) both work, and JSON numbers are accepted. Max 32 characters each.
sender_idyesAn approved sender name for the organisation. Any value works on a test key. Max 20.
messageyesUp to 1600 characters. Longer messages cost more credits — see §7.
referencenoYour 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_urlnoOverrides the standing callback for this request only.

The response

202 Accepted
{
  "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 id against your own record, then track the outcome with a webhook (§5) or by reading it back (§4).

Several recipients at once

JSON
{ "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
HTTP/1.1 202 Accepted
Idempotent-Replay: true
What you sendWhat you get
Same key, same bodyThe original response, verbatim, including its request_id.
Same key, different body409 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 running409 idempotency_conflict. Retry shortly.
No key400 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

HTTP
GET /api/v1/sms/412
GET /api/v1/messages?reference=receipt-4821
GET /api/v1/messages?status=failed&from=2026-10-01&limit=100
200 OK
{
  "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

StatusMeaning
pendingAccepted, not yet picked up.
queuedWith a worker.
sentThe operator accepted it.
deliveredConfirmed on the handset.
failed / rejected / expiredDid not arrive; error_code says why, and the credits were refunded.
cancelledWithdrawn 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).

POST to your URL
{
  "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"
  }
}
EventWhen
message.sentThe operator accepted the message.
message.deliveredDelivery confirmed on the handset.
message.failedFailed, rejected or expired; status says which, error_code why. Credits refunded.
balance.lowPartner applications only: a customer fell below its threshold (§10).
tenant.connectedPartner 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:

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

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_charged is 0 and the balance does not move.
  • Any sender_id works, 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 inOutcomeerror_code
anything elseaccepted, then delivered—
0001rejectedinvalid_number
0002rejectedsender_not_approved
0003rejectedoperator_rejected
0004rejectedinsufficient_provider_credit
0005rejectedrate_limited
0006rejectedconnection_error
0007rejectedprovider_error
0008accepted, then undelivered—
0009accepted, 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

GET /api/v1/balance
{
  "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:

POST /api/v1/sms/estimate
{ "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 at topup_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.

GET /api/v1/sender-names
{
  "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 envelope
{
  "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.

StatuscodeWhat to do
400idempotency_key_requiredSend one.
401unauthorizedKey missing, wrong, expired or revoked.
402insufficient_creditsThe organisation must top up. Nothing was sent and nothing charged — it is all or nothing, never a partial send.
403insufficient_scopeThe key lacks the scope; check its permissions (§1).
403tenant_inactiveThe organisation is pending approval, suspended or rejected.
403app_inactiveThe application holding this key has been turned off; contact us.
403ip_not_allowedThe key has an IP allowlist and your address is not on it.
404not_foundNo message with that id belongs to this organisation.
409idempotency_key_reusedYou reused a key for a different body.
409idempotency_conflictThe first request with this key is still running; retry shortly.
409reference_reusedAnother 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.
422sender_not_approvedPick a different sender_id, or get it approved (§8).
422no_valid_recipientsNone of the numbers was usable.
429—Rate limited; back off. The default is 120 requests a minute per key.
429daily_cap_exceededThe 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
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:

  1. Store token against your customer, encrypted, with tenant.id and tenant.status. It is shown once.
  2. Give owner.invitation_url to 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.
  3. Treat the customer as not ready. status is pending and can_send is false until 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 pathPurpose
POST /partner/tenantsCreate a customer and get its key.
GET /partner/tenantsThe customers you serve, with status, credits and whether each can send.
POST /partner/tenants/{id}/keysAnother key for a customer you serve (a rotation, or a test key).
GET /partner/link-urlA 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 pathScopePurpose
POST /api/v1/smssms:sendSend to one or many recipients. Idempotency-Key required.
POST /api/v1/sms/estimatesms:sendPrice a send without charging for it.
GET /api/v1/sms/{id}sms:readOne message, with its timestamps and error code.
GET /api/v1/messagessms:readMessages, filtered by status, reference, source or date.
GET /api/v1/balancebalance:readCredits, rate, low-balance flag and top-up link.
GET /api/v1/sender-namessender_names:readWhich sender names are usable.
POST /api/v1/partner/tenantspartner:provisionCreate a customer and get its key.
GET /api/v1/partner/tenantspartner:provisionCustomers you serve.
POST /api/v1/partner/tenants/{id}/keyspartner:provisionAnother key for one of them.
GET /api/v1/partner/link-urlpartner:provisionLink 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-Key is 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_credits is handled and surfaced to the organisation.
  • low_balance is 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.

Email
—
WhatsApp
—

Create an account