Docs

Keltap API

Keltap is a bulk SMS platform for businesses in Africa. The API lets you send transactional and marketing SMS at scale, verify users with one-time passcodes (OTP), and track delivery status in real time, all through a small set of JSON HTTP endpoints.

This site covers the developer-facing API

Send SMS or WhatsApp templates, request and manage sender names, buy credit, send and verify OTPs, and receive status webhooks, everything a normal integration needs. Admin-only actions (approving a sender name, managing bundle pricing, reseller payouts) stay in the Keltap portal.

What you can build

  • Bulk & single SMS: send one message or thousands in a single request, immediately or scheduled for a future date.
  • WhatsApp template messages: the same send endpoint can push approved WhatsApp templates instead of SMS, billed from a separate WhatsApp wallet.
  • Delivery tracking: every message moves through a clear status lifecycle, queryable by ID or pushed to your webhook URL.
  • OTP verification: generate and verify one-time passcodes for login, signup or transaction confirmation flows, delivered by SMS, WhatsApp, or optionally also email.
  • Custom sender names: request, manage and assign your own approved brand name instead of sending under a shared system sender.
  • Buy credit: top up your SMS balance programmatically, with the same pricing and payment flow as the portal's checkout.
  • Multiple API keys: create a separate, independently revocable key per environment or integration, each with its own optional webhook URL.

How the API is organized

Every request and response follows the same conventions, described in the next few pages:

From there, the reference is grouped by resource:

  • SMS API: send SMS or WhatsApp templates, list messages, fetch a single message.
  • OTP API: send and verify one-time passcodes, over SMS or WhatsApp.
  • Sender Names: request, list, update and assign your own sender names.
  • Credits: browse bundle pricing and buy SMS credit.
  • Webhooks: receive message, sender name, and purchase status updates on your own server.
  • SMPP: connect an existing SMPP client or gateway instead of using the HTTP API.

Quickstart

Once you have an API key (see Authentication), sending an SMS is one request:

Send an SMS
curl -X POST https://portal.keltap.co.tz/api/v1/messages \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sender_name": "MYBRAND", "is_scheduled": false, "messages": [ { "receiver": "0712345678", "content": "Hello Asha, your order is ready" } ] }'

Every response, success or failure, comes back in the same envelope shape, so you can write one response handler for the whole API. Continue to Authentication to get your API key.

Authentication

Every endpoint except a small set marked public requires an API key, sent as a standard Authorization header.

API Keys

Create non-expiring API keys from your account in the Keltap portal, not from the API itself: log in, open the API section, and click New API Key. Give it a name (defaults to "Default" if left blank) so you can tell keys apart, e.g. one per environment or integration. The raw key is shown once:

Created key
{ "success": true, "status_code": 201, "status_text": "Created", "message": "api key has been created", "data": { "id": "1d9b7e3a-2f7e-4b3e-9d0a-8c1a6b2e4f5c", "name": "Production", "api_key": "txf_92c58d699610fc2233f871f05fcb5b78b5f07a483a449525b53c71732af0caf6" } }

Shown once, save it immediately

The server only ever stores a SHA-256 hash of your key, never the raw value, so there is no way to retrieve it again later. If you lose it, create a new one.

Multiple, independently revocable keys

An account can hold any number of active keys at once, there's no single global key that generating a new one invalidates. Create one key per integration or environment, rename or change a key's webhook URL at any time, and delete a single key without affecting any of the account's other keys. Deleting a key takes effect immediately, the very next request authenticated with it fails.

Using an API key

Send it in the Authorization header, prefixed txf_, on any protected endpoint:

Authenticated request
curl https://portal.keltap.co.tz/api/v1/messages \ -H "Authorization: Bearer txf_92c58d699610fc2233f871f05fcb5b78b5f07a483a449525b53c71732af0caf6"

API keys don't expire and aren't tied to a session, so there's no logout for them.

Per-key webhook URL

Each key can optionally carry its own webhook_url, set when you create it or changed later from the portal. Sender name and purchase status callbacks are routed to the webhook_url of whichever key was used to create that sender name request or purchase, falling back to your account's default webhook URL when that key has none set. Message delivery-status callbacks always use the account default, regardless of which key sent the message. See Webhooks for the full routing rules and payload shapes.

Authentication errors

A missing or invalid key, or a disabled/unverified account, returns 401 Unauthorized with a machine-readable error code:

401 Unauthorized
{ "success": false, "status_code": 401, "status_text": "Unauthorized", "message": "invalid api key", "error": "invalid_token" }
StatuserrorMeaning
401missing_tokenNo Authorization header was sent.
401invalid_tokenThe API key doesn't match any account.
401account_not_foundThe account tied to the key no longer exists.
401account_disabledThe account has been disabled by an administrator.
401account_not_verifiedThe account has not completed OTP verification yet.

Base URL

Every endpoint in this reference is relative to a single base URL:

Base URL
https://portal.keltap.co.tz/api/v1

For example, sending an SMS means making a request to https://portal.keltap.co.tz/api/v1/messages. Throughout this reference, paths are written relative to that base, e.g. POST /messages.

All traffic is HTTPS

The API only accepts HTTPS requests. Plain HTTP requests are not supported in production.

Versioning

/api/v1 is the current and only stable version. Breaking changes will be introduced under a new version prefix rather than changing this one in place.

Request & Response Format

Requests

All request bodies are JSON. Send Content-Type: application/json on every POST, PUT, PATCH and DELETE request that includes a body, and authenticate with a bearer token as described in Authentication unless the endpoint is marked public.

