1. The model in one page
Every organisation you serve is its own JamboSender customer. It is not a sub-account of your application. It has its own credit balance, its own sender names, its own message log, and its owner can sign in to the JamboSender portal directly. Your application sends for it, and every message is charged to it.
┌─────────────────────── JamboSender ───────────────────────┐
│ │
Your app ──┤ partner key ──► create / link customers (cannot send) │
│ │
│ customer key A ──► send as Customer A, charged to A │
│ customer key B ──► send as Customer B, charged to B │
│ │
│ ◄── webhooks, signed with your webhook secret │
└───────────────────────────────────────────────────────────┘
Three credentials, three jobs
| Credential | Belongs to | Does | Where you keep it |
|---|---|---|---|
Partner keyjsk_live_… |
your application | Creates and links customers, lists them. Cannot send, cannot read any customer's messages or balance. | One, in your server configuration. |
Customer keyjsk_live_… |
one customer, held by your application | Sends for that customer, reads its messages, balance and sender names. | One per customer, in your database, encrypted. |
Webhook secretwhsec_… |
your application | Lets you verify that a webhook really came from JamboSender. | One, in your server configuration. |
Why it is split like this
- A leaked partner key cannot spend anyone's money. The worst it can do is create customers, which still need our approval before they can send.
- A leaked customer key affects one customer, has a daily cap if you set one, and that customer (or we) can revoke it without touching anyone else.
- The customer stays in control. They can see every message your application sent for them, top up themselves, and revoke your access from their own API keys page.
You never handle customers' passwords and you never take payment for SMS credits. Customers top up in JamboSender.
2. What you get from us
Ask us to register your application. We need:
| We need | Example |
|---|---|
| Application name | Wamiliki |
| A technical contact email | dev@wamiliki.co.tz |
| Your webhook URL (HTTPS, publicly reachable) | https://wamiliki.co.tz/api/webhooks/jambosender |
We send you, once, the partner key and the webhook secret. Neither can be shown again. If you lose one, ask for a new one: a new partner key can be issued alongside the old, and a new webhook secret replaces the old immediately.
If your application also needs to send messages as itself (§7.3), ask for that too: we create an organisation for your application and give you an ordinary customer key for it. For development, ask for a test-mode customer key held by your application as well (§10).
3. Your configuration
Five values, all server-side. None of them belongs in a mobile app, a browser bundle or a git repository.
JAMBOSENDER_URL=https://portal.jambosender.com
JAMBOSENDER_PARTNER_KEY=jsk_live_… # provisioning only
JAMBOSENDER_WEBHOOK_SECRET=whsec_… # verifies our webhooks
JAMBOSENDER_API_KEY=jsk_live_… # optional: your own organisation's key
JAMBOSENDER_SENDER_ID=WAMILIKI # optional: the sender name on your own key
Per-customer keys are not configuration. They arrive at runtime and live in your database, one row per customer:
jambosender_accounts
organization_id your own customer id, unique
tenant_id JamboSender's id for the customer
tenant_status pending | active | suspended | rejected
api_key the customer key, encrypted at rest
link_url set while waiting for an existing account to be linked (§5)
error why connecting failed, until it succeeds
connected_at
Encrypt the key column (in Laravel, the encrypted cast) and keep it out of anything that logs model changes.
4. Creating a customer
POST /api/v1/partner/tenants
Authorization: Bearer <partner key>
Idempotency-Key: wamiliki-org-12-3f9a1c0b7d2e
Content-Type: application/json
{
"name": "Levolosi Estate Association",
"email": "mwenyekiti@levolosi.co.tz",
"owner_name": "Hassan Omari",
"phone": "255712000001",
"reference": "wamiliki-org-12"
}
| Field | Required | Notes |
|---|---|---|
name | yes | The organisation's name, as its members would recognise it. Max 150. |
email | yes | The owner's email. One email belongs to exactly one JamboSender customer; see §5. |
owner_name | yes | The person who will own the account. Max 150. |
phone | no | The owner's phone. Max 30. |
reference | no | Your id for this customer. Max 200. Strongly recommended: it comes back to you on tenant.connected (§5), which is how you match an approval to your record. |
Idempotency here
Idempotency-Key is optional on this endpoint but use it: if the
response is lost on the way back, a retry with the same key replays the original answer, token
included, instead of failing with "email already exists". Derive the key from your record and the
email, never randomly.
- Every answer below 500 is kept and replayed, with no expiry: the
201, but also a409 account_existsor a422. Repeating the same key gets the same answer forever. Only a5xxis forgotten, so retrying after a server error really retries. - The same key with a different body (say the organisation was renamed) gets
409 idempotency_key_reused. - The same key while the first request is still running gets
409 idempotency_conflict; retry shortly.
So use a key per attempt that should have one outcome: Wamiliki's is
wamiliki-org-<id>-<hash of the email>. After a
409 account_exists, do not expect a new link by retrying with the same key; get one
from GET /partner/link-url (§5).
201 Created
{
"tenant": {
"id": "41",
"name": "Levolosi Estate Association",
"slug": "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 it, 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 to the portal, where they request their sender name, top up and see their messages. It is valid for 14 days and works once. Show it in your app, or email it yourself. - Treat the customer as not ready.
statusispendingandcan_sendisfalse; see §6.
Choosing the owner
The owner is whoever should control the account and its money. Pick someone with authority in the organisation, not your own staff. Wamiliki picks, in order: the committee chair, the secretary, the treasurer, any other committee member, and finally the person who asked for the trial. It skips anyone without an email and tries again later rather than inventing one.
Errors
| Status | code | Meaning |
|---|---|---|
| 409 | account_exists | The email already has a JamboSender account: go to §5. |
| 409 | idempotency_key_reused | This Idempotency-Key was used before with a different body. |
| 409 | idempotency_conflict | A request with this key is still running; retry shortly. |
| 422 | (validation) | A field is missing or too long; errors names it. |
| 401 | unauthorized | The partner key is wrong or revoked. |
| 403 | insufficient_scope | You used a customer key here instead of the partner key. |
| 403 | app_inactive | Your application has been turned off; contact us. |
| 429 | — | Rate limited (120 requests a minute per key); retry with the same Idempotency-Key. |
5. When the customer already has an account
One email, one customer. If the owner already uses JamboSender — directly, or through another application — we never create a second account: they would end up with two balances. Instead you get a link for them to approve.
{
"error": {
"code": "account_exists",
"message": "Someone already uses this email on JamboSender. Ask them to connect the account they have.",
"details": {
"link_url": "https://portal.jambosender.com/connect?token=…",
"tenant_name": "Levolosi Estate Association"
}
},
"request_id": "req_…"
}
-
Show
link_urlto the owner as a button ("Connect your JamboSender account"). It carries thereferenceyou sent, expires after 2 hours and works once. Store when you got it; when it is older than 2 hours, get a fresh one fromGET /partner/link-urlwith the sameemailandreference. RetryingPOST /partner/tenantswith the sameIdempotency-Keywould only replay the old, expired link (§4). - The owner opens it, signs in to JamboSender, sees what your application is asking for (send SMS charged to their balance, read the messages it sent, read the balance, read approved sender names) and approves. Only an owner of that organisation can approve; a staff user is refused.
- We post the key to your webhook URL.
{
"event": "tenant.connected",
"sent_at": "2026-10-24T09:12:11+00:00",
"data": {
"tenant": { "id": "41", "name": "Levolosi Estate Association", "status": "active" },
"api_key": { "id": 89, "prefix": "f6e5d4c3b2a1", "token": "jsk_live_f6e5d4c3b2a1…" },
"reference": "wamiliki-org-12"
}
}
Match data.reference to your record and store data.api_key.token exactly
as in §4. Clear the stored link_url.
The token travels only to your webhook, never through the owner's browser, so it cannot be copied off a page. Without a webhook URL the approval page shows the token to the owner instead, and they have to paste it into your application. Register a webhook URL.
If the owner approves a second time, nothing new is issued and no webhook is sent: the application is already connected.
A link without trying to create first
GET /api/v1/partner/link-url?email=mwenyekiti@levolosi.co.tz&reference=wamiliki-org-12
Authorization: Bearer <partner key>
→ { "link_url": "https://portal.jambosender.com/connect?token=…", "expires_in": 7200, "request_id": "req_…" }
Useful for a "Connect an existing JamboSender account" button. Both parameters are optional;
reference is what comes back on tenant.connected.
6. What has to happen before a customer can send
created (pending) ──► approved by JamboSender ──► sender name approved ──► credits ──► sends
§4 us, by hand days, at the operator owner tops up
| Step | Who | How you see it |
|---|---|---|
| Approval | JamboSender, by hand. Provisioning never bypasses review. | status becomes active on GET /partner/tenants (§9.1). Until then every call with the customer key gets 403 tenant_inactive. |
| Sender name | The owner requests it in the portal, or asks us. It is then registered with the operator, which takes days. | GET /api/v1/sender-names with the customer key: usable: true. Until then sending under that name gets 422 sender_not_approved. |
| Credits | The owner tops up in the portal, or pays us and we record a deposit. | GET /api/v1/balance with the customer key. Without credits, sending gets 402 insufficient_credits. |
There is no shared sender name a new customer can borrow while theirs is pending. If your users must receive messages from day one, send those from your own organisation's key (§7.3).
Approval of the customer and approval of its sender name are separate, and days apart. A customer can be
activewith no usable sender name yet. Do not switch a customer to its own key onstatusalone: readGET /api/v1/sender-nameswith its key (your partner key cannot) and switch once the name you will send under hasusable: true. Names are matched ignoring case.
Sender names are 3 to 11 characters: letters, digits and spaces. Ask the owner to request one that
their recipients will recognise — Wamiliki uses the estate's short name (LEVOLOSI), and
limits that field in its own forms to what JamboSender accepts, so the two can always match.
7. Sending for a customer
7.1 The request
Exactly the request in API guide §2, with the customer's key:
POST /api/v1/sms
Authorization: Bearer <that customer's key>
Idempotency-Key: statement-2026-10-unit-A01
Content-Type: application/json
{
"to": ["255712000482"],
"sender_id": "LEVOLOSI",
"message": "LEVOLOSI: Taarifa ya Oktoba 2026. Deni lako ni TZS 30,000.",
"reference": "wamiliki-5521",
"callback_url": "https://wamiliki.co.tz/api/webhooks/jambosender"
}
Rules that matter more for a partner than for anyone else:
- Pick the key by customer, every time. Never fall back to another customer's key: you would charge the wrong organisation.
Idempotency-Keyis required and must come from your own record of the message, so that a retried job replays instead of sending twice. One key per logical send, reused on every retry of that send.referenceis your id. It comes back on every webhook and is how you find your record. It must be unique per customer; for several recipients in one request we append-1,-2, … per recipient. Reusing one for a different send gets409 reference_reused. So if you let an operator send a failed message again as a new send (with a newIdempotency-Key), give it a new reference too: Wamiliki appends-retry1,-retry2, …callback_urlis optional. Without it, delivery reports go to your application's webhook URL.
7.2 What to retry
| Response | Retry? |
|---|---|
202 | No: accepted. Store messages[0].id to match webhooks. |
Connection error, timeout, 5xx, 429 | Yes, with backoff and the same Idempotency-Key. |
409 idempotency_key_reused, 409 reference_reused | No: a bug on your side, a key or reference reused for a different send. |
402, 403, 422 | No: something the customer or we must fix. Record the code and tell someone. |
A 202 can still list a recipient under rejected_recipients (an unusable number); that recipient was not charged.
7.3 Sending before a customer is ready
Between creating a customer and its first approved sender name there are usually days. Messages your users need in that time — invitations, sign-in codes, confirmations — have nowhere to go.
The solution is to send those from your own organisation: we create one for your application and give you an ordinary customer key for it, with your own approved sender name. Your application then chooses per message:
customer's account is active → customer key, customer's sender name
otherwise → your own key, your own sender name (you pay)
This is exactly what Wamiliki does (§12.5). Decide deliberately which messages may fall back: it is your balance that pays for them.
8. Webhooks: one endpoint for everything
All events for your application go to the one URL you registered (or a message's
callback_url), signed with your webhook secret:
X-JamboSender-Signature: t=1792282341,v1=6cca08264707018c…
v1 is HMAC-SHA256(webhook secret, "<t>.<raw request body>").
Verify it on the raw bytes before parsing, and reject a t more than 5 minutes from
now. Code for PHP, Node and Python is in
API guide §5.
| Event | When | data holds | Match on |
|---|---|---|---|
message.sent | The operator accepted a message. | The message (below). | data.id (or data.reference) |
message.delivered | Confirmed on the handset. | The message. | data.id |
message.failed | Failed, rejected or expired; credits refunded. | The message, with error_code. | data.id |
balance.low | A customer you serve fell below its threshold; at most once a day per customer. | tenant, credits, threshold, overdraft, topup_url. | data.tenant.id |
tenant.connected | An owner approved a link (§5). | tenant, api_key.token, reference. | data.reference |
{
"event": "message.failed",
"sent_at": "2026-10-24T09:12:11+00:00",
"data": {
"id": "412",
"to": "255712000482",
"status": "failed",
"reference": "wamiliki-5521",
"credits": 1,
"error_code": "invalid_number",
"provider_status": "UNDELIV",
"sent_at": "2026-10-24T09:12:03+00:00",
"delivered_at": null
}
}
Delivery mechanics
- Answer
2xxfast, then do the work. Anything else, or a timeout (15 s), counts as a failure. - A failed delivery is tried 5 times in all: at once, then after 1 minute, 5 minutes, 30 minutes and 2 hours. After that it stays failed in JamboSender and an administrator can send it again.
- The same event can arrive more than once. Make handling idempotent: a
message.sentfor a message you already have asdeliveredmust not move it backwards. - Events for messages you do not recognise — for example ones the customer sent from the portal with their own key — should be acknowledged with
2xxand ignored. tenant.connectedcontains a live key. Log the event, never its body.
9. Keeping in step: statuses, balances, revocation
9.1 Customer statuses
GET /api/v1/partner/tenants
Authorization: Bearer <partner key>
→ {
"tenants": [
{ "id": "41", "name": "Levolosi Estate Association", "slug": "levolosi-estate-association", "status": "active", "can_send": true, "credits": 4000 },
{ "id": "42", "name": "Majaribio Estate", "slug": "majaribio-estate", "status": "pending", "can_send": false, "credits": 0 }
],
"request_id": "req_…"
}
Every customer your application has been issued a key for, including through a link, and even if that key was later revoked. There is no webhook for approval, so poll this — hourly is plenty — and update your stored statuses.
status | Meaning | Customer key works? |
|---|---|---|
pending | Waiting for our approval. | No, 403 tenant_inactive. |
active | Approved. | Yes. |
suspended | Stopped by us (usually payment or abuse). | No, 403 tenant_inactive. |
rejected | Not approved. | No, 403 tenant_inactive. |
can_send is true exactly when status is active.
It does not check sender names or credits; those are per-message outcomes.
9.2 Balances
GET /api/v1/balance with the customer key returns credits, rate, the low-balance flag
and topup_url (API guide §7). Show it where the customer's
administrators will see it, together with balance.low warnings, and link to
topup_url. A customer that runs out stops getting messages out, and your application
is where they will notice.
9.3 Another key for a customer
POST /api/v1/partner/tenants/41/keys
Authorization: Bearer <partner key>
Content-Type: application/json
{ "name": "Wamiliki (worker 2)", "daily_credit_cap": 5000 }
201 with a new token for a customer your application serves — it has been
issued at least one key for it, even a revoked one; 404 not_found otherwise. Use it to
replace a key you lost or that was revoked by mistake. Both fields are optional.
9.4 When access stops
| What happened | You see | What to do |
|---|---|---|
| The owner revoked your key on their API keys page. | 401 unauthorized on that customer's key. | Mark the customer disconnected; ask them to reconnect (§5). |
| We suspended the customer. | 403 tenant_inactive. | Show it; the owner must contact us. |
| We revoked or regenerated a key. | 401 unauthorized. | Ask us for the replacement. |
| We turned your application off. | 403 app_inactive on every key you hold. | Contact us. |
Never treat these as retryable.
10. Testing
A test key (jsk_test_…) runs the whole gateway — validation, credit
arithmetic, queueing, statuses, webhooks — without reaching an operator or charging anything, and
accepts any sender_id. The magic numbers let you force each
failure, including …0008: accepted, then undelivered.
Partner-specific points:
- Provisioning always issues live keys.
POST /partner/tenantsandPOST /partner/tenants/{id}/keysreturnjsk_live_tokens, and a new customer cannot send until approved anyway. To exercise sending, ask us for a test-mode customer key held by your application, or have a test organisation create a test key itself. - Exercise provisioning against a staging JamboSender, or with clearly fake owners that we then reject, rather than creating real customers in production.
- Test the 409 path by provisioning the same email twice.
- Test
tenant.connectedby having a test owner open the link and approve. - Test your signature check with a wrong secret and with a
tan hour old; both must be refused.
11. Going live
- Application registered; partner key and webhook secret stored server-side.
- Webhook URL is HTTPS, public, verifies signatures on the raw body, checks the timestamp, answers
2xxfast. - Customer keys stored encrypted, one per customer, never logged.
- Provisioning sends an
Idempotency-Keyderived from your record and the email. owner.invitation_urlreaches the owner.409 account_existsshows thelink_urlto the owner;tenant.connectedstores the key byreference.- Customer statuses are refreshed from
GET /partner/tenants. - Every send uses that customer's key, a stable
Idempotency-Keyand areference. - Retries only on network errors,
5xxand429, with the sameIdempotency-Key. 402,403 tenant_inactiveand422 sender_not_approvedare shown to someone who can act on them.401on a customer key marks that customer disconnected.- Low balance is visible to the customer, with
topup_url. - Decided which messages may fall back to your own key, if any.
- Tested with a test key, including magic numbers
0001,0002and0008.
12. Case study: Wamiliki
Wamiliki is an estate-management platform. Each estate — an owners' association such as Levolosi Estate — is a Wamiliki organisation, and sends its residents statements, sign-in codes and notices by SMS. Each estate is its own JamboSender customer.
12.1 What Wamiliki holds
Wamiliki's .env
JAMBOSENDER_URL https://portal.jambosender.com
JAMBOSENDER_PARTNER_KEY provisioning
JAMBOSENDER_WEBHOOK_SECRET verifying webhooks
JAMBOSENDER_API_KEY Wamiliki's own organisation, for estates not ready yet
JAMBOSENDER_SENDER_ID WAMILIKI
Wamiliki's database: jambosender_accounts, one row per estate
tenant_id, tenant_status JamboSender's id and status for the estate
sender_names, …_checked_at the estate's sender names and statuses, read hourly
owner_email who owns it, or was asked to connect it
api_key the estate's key (encrypted)
invitation_url JamboSender's set-a-password link (encrypted)
owner_notified_at when the owner was emailed it, or a connect link
link_url, link_requested_at the current connect link and when it was fetched
error why connecting failed, until it succeeds
connected_at, disconnected_at when the key arrived; when JamboSender refused it
credits, low_balance_at the last balance seen; the last balance.low
12.2 When an estate becomes a customer
Wamiliki connects an estate:
- when staff finish onboarding the estate,
- when a committee member accepts an invitation to an estate that is already onboarded (a member with an email may have appeared),
- when JamboSender refuses the estate's key (
401, §12.5), and - hourly, via
php artisan jambosender:connect, which retries estates that could not be connected, keeps connect links fresh, and refreshes every status and balance fromGET /partner/tenants.
An estate that is already connected is skipped without calling JamboSender, apart from retrying an invitation email that failed.
12.3 Provisioning
$owner = $this->owner($organization); // chair, secretary, treasurer, member, then trial contact
if ($owner === null) {
return $this->failed($account, 'No committee member or trial contact with an email yet.');
}
// A customer that exists (its key was lost or revoked) or an owner already
// asked to connect: only the owner's approval helps now. Posting again would
// replay the first answer: a revoked key, or a link long expired.
if ($account->tenant_id !== null || ($account->link_url !== null && $account->owner_email === $owner['email'])) {
return $this->awaitApproval($organization, $account, $owner); // §12.4
}
$response = Http::baseUrl($this->baseUrl)->withToken($this->partnerKey)->acceptJson()->timeout(15)
->withHeaders(['Idempotency-Key' => 'wamiliki-org-'.$organization->id.'-'.substr(sha1($owner['email']), 0, 12)])
->post('/api/v1/partner/tenants', array_filter([
'name' => $organization->name,
'email' => $owner['email'],
'owner_name' => $owner['name'],
'phone' => $owner['phone'],
'reference' => 'wamiliki-org-'.$organization->id,
]));
if ($response->status() === 201) {
$account->fill([
'tenant_id' => (string) $response->json('tenant.id'),
'tenant_status' => $response->json('tenant.status'),
'owner_email' => $owner['email'],
'api_key' => $response->json('token'), // encrypted cast
'invitation_url' => $response->json('owner.invitation_url'), // encrypted cast
'connected_at' => now(), /* … */
])->save();
// JamboSender does not email it: without it the owner cannot sign in.
$this->notifyOwner($organization, $account, $owner, $account->invitation_url, 'invitation');
} elseif ($response->status() === 409 && $response->json('error.code') === 'account_exists') {
$account->fill([
'owner_email' => $owner['email'],
'link_url' => $response->json('error.details.link_url'),
'link_requested_at' => now(),
])->save();
$this->notifyOwner($organization, $account, $owner, $this->connectUrl($organization), 'connect');
} else {
$this->failed($account, "JamboSender {$response->status()} ...");
}
The owner's emails are written in the estate's language, Swahili or English:
| Sent when | Button goes to | |
|---|---|---|
| Set up the estate's SMS account | 201: a new account was created. | JamboSender's invitation_url (set a password; 14 days, once). |
| Connect the estate's SMS account | 409 account_exists, or the key was revoked. | Wamiliki's own signed link (14 days), which fetches a fresh JamboSender link when opened (§12.4). |
Both say what the owner does in JamboSender — request the sender name, buy credits, see messages — and that messages go out from Wamiliki until then.
Design choices worth copying
- The
referenceis stable (wamiliki-org-<id>), sotenant.connectedcan always be matched. - The idempotency key includes a hash of the email, so correcting the owner's email is a fresh request rather than a replay of the old answer. Renaming the estate while the email stays the same is not: that gets
409 idempotency_key_reused, recorded on the row as an error. - Failures are recorded on the row, not thrown, and the hourly run retries them. Nothing in onboarding waits on JamboSender.
- It never posts the same customer twice. Once a tenant id is known, or a link is pending for the same owner, the only way forward is the owner's approval, because a repeat would only replay the first answer (§4).
- It never issues itself a new key with
POST /partner/tenants/{id}/keysafter a401. A revoked key usually means the owner revoked it on purpose; only the owner reconnects.
12.4 Linking an existing account
JamboSender's connect link lasts two hours; an owner may open their email days later. So the owner is never sent JamboSender's link. They get a Wamiliki link instead, whose controller checks the signature (14 days), then:
- estate already connected → redirect to JamboSender, nothing to approve;
- otherwise →
GET /partner/link-url?email=<owner>&reference=wamiliki-org-12and redirect to the fresh link, or to a "try again in a few minutes" page if JamboSender cannot be reached.
While it waits, the hourly run:
- fetches a fresh link with
GET /partner/link-urlonce the stored one is 90 minutes old, so the link printed for staff always works, - emails the owner the Wamiliki link once, and again only if the owner changes (a new chair),
- never posts the customer again.
When the owner approves, the webhook handler passes tenant.connected to a method that:
- checks
data.referencematcheswamiliki-org-<id>and that organisation exists, - stores
data.tenant.id,data.tenant.statusanddata.api_key.token, - clears
link_url,erroranddisconnected_at.
12.5 Sending
Every SMS in Wamiliki is a row sent by a queued job through a driver that chooses the key per message:
$account = JamboSenderAccount::query()->where('organization_id', $message->organization_id)->first();
$label = $organization->sms_label; // e.g. LEVOLOSI
// Connected, tenant_status === 'active', and $label approved among the
// estate's sender names (a test key takes any name).
if ($account?->canSendAs($label)) {
$key = $account->api_key;
$sender = $label;
} elseif ($this->platformKey !== '') {
$key = $this->platformKey; // Wamiliki's own organisation
$sender = $this->defaultSenderId; // WAMILIKI
} else {
return SmsResult::failed('The estate has no approved JamboSender account yet.');
}
Http::withToken($key)
->withHeaders(['Idempotency-Key' => $message->idempotency_key]) // e.g. statement-1-unit-7
->post('/api/v1/sms', [
'to' => [$message->phone],
'sender_id' => $sender,
'message' => $message->body,
'reference' => 'wamiliki-'.$message->id, // plus -retryN when resent from the SMS page
'callback_url' => route('webhooks.jambosender'),
]);
The hourly run reads each connected estate's sender names with the estate's own key
(GET /api/v1/sender-names) and stores them with their status. A 401 there
disconnects the estate just as a send would; a 403 — estate pending or suspended —
keeps what was last read.
202→ the message isaccepted, and JamboSender'smessages[0].idis stored to match webhooks.- Connection errors,
5xx,409,429→ the job retries with the same idempotency key, backing off over about an hour (30 s, 2 min, 5 min, 15 min, 30 min). 401on the estate's own key → the key was revoked. Wamiliki marks the estate disconnected, queues a reconnect (which emails the owner a connect link), and retries the message, which now goes out from Wamiliki's own account. The retry is not a replay: idempotency keys are per JamboSender customer, and this is a different one.- Anything else → the message is marked
failedwith JamboSender's code, for exampleJamboSender 402 insufficient_credits: ….
Because an estate's own key is used only once JamboSender has approved both the estate and its sender name, everything sent before that goes out from Wamiliki's own account, as
WAMILIKI, and Wamiliki pays for it. From the next send after both approvals, everything goes out under the estate's own name, charged to the estate. Without this check, an estate approved days before its sender name would have every SMS refused with422 sender_not_approved.
12.6 Webhooks
One route, POST /api/webhooks/jambosender:
- verify
X-JamboSender-Signatureagainst the webhook secret, with a 5-minute window;401otherwise, tenant.connected→ store the key (§12.4),balance.low→ find the estate bydata.tenant.id, and email its chair and treasurer the balance and a button todata.topup_url, in the estate's language. The emails are queued before the warning is recorded, so if queueing fails the webhook fails and JamboSender's retry is not mistaken for a duplicate; a second warning within an hour only updates the stored balance,message.*→ find the message by the stored provider id againstdata.id; unknown ids are acknowledged and ignored,message.sentmovesacceptedtosent;message.deliveredsetsdeliveredanddelivered_at;message.failedsetsfailedwith the error code, unless the message was already delivered,- answer
204.
12.7 Operations
php artisan jambosender:connect # every open estate
php artisan jambosender:connect levolosi # one estate, by slug or id
+-----------+--------+---------------+-------------------+----------------------------------------------------------+
| Estate | Tenant | Status | Sender | Note |
+-----------+--------+---------------+-------------------+----------------------------------------------------------+
| levolosi | 41 | active | LEVOLOSI approved | Low balance: 37 credits |
| mbezi | 44 | active | MBEZI pending | |
| majaribio | 42 | pending | no_label | Owner neema@majaribio.co.tz invited (emailed 2026-10-24) |
| njiro | 43 | disconnected | - | Waiting for chair@njiro.co.tz to approve (emailed …). … |
| kisongo | - | not connected | - | No committee member or trial contact with an email yet. |
+-----------+--------+---------------+-------------------+----------------------------------------------------------+
Administrators see the same on Wamiliki's own SMS page, with the message log, a live health check of JamboSender and the keys, and a test send. An estate that is approved but whose sender name is not shows as sends as WAMILIKI until its sender name is approved.
12.8 What Wamiliki still leaves to people
- Requesting the sender name. Wamiliki cannot request one: there is no API for it. The owner requests it in JamboSender (or an administrator adds it), and Wamiliki notices within the hour.
- A newly approved name is picked up within the hour, or at once with Connect now on the SMS page; until then the estate keeps sending as
WAMILIKI. 403 tenant_inactiveis not acted on at once. A suspended estate's messages fail with that code until the hourly status refresh marks it, after which they go out from Wamiliki's own account.- Wamiliki pays for everything sent before an estate and its sender name are approved. That is a deliberate choice (§7.3); another integrator may prefer to hold those messages instead.
13. Reference
Endpoints
| Method and path | Key | Scope | Purpose |
|---|---|---|---|
POST /api/v1/partner/tenants | partner | partner:provision | Create a customer and get its key (§4). |
GET /api/v1/partner/tenants | partner | partner:provision | Customers you serve, with status (§9.1). |
POST /api/v1/partner/tenants/{id}/keys | partner | partner:provision | Another key for a customer you serve (§9.3). |
GET /api/v1/partner/link-url | partner | partner:provision | A link to connect an existing account (§5). |
POST /api/v1/sms | customer | sms:send | Send (API guide §2). |
POST /api/v1/sms/estimate | customer | sms:send | Price a message. |
GET /api/v1/sms/{id}, GET /api/v1/messages | customer | sms:read | Read messages back. |
GET /api/v1/balance | customer | balance:read | Credits and top-up link. |
GET /api/v1/sender-names | customer | sender_names:read | Which names are usable. |
Every endpoint is limited to 120 requests a minute per key by default. Every response carries a request_id; quote it when you contact us.
Errors specific to partners
| Status | code | Where | Meaning |
|---|---|---|---|
| 409 | account_exists | POST /partner/tenants | The email has an account; details.link_url. |
| 404 | not_found | POST /partner/tenants/{id}/keys | Not a customer your application serves. |
| 403 | insufficient_scope | any | Wrong kind of key: partner key on /sms, or customer key on /partner/*. |
| 403 | app_inactive | any | Your application is turned off. |
| 403 | tenant_inactive | customer endpoints | The customer is pending, suspended or rejected. |
All other errors are in API guide §9.
Webhook events
message.sent, message.delivered, message.failed, balance.low, tenant.connected — see §8.
14. Questions integrators ask
Can the partner key send a message?
No. It only has partner:provision. Sending always needs a customer key.
Can we create a customer without an email?
No. The email identifies the owner and is how an existing account is detected. Wait until you have one; Wamiliki records "no email yet" and retries hourly.
Can we choose the owner's password, or sign in as them?
No. The owner sets their own password through invitation_url. You never handle it.
Can our application top up a customer's credits, or take payment for them?
No. Credits are bought in JamboSender, by the owner (or recorded by us). Show topup_url.
Can a customer use several applications?
Yes. Each application holds its own key for the same customer; they share one balance and one message log, and the owner can revoke each separately.
What happens to our keys if the customer leaves our application?
The owner revokes your key on their API keys page. You get 401 from then on. Their account, balance and history stay theirs.
Why is a new customer pending? Can we skip it?
Every new customer is reviewed by us before it can send, however it was created. You cannot skip it; poll GET /partner/tenants to see when it is done.
We lost a customer's key.
Call POST /partner/tenants/{id}/keys for a new one, then ask the owner or us to revoke the old one.
We lost the partner key or the webhook secret.
Ask us. We issue a new partner key — the old one keeps working until we revoke it — or a new webhook secret, which replaces the old one immediately, so update your configuration at once.
Is there a sandbox?
Test-mode keys behave like a sandbox for sending (§10). Provisioning has no test mode; use a staging JamboSender or fake owners we can reject.
Our webhook was down for a day.
Each event is tried 5 times over about 2.5 hours, then left as failed in JamboSender. Ask us to send the failed ones again, and in the meantime read statuses with GET /api/v1/messages.
Can we use a customer's key from a mobile app?
No. Keys stay on your server. A key in a mobile app can be extracted and spent.
Getting started
Becoming a partner is a conversation, not a form. Send us your application's name, a technical contact email and your webhook URL, and we will issue the partner key and the webhook secret.