J JamboSender

Integration guide · Partner applications

Sending for many organisations

A complete guide to building an application that sends SMS through JamboSender on behalf of many organisations — provisioning them, linking the ones that already have an account, holding a key per customer, and receiving every event on one signed webhook. Wamiliki, an estate-management platform, is the worked example throughout.

Sending for only one organisation? Your own shop, your own school — then you need none of this. Create a key in the portal and follow the API guide.

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.

The shape of 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

CredentialBelongs toDoesWhere you keep it
Partner key
jsk_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 key
jsk_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 secret
whsec_…
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 needExample
Application nameWamiliki
A technical contact emaildev@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.

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

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
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"
}
FieldRequiredNotes
nameyesThe organisation's name, as its members would recognise it. Max 150.
emailyesThe owner's email. One email belongs to exactly one JamboSender customer; see §5.
owner_nameyesThe person who will own the account. Max 150.
phonenoThe owner's phone. Max 30.
referencenoYour 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 a 409 account_exists or a 422. Repeating the same key gets the same answer forever. Only a 5xx is 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

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:

  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 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.
  3. Treat the customer as not ready. status is pending and can_send is false; 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

StatuscodeMeaning
409account_existsThe email already has a JamboSender account: go to §5.
409idempotency_key_reusedThis Idempotency-Key was used before with a different body.
409idempotency_conflictA request with this key is still running; retry shortly.
422(validation)A field is missing or too long; errors names it.
401unauthorizedThe partner key is wrong or revoked.
403insufficient_scopeYou used a customer key here instead of the partner key.
403app_inactiveYour 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.

409 Conflict
{
  "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_…"
}
  1. Show link_url to the owner as a button ("Connect your JamboSender account"). It carries the reference you sent, expires after 2 hours and works once. Store when you got it; when it is older than 2 hours, get a fresh one from GET /partner/link-url with the same email and reference. Retrying POST /partner/tenants with the same Idempotency-Key would only replay the old, expired link (§4).
  2. 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.
  3. We post the key to your webhook URL.