Example request
curl -X POST https://portal.keltap.co.tz/api/v1/messages \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sender_name": "MYBRAND", "messages": [ { "receiver": "0712345678", "content": "Hi" } ] }'

Response envelope

Every endpoint, success or failure, responds with the same JSON envelope:

Success
{ "success": true, "status_code": 200, "status_text": "OK", "message": "message has been sent", "data": { } }
Error
{ "success": false, "status_code": 400, "status_text": "Bad Request", "message": "no receiver has been provided", "error": "validation_error" }

success tells you which shape you got without inspecting the status code. On success, data holds the payload and error is omitted. On failure, error is a short machine-readable code you can branch on, and message is a human-readable description safe to log or, in most cases, show to a user.

Pagination

List endpoints (like GET /messages) accept these query parameters:

ParamDefaultDescription
page1Page number.
limit10Records per page (max 1000).
sort_bycreated_atColumn to sort by (whitelisted per endpoint).
sort_dirdescasc or desc.
searchNoneRequired on /search endpoints.

The paginated result is nested inside the standard envelope's data field:

Paginated response
{ "success": true, "status_code": 200, "status_text": "OK", "message": "messages retrieved", "data": { "data": [ { "id": "…", "status": "delivered" } ], "total": 42, "page": 1, "limit": 10, "total_pages": 5, "has_next_page": true, "has_previous_page": false, "next_page": 2, "previous_page": null } }

Data scoping

List and search endpoints only return records the caller owns. keltap admin accounts see everything.

HTTP status codes

StatusStatus TextMeaning
200OKRequest succeeded.
201CreatedA resource was created.
400Bad RequestMissing or invalid fields, see `error` and `message`.
401UnauthorizedMissing/invalid API key or disabled/unverified account.
403ForbiddenThe account type doesn't have permission for this action.
404Not FoundThe route or the requested record doesn't exist.
405Method Not AllowedThe HTTP method isn't supported on this path.
500Internal Server ErrorUnexpected server-side failure.

SMS API

Send single or bulk SMS (or WhatsApp template messages), list your message history, and fetch a single message by id. One SMS unit is 160 characters, longer content is automatically billed as multiple parts.

Send SMS

Creates and queues one or many messages atomically. The sender name (if provided) must be approved, enabled, and owned by, assigned to, or a shared default for the caller. Unknown recipients are auto-created as contacts. Balance (or postpaid limit) is checked and deducted in the same transaction. Admins send for free.

POST/messages
FieldTypeRequiredDescription
sender_namestringoptionalAn approved sender name you own or are assigned to. Omit to send without an attached sender identity. Ignored when channel is whatsapp.
messagesarrayrequiredOne or more { receiver, content } objects. Non-empty.
messages[].receiverstringrequiredRecipient phone number. Normalized to 255… international format.
messages[].contentstringrequiredMessage text. Up to 160 characters is 1 billed SMS unit; longer content is billed as one unit per 153 characters (for example 306 characters = 2 units, 307 = 3, 475 = 4). Text with non-ASCII characters is billed at 70 characters, then 63 per part.
is_scheduledbooleanoptionalDefaults to false. When true, scheduled_date is required.
scheduled_datestring (ISO 8601)optionalRequired when is_scheduled is true. When it's due, the message moves from scheduled to sending.
channelstringoptionalsms (default) or whatsapp. See Send via WhatsApp below for the fields that apply then.

Single message

Request
curl -X POST https://portal.keltap.co.tz/api/v1/messages \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sender_name": "MYBRAND", "is_scheduled": false, "messages": [ { "receiver": "0712345678", "content": "Hello Asha, your order is ready" } ] }'

Bulk messages

Send to many receivers in one call by adding more entries to messages:

Request body
{ "sender_name": "MYBRAND", "is_scheduled": false, "messages": [ { "receiver": "0711111111", "content": "Hello Asha, your order is ready" }, { "receiver": "0722222222", "content": "Hi, your order is ready" } ] }

Scheduled send

Set is_scheduled and a future scheduled_date. The message is stored with status scheduled and picked up by the background sender once due:

Request body
{ "sender_name": "MYBRAND", "is_scheduled": true, "scheduled_date": "2026-07-20T08:00:00Z", "messages": [ { "receiver": "0712345678", "content": "Reminder: your appointment is tomorrow" } ] }

Response

On success the endpoint returns 201 Created with no payload. The message is queued, not returned inline. Look it up afterwards with Get Message by ID or List Messages, or subscribe to status webhooks.

201 Created
{ "success": true, "status_code": 201, "status_text": "Created", "message": "messages have been created", "data": null }

Message lifecycle

Non-scheduled messages start as sending; scheduled ones stay scheduled until due. From there: sending → processing → sent → delivered | undelivered | failed. Delivery status is polled from the provider roughly every 30 seconds and pushed to your webhook_url when it changes. A message can also end as unknown (the outcome could not be confirmed), expired (no delivery report arrived in time) or operator_rejected (the mobile operator refused it; resending will not help).

Safe to repeat: Idempotency-Key

If your request times out or the connection drops, you cannot know whether the messages were created. Send an Idempotency-Key header (up to 128 printable characters, unique per send) with the request. Repeating the same request with the same key does not create or charge the messages a second time and returns 200 saying they were already created. Reusing a key for a different request is refused. Keys are remembered for 48 hours.

Errors

StatuserrorMeaning
400validation_errorNo messages provided, missing scheduled_date, empty content, or an invalid receiver.
400creation_errorSender name isn't approved/enabled, isn't owned or assigned to the caller, or the balance/postpaid limit is insufficient.
401unauthorized / invalid_tokenMissing or invalid API key.

Send via WhatsApp

The same POST /messages endpoint sends WhatsApp messages when channel is "whatsapp". Every WhatsApp send is a template send, freeform content isn't supported on this channel, use template_params per recipient instead.

FieldTypeRequiredDescription
channelstringrequiredMust be "whatsapp".
template_iduuidrequiredAn approved WhatsApp template, created and approved in the portal's WhatsApp section. Every WhatsApp send is a template send, there's no freeform text.
whatsapp_sourcestringoptionaldefault (Keltap's shared WhatsApp number) or own (your own registered number). Defaults to default.
messages[].receiverstringrequiredRecipient phone number. Normalized to 255… international format.
messages[].template_paramsarray of stringsoptionalOrdered body variable values for this recipient, matching the template's placeholders.
Request body
{ "channel": "whatsapp", "template_id": "7c1a2e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "whatsapp_source": "default", "messages": [ { "receiver": "0712345678", "template_params": ["Asha", "KLT-4821"] } ] }

Billed from a separate WhatsApp wallet

WhatsApp sends are charged against your account's USD WhatsApp wallet balance at a live rate based on the template's category, not your regular SMS balance. Top up the wallet from the portal's WhatsApp section.

Errors

StatuserrorMeaning
400validation_errorNo messages provided, or a receiver is missing/invalid.
400creation_errortemplate_id is missing, isn't approved, WhatsApp sending isn't configured for the account, whatsapp_source is "own" without an active registered number, or the WhatsApp wallet balance is insufficient.

List Messages

Paginated list of messages the caller owns (or all messages, for admins).

GET/messages
FieldTypeRequiredDescription
pageintoptionalPage number. Default 1.
limitintoptionalRecords per page. Default 10, max 1000.
sort_bystringoptionalOne of status, count, is_scheduled, scheduled_date, created_at, updated_at. Default created_at.
sort_dirstringoptionalasc or desc. Default desc.
statusstringoptionalFilter by exact status, e.g. delivered, sent, failed.
scheduledbooleanoptionaltrue returns only scheduled messages.
Request
curl "https://portal.keltap.co.tz/api/v1/messages?page=1&limit=20&status=delivered&sort_dir=desc" \ -H "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "messages have been fetched", "data": { "data": [ { "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "sender_name_id": "9a1c...", "sender_name": "MYBRAND", "contact_id": "3d4e...", "phone_number": "255712345678", "contact_name": "Asha M", "content": "Hello Asha, your order is ready", "count": 1, "status": "delivered", "is_scheduled": false, "scheduled_date": null, "message_id": "AT-8817263", "failure_reason": null, "created_by": "c62f7e2a-...", "created_by_name": "jane@example.com", "created_at": "2026-07-10T08:12:41Z", "updated_at": "2026-07-10T08:13:05Z" } ], "total": 42, "page": 1, "limit": 20, "total_pages": 3, "has_next_page": true, "has_previous_page": false, "next_page": 2, "previous_page": null } }

There's also GET /messages/search?search=…, which accepts the same pagination parameters and matches against message content and recipient.

Get Message by ID

Fetch a single message, including its joined sender name, recipient and contact name.

GET/messages/{id}
Request
curl https://portal.keltap.co.tz/api/v1/messages/6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f \ -H "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "message has been fetched", "data": { "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "sender_name_id": "9a1c...", "sender_name": "MYBRAND", "contact_id": "3d4e...", "phone_number": "255712345678", "contact_name": "Asha M", "content": "Hello Asha, your order is ready", "count": 1, "status": "delivered", "is_scheduled": false, "scheduled_date": null, "message_id": "AT-8817263", "channel": "sms", "template_id": null, "template_name": null, "whatsapp_number": null, "whatsapp_phone_number_id": null, "next_check_at": null, "failure_reason": null, "created_by": "c62f7e2a-...", "created_by_name": "jane@example.com", "created_at": "2026-07-10T08:12:41Z", "updated_by": null, "updated_by_name": null, "updated_at": "2026-07-10T08:13:05Z" } }

Message object

FieldTypeRequiredDescription
iduuidrequiredMessage id.
statusstringrequiredscheduled, sending, processing, processing_status, sent, delivered, undelivered, or failed.
contentstringrequiredMessage body as sent.
countintrequiredBilled SMS units, ceil(length / 160).
sender_namestring | nulloptionalJoined sender name, when one was attached.
phone_numberstring | nulloptionalJoined recipient number (255… format).
contact_namestring | nulloptionalJoined contact name, when known.
is_scheduledbooleanrequiredWhether this message was created as a scheduled send.
scheduled_datestring | nulloptionalISO 8601 timestamp the message is/was due.
message_idstring | nulloptionalProvider-assigned message id, once sent.
channelstringrequiredsms or whatsapp.
template_id / template_nameuuid | string | nulloptionalThe WhatsApp template used, when channel is whatsapp.
whatsapp_phone_number_idstring | nulloptionalThe Meta phone number id the WhatsApp message was sent from, when channel is whatsapp.
failure_reasonstring | nulloptionalSet when status is failed.
created_atstringrequiredISO 8601 creation timestamp.

Errors