tenant.connected
{
  "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
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

The pipeline
created (pending) ──► approved by JamboSender ──► sender name approved ──► credits ──► sends
     §4                    us, by hand                days, at the operator    owner tops up
StepWhoHow 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 active with no usable sender name yet. Do not switch a customer to its own key on status alone: read GET /api/v1/sender-names with its key (your partner key cannot) and switch once the name you will send under has usable: 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
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-Key is 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.
  • reference is 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 gets 409 reference_reused. So if you let an operator send a failed message again as a new send (with a new Idempotency-Key), give it a new reference too: Wamiliki appends -retry1, -retry2, …
  • callback_url is optional. Without it, delivery reports go to your application's webhook URL.

7.2 What to retry

ResponseRetry?
202No: accepted. Store messages[0].id to match webhooks.
Connection error, timeout, 5xx, 429Yes, with backoff and the same Idempotency-Key.
409 idempotency_key_reused, 409 reference_reusedNo: a bug on your side, a key or reference reused for a different send.
402, 403, 422No: 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:

The choice, 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:

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

EventWhendata holdsMatch on
message.sentThe operator accepted a message.The message (below).data.id (or data.reference)
message.deliveredConfirmed on the handset.The message.data.id
message.failedFailed, rejected or expired; credits refunded.The message, with error_code.data.id
balance.lowA customer you serve fell below its threshold; at most once a day per customer.tenant, credits, threshold, overdraft, topup_url.data.tenant.id
tenant.connectedAn owner approved a link (§5).tenant, api_key.token, reference.data.reference
A message event
{
  "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 2xx fast, 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.sent for a message you already have as delivered must 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 2xx and ignored.
  • tenant.connected contains 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
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.

statusMeaningCustomer key works?
pendingWaiting for our approval.No, 403 tenant_inactive.
activeApproved.Yes.
suspendedStopped by us (usually payment or abuse).No, 403 tenant_inactive.
rejectedNot 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
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 happenedYou seeWhat 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/tenants and POST /partner/tenants/{id}/keys return jsk_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.connected by having a test owner open the link and approve.
  • Test your signature check with a wrong secret and with a t an 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 2xx fast.
  • Customer keys stored encrypted, one per customer, never logged.
  • Provisioning sends an Idempotency-Key derived from your record and the email.
  • owner.invitation_url reaches the owner.
  • 409 account_exists shows the link_url to the owner; tenant.connected stores the key by reference.
  • Customer statuses are refreshed from GET /partner/tenants.
  • Every send uses that customer's key, a stable Idempotency-Key and a reference.
  • Retries only on network errors, 5xx and 429, with the same Idempotency-Key.
  • 402, 403 tenant_inactive and 422 sender_not_approved are shown to someone who can act on them.
  • 401 on 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, 0002 and 0008.

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

Configuration and storage
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 from GET /partner/tenants.

An estate that is already connected is skipped without calling JamboSender, apart from retrying an invitation email that failed.

12.3 Provisioning

PHP · condensed
$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:

EmailSent whenButton goes to
Set up the estate's SMS account201: a new account was created.JamboSender's invitation_url (set a password; 14 days, once).
Connect the estate's SMS account409 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 reference is stable (wamiliki-org-<id>), so tenant.connected can 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}/keys after a 401. 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-12 and 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:

  1. fetches a fresh link with GET /partner/link-url once the stored one is 90 minutes old, so the link printed for staff always works,
  2. emails the owner the Wamiliki link once, and again only if the owner changes (a new chair),
  3. never posts the customer again.

When the owner approves, the webhook handler passes tenant.connected to a method that:

  1. checks data.reference matches wamiliki-org-<id> and that organisation exists,
  2. stores data.tenant.id, data.tenant.status and data.api_key.token,
  3. clears link_url, error and disconnected_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:

PHP · condensed
$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 is accepted, and JamboSender's messages[0].id is 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).
  • 401 on 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 failed with JamboSender's code, for example JamboSender 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 with 422 sender_not_approved.

12.6 Webhooks

One route, POST /api/webhooks/jambosender:

  1. verify X-JamboSender-Signature against the webhook secret, with a 5-minute window; 401 otherwise,
  2. tenant.connected → store the key (§12.4),
  3. balance.low → find the estate by data.tenant.id, and email its chair and treasurer the balance and a button to data.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,
  4. message.* → find the message by the stored provider id against data.id; unknown ids are acknowledged and ignored,
  5. message.sent moves accepted to sent; message.delivered sets delivered and delivered_at; message.failed sets failed with the error code, unless the message was already delivered,
  6. answer 204.

12.7 Operations

Console
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_inactive is 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 pathKeyScopePurpose
POST /api/v1/partner/tenantspartnerpartner:provisionCreate a customer and get its key (§4).
GET /api/v1/partner/tenantspartnerpartner:provisionCustomers you serve, with status (§9.1).
POST /api/v1/partner/tenants/{id}/keyspartnerpartner:provisionAnother key for a customer you serve (§9.3).
GET /api/v1/partner/link-urlpartnerpartner:provisionA link to connect an existing account (§5).
POST /api/v1/smscustomersms:sendSend (API guide §2).
POST /api/v1/sms/estimatecustomersms:sendPrice a message.
GET /api/v1/sms/{id}, GET /api/v1/messagescustomersms:readRead messages back.
GET /api/v1/balancecustomerbalance:readCredits and top-up link.
GET /api/v1/sender-namescustomersender_names:readWhich 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

StatuscodeWhereMeaning
409account_existsPOST /partner/tenantsThe email has an account; details.link_url.
404not_foundPOST /partner/tenants/{id}/keysNot a customer your application serves.
403insufficient_scopeanyWrong kind of key: partner key on /sms, or customer key on /partner/*.
403app_inactiveanyYour application is turned off.
403tenant_inactivecustomer endpointsThe 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.

Email
—
WhatsApp
—

Back to the API guide