StatuserrorMeaning
400invalid_parameterThe id path segment isn't a valid UUID.
404not_foundNo message exists with that id.

OTP API

Send one-time passcodes for login, signup or transaction confirmation, and verify them server-side. OTPs are single-use and expire after 30 minutes.

Send OTP

Sends a 6-digit code by SMS (and by email, if email_address is provided). Without sender_name, the code is delivered for free via the system sender. With a custom, approved sender_name, delivery is billed like a normal message from the caller's balance. This is the flow resellers integrate with to send OTPs under their own brand.

POST/otps
FieldTypeRequiredDescription
phone_numberstringrequiredRecipient phone number. Normalized to 255… international format.
email_addressstringoptionalWhen set, the OTP is also emailed to this address.
sender_namestringoptionalAn approved sender name you own. Omit to send free from the system sender ("Keltap"). Ignored when channel is whatsapp.
brand_namestringoptionalBrand name interpolated into the OTP message. Defaults to "Keltap".
channelstringoptionalsms (default) or whatsapp.
whatsapp_sourcestringoptionaldefault (Keltap's shared WhatsApp number) or own (your own registered number). Only used when channel is whatsapp.
Request
curl -X POST https://portal.keltap.co.tz/api/v1/otps \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "0712345678", "email_address": "customer@example.com", "sender_name": "MYBRAND", "brand_name": "My Brand" }'
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "otp has been sent", "data": null }

Branded OTPs require authentication

POST /otps sits behind the same bearer-token auth as every other endpoint. If you pass a custom sender_name, the message is charged to the authenticated caller's balance. There's no way to send a billed, branded OTP anonymously.

Sending over WhatsApp

Set channel to "whatsapp" and whatsapp_source to pick which registered number sends it. sender_name is ignored on this channel:

Request
curl -X POST https://portal.keltap.co.tz/api/v1/otps \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "0712345678", "brand_name": "My Brand", "channel": "whatsapp", "whatsapp_source": "default" }'

WhatsApp OTPs are always billed, authenticated sends

There's no free system number for WhatsApp, every WhatsApp OTP is charged from the authenticated caller's WhatsApp wallet balance. Account-verification OTPs (registration, login, forgot-password) always stay on the SMS channel.

Errors

StatuserrorMeaning
400validation_errorphone_number was missing or blank.
400creation_errorsender_name isn't approved/owned, channel is whatsapp without an authenticated caller, or WhatsApp sending isn't available (misconfigured account/number).
401unauthorized / invalid_tokenMissing or invalid API key.

Verify OTP

Consumes a code, each OTP can only be verified once. This endpoint is public, so it can be called directly from a client app during signup or login flows, without an API key.

POST/otps/verifyPublic
FieldTypeRequiredDescription
codenumberrequiredThe 6-digit OTP code, as a JSON number (not a string).
phone_numberstringoptionalMust match the number the OTP was sent to. Provide phone_number and/or email_address.
email_addressstringoptionalAlternative match target if the OTP was also emailed.
Request
curl -X POST https://portal.keltap.co.tz/api/v1/otps/verify \ -H "Content-Type: application/json" \ -d '{ "code": 484895, "phone_number": "0712345678" }'
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "otp has been verified", "data": null }

Errors

StatuserrorMeaning
400validation_errorcode was missing or 0.
400verification_errorNo matching, unexpired OTP was found for that code and phone/email.

Sender Names

Request your own branded sender name instead of sending under the shared system sender, manage who on your account can use it, and track its review status.

Requests go through admin review

A new sender name is created with status requested. An admin approves or declines it, you can't approve your own. Poll GET /sender-names/{id} or subscribe to the sender name status webhook to know when a decision is made.

Request a Sender Name

POST/sender-names
FieldTypeRequiredDescription
namestringrequiredThe sender name to request. Max 11 characters.
reasonstringrequiredWhy you're requesting it, shown to the reviewing admin.
is_defaultbooleanoptionalRequest this as your account's default sender, used when a Send SMS call omits sender_name.
Request
curl -X POST https://portal.keltap.co.tz/api/v1/sender-names \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "MYBRAND", "reason": "Marketing campaigns for my store" }'
201 Created
{ "success": true, "status_code": 201, "status_text": "Created", "message": "sender name has been created", "data": { "id": "9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "name": "MYBRAND", "status": "requested", "is_default": false, "is_disabled": false, "created_at": "2026-09-19T08:12:41Z" } }

You don't pick the sending gateway

Which SMS provider actually delivers messages under this name is decided by Keltap when the request is reviewed, not by you at request time — there's no provider field to set, and it isn't included in the API response.

Errors

StatuserrorMeaning
400validation_errorname or reason was missing, or name is longer than 11 characters.
400creation_errorThat name is already taken by another sender name.
401unauthorized / invalid_tokenMissing or invalid API key.

List Sender Names

Returns sender names you created, are assigned to, or that are the shared default, plus your customers' if you're a reseller.

GET/sender-names
FieldTypeRequiredDescription
statusstringoptionalrequested, approved, or declined.
page / limit / sort_by / sort_dir—optionalStandard pagination; see Request & Response Format.
Request
curl "https://portal.keltap.co.tz/api/v1/sender-names?status=approved" \ -H "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "sender names have been fetched", "data": { "data": [ { "id": "9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "name": "MYBRAND", "status": "approved", "is_default": false, "is_disabled": false, "created_at": "2026-09-19T08:12:41Z", "created_by": "c62f7e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "created_by_name": "you@example.com", "updated_by": null, "updated_by_name": null, "updated_at": null, "reseller_id": null, "users": ["c62f7e2a-...", "d81a9b3c-..."] } ], "total": 1, "page": 1, "limit": 10, "total_pages": 1, "has_next_page": false, "has_previous_page": false, "next_page": null, "previous_page": null } }

GET /sender-names/search?search=… accepts the same filters plus a search term matched against name and status. GET /sender-names/{id} fetches one by id.

Sender name object

Every response includes these fields, regardless of whose sender name it is:

FieldTypeRequiredDescription
iduuidrequiredSender name id.
namestringrequiredThe sender name text, as it appears to recipients.
statusstringrequiredrequested, approved, or declined.
is_defaultbooleanrequiredWhether this is the account's default sender.
is_disabledbooleanrequiredSet by an admin independently of status, an approved name can still be disabled.
created_atstringrequiredISO 8601 creation timestamp.

Extra fields on your own sender names

A sender name you don't own — visible only because it's a shared default — comes back with just the fields above. One you created, or one belonging to your own reseller customers, also includes:
FieldTypeRequiredDescription
created_by / created_by_nameuuid / stringoptionalWho requested it.
updated_by / updated_by_nameuuid | null / string | nulloptionalThe admin who last approved/declined/updated it, if any.
updated_atstring | nulloptionalISO 8601 timestamp of the last update.
reseller_iduuid | nulloptionalSet when it belongs to one of your customers, if you're a reseller.
usersarray of uuidsoptionalWhich of your account's users are allowed to send with it.
api_key_iduuid | nulloptionalThe API key that created it, if it was created via the API rather than the portal.

Update a Sender Name

You can update a sender name you own; a reseller can also update their customers'.

PUT/sender-names/{id}
FieldTypeRequiredDescription
namestringrequiredMax 11 characters.
is_defaultbooleanoptionalWhether this should be the account's default sender.
Request
curl -X PUT https://portal.keltap.co.tz/api/v1/sender-names/9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "MYBRAND", "is_default": true }'

Assign Users

Replaces the full list of user ids allowed to send with this sender name, send the complete list each time, not just additions.

PATCH/sender-names/{id}/assign
Request
curl -X PATCH https://portal.keltap.co.tz/api/v1/sender-names/9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f/assign \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "users": ["c62f7e2a-...", "d81a9b3c-..."] }'

Delete a Sender Name

DELETE/sender-names/{id}
Request
curl -X DELETE https://portal.keltap.co.tz/api/v1/sender-names/9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f \ -H "Authorization: Bearer $API_KEY"

DELETE /sender-names/bulk takes { "ids": ["…", "…"] } to delete several at once.

Credits

Buy SMS credit programmatically instead of through the portal's checkout: browse the bundle prices available to your account, create a purchase, and track it to payment.

Check Balance

Returns the SMS and WhatsApp wallet balance available to your account, so you can check you have enough credit before sending instead of reacting to a failed send.

GET/users/self/balance
Request
curl https://portal.keltap.co.tz/api/v1/users/self/balance \ -H "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "balance has been fetched", "data": { "sms_balance": 8420, "whatsapp_balance": 12.5, "withdraw_balance": 0, "balance_threshold": 100 } }

sms_balance is your remaining SMS credit count, whatsapp_balance is your remaining WhatsApp wallet balance in USD. withdraw_balance and balance_threshold only apply to reseller accounts and to the low-balance alert set in the portal, respectively.

Browse Bundles

Lists the price tiers available for your account to buy against. The tier your amount is checked against on Buy Credit is resolved the same way: a bundle assigned to you, else one created for your account type, else the platform default.

GET/bundles/all?condition=buy
Request
curl "https://portal.keltap.co.tz/api/v1/bundles/all?condition=buy" \ -H "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "status_code": 200, "status_text": "OK", "message": "bundles have been fetched", "data": [ { "id": "3f2a1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "name": "standard", "items": [ { "from": 1, "to": 5000, "price": 20 }, { "from": 5001, "to": 100000, "price": 17 } ], "is_default": true, "type": "customer" } ] }

items is a list of { from, to, price } tiers, price is the per-SMS retail price for a purchase whose quantity falls in that from–to range. The paginated GET /bundles?condition=buy accepts the same filter.

Buy Credit

Creates a pending purchase. Give a customer_phone_number and a mobile-money USSD prompt is sent to that number; without one, arrange payment and confirm it with Check Purchase Status once it's done.

POST/purchases
FieldTypeRequiredDescription
quantityintrequiredNumber of SMS credits to buy.
amountnumberrequiredTotal price you're paying. Must clear the retail floor price resolved for your account and quantity (see below) or the request is rejected.
customer_namestringoptionalShown on the purchase record.
customer_emailstringoptionalShown on the purchase record.
customer_phone_numberstringoptionalWhen set, a mobile-money USSD collection is initiated for this number and the purchase stays pending until it's confirmed.
Request
curl -X POST https://portal.keltap.co.tz/api/v1/purchases \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "quantity": 10000, "amount": 200000, "customer_name": "Jane Doe", "customer_email": "jane@example.com", "customer_phone_number": "0712345678" }'
201 Created
{ "success": true, "status_code": 201, "status_text": "Created", "message": "purchase has been created", "data": { "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "generated_number": "104822", "reference": "104822", "quantity": 10000, "amount": 200000, "status": "pending", "customer_name": "Jane Doe", "customer_email": "jane@example.com", "customer_phone_number": "255712345678", "created_at": "2026-09-19T08:12:41Z" } }

Credit lands once the purchase is paid

A purchase starts pending and becomes paid once the payment gateway confirms it, your SMS balance is credited at that point, not at creation. Subscribe to the purchase status webhook to be notified instead of polling.

Errors

StatuserrorMeaning
400validation_errorquantity or amount was missing or zero.
400creation_erroramount is below your resolved floor price for this quantity, or no bundle price tier applies at all.
401unauthorized / invalid_tokenMissing or invalid API key.

Check Purchase Status

Manually re-checks a pending purchase with the payment gateway, useful if you'd rather poll than rely on the webhook. A background job also does this automatically roughly every 30 seconds for recent pending purchases.

POST/purchases/status
Request
curl -X POST https://portal.keltap.co.tz/api/v1/purchases/status \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f" }'

Returns 200 OK once the gateway confirms payment (the purchase is now paid). Anything else, still pending, or a gateway-reported failure, comes back as 400; check back later or via the webhook rather than retrying immediately.

StatuserrorMeaning
400creation_errorNot yet confirmed paid, either genuinely still pending, or the gateway reported a failure.

List Purchases

Paginated list of purchases you own.

GET/purchases
FieldTypeRequiredDescription
statusstringoptionalpending or paid.
page / limit / sort_by / sort_dir—optionalStandard pagination; see Request & Response Format.
Request
curl "https://portal.keltap.co.tz/api/v1/purchases?status=paid" \ -H "Authorization: Bearer $API_KEY"

GET /purchases/search?search=… accepts the same filters. GET /purchases/{id} fetches one by id.

Webhooks

Register a webhook URL to get pushed status updates instead of polling for them. Every callback shares the same envelope shape, with an entity field telling you which kind of update it is.

Registering a webhook URL

Webhook URLs are account configuration, not an API call, there's no endpoint to set or change one programmatically. Set them from your account in the Keltap portal (the API section):

  • Per API key: each of your API keys can carry its own webhook_url, set when the key is created or changed later. Sender name and purchase status callbacks are routed to the webhook_url of the key that was used to create that sender name request or purchase.
  • Account default: your account also has one default webhook_url, used for every message delivery-status callback (these aren't routed per key), and as the fallback for sender name/purchase callbacks when the creating key has none set of its own, or the record was created from the portal rather than through the API.

No signature, no retries

Callback requests aren't signed, and a failed delivery to your endpoint is logged but not retried. If you need a canonical record, treat the callback as a prompt to re-fetch the underlying resource rather than trusting the payload alone, and keep your endpoint fast and reliably reachable: requests are sent with Content-Type: application/json and a 10-second send timeout, and there's no backoff or redelivery. Respond quickly with any 2xx status to acknowledge receipt.

Message Status Callback

Delivery status is checked roughly every 30 seconds. Whenever a message you own transitions to delivered or undelivered, Keltap sends an HTTP POST with a JSON body to your account's default webhook_url:

POST <your account webhook_url>
{ "entity": "messages", "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "status": "delivered" }
FieldTypeRequiredDescription
entitystringrequiredAlways "messages" on this callback.
iduuidrequiredThe message id. Call Get Message by ID to fetch full details if you need them.
statusstringrequiredThe message's new status: delivered or undelivered.

A message moves through scheduled → sending → processing → sent → delivered | undelivered | failed. Only the delivered / undelivered transitions above are pushed to your webhook; poll List Messages if you also need to observe earlier stages like sending or sent.

Sender Name Status Callback

Fired whenever an admin approves, declines, or enables/disables a sender name request you created, to the webhook_url of the API key that created it (or your account default, see Registering a webhook URL above):

POST <resolved webhook_url>
{ "entity": "sender-names", "id": "9a1c1e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "status": "approved", "is_disabled": false }
FieldTypeRequiredDescription
entitystringrequiredAlways "sender-names" on this callback.
iduuidrequiredThe sender name id.
statusstringrequiredrequested, approved, or declined.
is_disabledbooleanrequiredWhether an admin has since disabled the sender name. Included alongside status because either field can change independently of the other.

Purchase Status Callback

Fired when a bundle purchase's payment is confirmed, to the webhook_url of the API key that created the purchase (or your account default):

POST <resolved webhook_url>
{ "entity": "purchases", "id": "3d4e6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "status": "paid" }
FieldTypeRequiredDescription
entitystringrequiredAlways "purchases" on this callback.
iduuidrequiredThe purchase id.
statusstringrequiredpending or paid.

SMPP

SMPP is the protocol most messaging platforms and gateways already speak. If your software can send over SMPP, connect it to Keltap instead of integrating the HTTP API. Messages cost the same and are billed to the same account, balance and sender names; the two ways of sending can be used side by side.

HTTP or SMPP?

Use SMPP when your software or gateway already has an SMPP client, or you send steady high volume over one long-lived connection. Use the HTTP API when your code runs somewhere with a changing outgoing address (serverless functions, some hosting platforms), when you need scheduling or an Idempotency-Key, or for WhatsApp.

Getting Access

It is self-serve and free, like the API. Sign in to the Keltap portal and open the API section, then SMPP access:

  1. Create a login. Give it a label you'll recognise, such as "Billing system", and list the IP addresses your software will connect from.
  2. Copy the system_id and password the portal shows. The password is shown once; only a hash is stored, so if you lose it you reset it and get a new one.
  3. Put the server, port, system_id and password into your SMPP client and bind.

Resellers and their customers get the same section for their own accounts. A sub-customer's messages are billed from their own balance and use only the sender names assigned to them. API keys can't manage SMPP logins: that is done from the portal while signed in.

You can switch a login off, reset its password, change its address list or delete it at any time. Each of those closes the connections using it at once. Keltap can also suspend a login, for example if it is being abused; a suspended login can't be switched back on by its owner.

Connection Settings

FieldTypeRequiredDescription
Server and porthost:portrequiredShown in the portal under API → SMPP access, next to your logins. Use exactly what it shows.
EncryptionTLSrequiredRequired. A plain, unencrypted connection is refused, because an SMPP bind sends the password as-is.
Bind typestringrequiredbind_transceiver, or one bind_transmitter plus one bind_receiver. Sending and receiving delivery receipts both need a receiving side.
system_idstringrequiredThe login name the portal gave you. It is generated for you, you can't choose it.
passwordstringrequiredThe password the portal showed once when you created or reset the login. SMPP limits it to 8 characters.
system_typestringoptionalIgnored. Leave it empty.
interface_versionintegeroptionalSMPP 3.4 (0x34).

Keep the password safe

Anyone with the system_id and password who connects from a listed address can send messages on your account. Store them like an API key, and reset the password if it may have leaked.

Static vs Dynamic IP

A login only accepts connections from the IP addresses you listed, because an SMPP password is limited to 8 characters and can't be as strong as an API key. The list is your main protection.

  • Use a static IP. Connect from a server with a fixed address, for example a cloud server with a reserved or elastic IP, or one behind a NAT gateway that has a fixed address.
  • If your address changes, connections from the new one are refused (ESME_RBINDFAIL), your software keeps retrying and messages fail until you add the new address in the portal. Changing the list also ends any connection that is open, so update it at a quiet moment.
  • Don't use a wide range to get around this. We accept single addresses and ranges of up to 256 addresses (IPv6: a /48), only public internet addresses, and at most 10 entries per login.
  • Serverless or ever-changing environments: use the HTTP API, which works from anywhere with an API key.

Sending a Message

Send a normal submit_sm. Each one is processed by the same code as an HTTP send: the sender name is checked, the balance is checked and charged, and the message is queued. The message is billed in segments exactly as on the HTTP API.

FieldTypeRequiredDescription
source_addrstringrequiredOne of your approved sender names (address type 5, numbering plan 0). Anything else is refused with ESME_RINVSRCADR. A sub-customer can only use the sender names assigned to them.
destination_addrstringrequiredA phone number, international like 255712345678 (type 1, numbering plan 1) or local like 0712345678.
short_message / message_payloadbytesrequiredThe text. For anything that doesn't fit one short_message, put it in the message_payload parameter, up to 1600 characters. Don't split a long message yourself.
data_codingintegeroptional0, 1 (ASCII), 3 (Latin-1) or 8 (UCS-2). Treat 0 as ASCII. As on the HTTP API, characters outside ASCII are removed before sending.
registered_deliveryintegeroptionalSet bit 0 (value 1) to get a delivery receipt for the message.
schedule_delivery_timestringoptionalNot supported. A submit that sets it is refused with ESME_RINVSCHED rather than sent immediately.
validity_periodstringoptionalAccepted and ignored.

The message_id in the submit_sm_resp is the message's id, the same one you see in the portal and in Get Message by ID. Keep it to match the delivery receipt.

In the portal's message report, messages sent this way are shown as sent via SMPP, and you can filter by it.

A timeout is not a failure

SMPP has no idempotency key. If a submit_sm times out you can't tell whether it was accepted, and sending it again may send it twice. This is true of every SMSC. Look the message up before resending, or use the HTTP API with an Idempotency-Key where that matters.

Delivery Receipts

Set registered_delivery to 1 and Keltap sends a deliver_sm with esm_class 0x04 when the message reaches a final state. Acknowledge each one with a deliver_sm_resp. The text uses the conventional format, and the receipted_message_id and message_state parameters are set as well:

deliver_sm short_message
id:6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f sub:001 dlvrd:001 submit date:2610111230 done date:2610111231 stat:DELIVRD err:000 text:Your code is 4821
  • id is the message id from the submit_sm_resp.
  • stat is DELIVRD, UNDELIV, EXPIRED or REJECTD.
  • Only final results are sent. A message still being retried isn't reported as failed.
  • Times are UTC.
  • A receipt you didn't acknowledge is sent again after a growing delay. If you are not connected, receipts wait and are delivered when you bind again, for up to 72 hours. You need a receiving bind (transceiver or receiver) to get them.

Your account's webhook still fires for these messages as well, if you've set one.

Error Codes

Refusals come back as the command status of the bind_*_resp or submit_sm_resp.

StatusSMPP statusMeaning
0x0000000EESME_RINVPASWDBind refused: wrong password, or an unknown system_id (the two look the same on purpose).
0x0000000DESME_RBINDFAILBind refused: your address isn't on the login's list, the login is switched off or suspended, or you're over its connection limit. Also returned to an address that failed too many binds in a minute.
0x00000004ESME_RINVBNDSTSYou tried to submit on a receive-only bind.
0x0000000AESME_RINVSRCADRSender name unknown, not approved, disabled, or not yours.
0x0000000BESME_RINVDSTADRNot a valid phone number.
0x00000001ESME_RINVMSGLENEmpty after removing non-ASCII characters, or too long.
0x00000043ESME_RINVESMCLASSA split (UDH) message. Send it as one submit_sm with message_payload instead.
0x000000C1ESME_ROPTPARNOTALLWDA sar_* split-message parameter. Use message_payload instead.
0x00000061ESME_RINVSCHEDschedule_delivery_time was set. Scheduling isn't supported over SMPP.
0x00000045ESME_RSUBMITFAILNot enough balance, or the account is disabled. SMPP has no "out of credit" status, so this is it.
0x00000058ESME_RTHROTTLEDOver the login's messages-per-second or unanswered-submit window. Safe to retry after a short pause.
0x00000008ESME_RSYSERROur side. Try again later.

Limits & Behaviour

FieldTypeRequiredDescription
Connectionsper loginoptionalDefault 2 at once. Over it, the new bind is refused.
Messages per secondper connectionoptionalDefault 10.
Windowper connectionoptionalDefault 10 submits sent but not yet answered. Don't send more before the responses come back.
Loginsper accountoptionalUp to 3 per account. Make one per system you connect.
Keep-alivesecondsoptionalSend enquire_link at least every 60 seconds. A connection silent for 3 minutes is closed.
  • The limits above are per login and are the defaults. Ask support if you need more.
  • Your account must be enabled and verified, as for the API. This is checked on every submit, so disabling the account stops a connected client at once.
  • Not supported: scheduled delivery, split (UDH or sar_*) messages, and incoming (mobile-originated) messages.
  • If your connection drops, reconnect and bind again. Receipts for messages sent in the meantime are waiting for you.

Code Samples

The samples use the popular open-source smpplib (Python), smpp (Node.js) and onlinecity/php-smpp (PHP) packages. Check the option names against the version you install. Replace the server, port, system_id, password and sender name with your own.

Python (smpplib)
import re import ssl import smpplib.client HOST, PORT = "smpp.example.com", 2776 # from the portal SYSTEM_ID, PASSWORD = "your-system-id", "your-pass" client = smpplib.client.Client(HOST, PORT, ssl_context=ssl.create_default_context()) def on_submit_response(pdu): # the message id; keep it to match the delivery receipt later print("accepted", pdu.message_id) def on_receipt(pdu): text = pdu.short_message.decode("ascii", "replace") match = re.search(r"id:(\S+).*stat:(\w+)", text) if match: print("receipt", match.group(1), match.group(2)) client.set_message_sent_handler(on_submit_response) client.set_message_received_handler(on_receipt) client.connect() client.bind_transceiver(system_id=SYSTEM_ID, password=PASSWORD) client.send_message( source_addr_ton=5, source_addr_npi=0, source_addr="MYBRAND", dest_addr_ton=1, dest_addr_npi=1, destination_addr="255712345678", short_message=b"Your code is 4821", registered_delivery=True, ) client.listen() # blocks; receipts arrive in on_receipt
Node.js (smpp)
const smpp = require("smpp"); const session = smpp.connect({ url: "ssmpp://smpp.example.com:2776" }, () => { session.bind_transceiver( { system_id: "your-system-id", password: "your-pass" }, (pdu) => { if (pdu.command_status !== 0) { console.error("bind refused", pdu.command_status); return; } session.submit_sm( { source_addr_ton: 5, source_addr_npi: 0, source_addr: "MYBRAND", dest_addr_ton: 1, dest_addr_npi: 1, destination_addr: "255712345678", short_message: "Your code is 4821", registered_delivery: 1, }, (response) => { if (response.command_status === 0) console.log("accepted", response.message_id); else console.error("refused", response.command_status); } ); } ); }); session.on("deliver_sm", (pdu) => { session.send(pdu.response()); // acknowledge every receipt const text = pdu.short_message.message.toString(); const match = text.match(/id:(\S+).*stat:(\w+)/); if (match) console.log("receipt", match[1], match[2]); }); session.on("error", (error) => console.error(error));
PHP (onlinecity/php-smpp)
<?php // composer require onlinecity/php-smpp require __DIR__ . "/vendor/autoload.php"; use smpp\Address; use smpp\Client as SmppClient; use smpp\Smpp; use smpp\transport\Socket; $transport = new Socket(["smpp.example.com"], 2776); // from the portal $transport->setRecvTimeout(10000); $smpp = new SmppClient($transport); $transport->open(); $smpp->bindTransceiver("your-system-id", "your-pass"); $from = new Address("MYBRAND", Smpp::TON_ALPHANUMERIC); $to = new Address(255712345678, Smpp::TON_INTERNATIONAL, Smpp::NPI_E164); // the message id is returned: keep it to match the delivery receipt $messageId = $smpp->sendSMS( $from, $to, "Your code is 4821", null, Smpp::DATA_CODING_DEFAULT ); echo "accepted: " . $messageId . PHP_EOL; $smpp->close();

PHP and TLS

Keltap requires TLS, and the PHP library above talks plain TCP. If it can't open a TLS connection in your setup, put a local TLS tunnel such as stunnel in front of it: the tunnel connects to the Keltap server and your PHP code connects to the tunnel on 127.0.0.1. If that is more than you want, PHP is also well served by the plain HTTP API.

Support

Need help with integration, billing, or your account? The Keltap team is reachable through any of these channels.

Before you reach out

  • Check the response envelope's error code against the tables on Request & Response Format and each endpoint's reference. Most integration issues surface there directly.
  • Have your account email/phone and, if relevant, the message or OTP id handy.
  • For delivery problems, include the message status and, if you have it, the id from Get Message by ID.