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:
Authentication: generate a long-lived API key from the portal.
Base URL: the single host every endpoint is relative to.
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-XPOSThttps://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.
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:
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"}
Status
error
Meaning
401
missing_token
No Authorization header was sent.
401
invalid_token
The API key doesn't match any account.
401
account_not_found
The account tied to the key no longer exists.
401
account_disabled
The account has been disabled by an administrator.
401
account_not_verified
The account has not completed OTP verification yet.
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.
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.
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:
Param
Default
Description
page
1
Page number.
limit
10
Records per page (max 1000).
sort_by
created_at
Column to sort by (whitelisted per endpoint).
sort_dir
desc
asc or desc.
search
None
Required on /search endpoints.
The paginated result is nested inside the standard envelope's data field:
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
Field
Type
Required
Description
sender_name
string
optional
An approved sender name you own or are assigned to. Omit to send without an attached sender identity. Ignored when channel is whatsapp.
messages
array
required
One or more { receiver, content } objects. Non-empty.
messages[].receiver
string
required
Recipient phone number. Normalized to 255… international format.
messages[].content
string
required
Message 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_scheduled
boolean
optional
Defaults to false. When true, scheduled_date is required.
scheduled_date
string (ISO 8601)
optional
Required when is_scheduled is true. When it's due, the message moves from scheduled to sending.
channel
string
optional
sms (default) or whatsapp. See Send via WhatsApp below for the fields that apply then.
Single message
Request
curl-XPOSThttps://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
Status
error
Meaning
400
validation_error
No messages provided, missing scheduled_date, empty content, or an invalid receiver.
400
creation_error
Sender name isn't approved/enabled, isn't owned or assigned to the caller, or the balance/postpaid limit is insufficient.
401
unauthorized / invalid_token
Missing 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.
Field
Type
Required
Description
channel
string
required
Must be "whatsapp".
template_id
uuid
required
An 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_source
string
optional
default (Keltap's shared WhatsApp number) or own (your own registered number). Defaults to default.
messages[].receiver
string
required
Recipient phone number. Normalized to 255… international format.
messages[].template_params
array of strings
optional
Ordered body variable values for this recipient, matching the template's placeholders.
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
Status
error
Meaning
400
validation_error
No messages provided, or a receiver is missing/invalid.
400
creation_error
template_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
Field
Type
Required
Description
page
int
optional
Page number. Default 1.
limit
int
optional
Records per page. Default 10, max 1000.
sort_by
string
optional
One of status, count, is_scheduled, scheduled_date, created_at, updated_at. Default created_at.
sort_dir
string
optional
asc or desc. Default desc.
status
string
optional
Filter by exact status, e.g. delivered, sent, failed.
{"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.
{"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
Field
Type
Required
Description
id
uuid
required
Message id.
status
string
required
scheduled, sending, processing, processing_status, sent, delivered, undelivered, or failed.
content
string
required
Message body as sent.
count
int
required
Billed SMS units, ceil(length / 160).
sender_name
string | null
optional
Joined sender name, when one was attached.
phone_number
string | null
optional
Joined recipient number (255… format).
contact_name
string | null
optional
Joined contact name, when known.
is_scheduled
boolean
required
Whether this message was created as a scheduled send.
scheduled_date
string | null
optional
ISO 8601 timestamp the message is/was due.
message_id
string | null
optional
Provider-assigned message id, once sent.
channel
string
required
sms or whatsapp.
template_id / template_name
uuid | string | null
optional
The WhatsApp template used, when channel is whatsapp.
whatsapp_phone_number_id
string | null
optional
The Meta phone number id the WhatsApp message was sent from, when channel is whatsapp.
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
Field
Type
Required
Description
phone_number
string
required
Recipient phone number. Normalized to 255… international format.
email_address
string
optional
When set, the OTP is also emailed to this address.
sender_name
string
optional
An approved sender name you own. Omit to send free from the system sender ("Keltap"). Ignored when channel is whatsapp.
brand_name
string
optional
Brand name interpolated into the OTP message. Defaults to "Keltap".
channel
string
optional
sms (default) or whatsapp.
whatsapp_source
string
optional
default (Keltap's shared WhatsApp number) or own (your own registered number). Only used when channel is whatsapp.
{"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:
⚠️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
Status
error
Meaning
400
validation_error
phone_number was missing or blank.
400
creation_error
sender_name isn't approved/owned, channel is whatsapp without an authenticated caller, or WhatsApp sending isn't available (misconfigured account/number).
401
unauthorized / invalid_token
Missing 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
Field
Type
Required
Description
code
number
required
The 6-digit OTP code, as a JSON number (not a string).
phone_number
string
optional
Must match the number the OTP was sent to. Provide phone_number and/or email_address.
email_address
string
optional
Alternative match target if the OTP was also emailed.
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
Field
Type
Required
Description
name
string
required
The sender name to request. Max 11 characters.
reason
string
required
Why you're requesting it, shown to the reviewing admin.
is_default
boolean
optional
Request this as your account's default sender, used when a Send SMS call omits sender_name.
Request
curl-XPOSThttps://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
Status
error
Meaning
400
validation_error
name or reason was missing, or name is longer than 11 characters.
400
creation_error
That name is already taken by another sender name.
401
unauthorized / invalid_token
Missing 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
Field
Type
Required
Description
status
string
optional
requested, approved, or declined.
page / limit / sort_by / sort_dir
—
optional
Standard pagination; see Request & Response Format.
{"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:
Field
Type
Required
Description
id
uuid
required
Sender name id.
name
string
required
The sender name text, as it appears to recipients.
status
string
required
requested, approved, or declined.
is_default
boolean
required
Whether this is the account's default sender.
is_disabled
boolean
required
Set by an admin independently of status, an approved name can still be disabled.
created_at
string
required
ISO 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:
Field
Type
Required
Description
created_by / created_by_name
uuid / string
optional
Who requested it.
updated_by / updated_by_name
uuid | null / string | null
optional
The admin who last approved/declined/updated it, if any.
updated_at
string | null
optional
ISO 8601 timestamp of the last update.
reseller_id
uuid | null
optional
Set when it belongs to one of your customers, if you're a reseller.
users
array of uuids
optional
Which of your account's users are allowed to send with it.
api_key_id
uuid | null
optional
The 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}
Field
Type
Required
Description
name
string
required
Max 11 characters.
is_default
boolean
optional
Whether this should be the account's default sender.
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.
{"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.
{"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
Field
Type
Required
Description
quantity
int
required
Number of SMS credits to buy.
amount
number
required
Total price you're paying. Must clear the retail floor price resolved for your account and quantity (see below) or the request is rejected.
customer_name
string
optional
Shown on the purchase record.
customer_email
string
optional
Shown on the purchase record.
customer_phone_number
string
optional
When set, a mobile-money USSD collection is initiated for this number and the purchase stays pending until it's confirmed.
{"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
Status
error
Meaning
400
validation_error
quantity or amount was missing or zero.
400
creation_error
amount is below your resolved floor price for this quantity, or no bundle price tier applies at all.
401
unauthorized / invalid_token
Missing 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.
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.
Status
error
Meaning
400
creation_error
Not yet confirmed paid, either genuinely still pending, or the gateway reported a failure.
List Purchases
Paginated list of purchases you own.
GET/purchases
Field
Type
Required
Description
status
string
optional
pending or paid.
page / limit / sort_by / sort_dir
—
optional
Standard pagination; see Request & Response Format.
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:
The message id. Call Get Message by ID to fetch full details if you need them.
status
string
required
The 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):
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:
Create a login. Give it a label you'll recognise, such as "Billing system", and list the IP addresses your software will connect from.
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.
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
Field
Type
Required
Description
Server and port
host:port
required
Shown in the portal under API → SMPP access, next to your logins. Use exactly what it shows.
Encryption
TLS
required
Required. A plain, unencrypted connection is refused, because an SMPP bind sends the password as-is.
Bind type
string
required
bind_transceiver, or one bind_transmitter plus one bind_receiver. Sending and receiving delivery receipts both need a receiving side.
system_id
string
required
The login name the portal gave you. It is generated for you, you can't choose it.
password
string
required
The password the portal showed once when you created or reset the login. SMPP limits it to 8 characters.
system_type
string
optional
Ignored. Leave it empty.
interface_version
integer
optional
SMPP 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.
Field
Type
Required
Description
source_addr
string
required
One 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_addr
string
required
A phone number, international like 255712345678 (type 1, numbering plan 1) or local like 0712345678.
short_message / message_payload
bytes
required
The 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_coding
integer
optional
0, 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_delivery
integer
optional
Set bit 0 (value 1) to get a delivery receipt for the message.
schedule_delivery_time
string
optional
Not supported. A submit that sets it is refused with ESME_RINVSCHED rather than sent immediately.
validity_period
string
optional
Accepted 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:
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.
Status
SMPP status
Meaning
0x0000000E
ESME_RINVPASWD
Bind refused: wrong password, or an unknown system_id (the two look the same on purpose).
0x0000000D
ESME_RBINDFAIL
Bind 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.
0x00000004
ESME_RINVBNDSTS
You tried to submit on a receive-only bind.
0x0000000A
ESME_RINVSRCADR
Sender name unknown, not approved, disabled, or not yours.
0x0000000B
ESME_RINVDSTADR
Not a valid phone number.
0x00000001
ESME_RINVMSGLEN
Empty after removing non-ASCII characters, or too long.
0x00000043
ESME_RINVESMCLASS
A split (UDH) message. Send it as one submit_sm with message_payload instead.
0x000000C1
ESME_ROPTPARNOTALLWD
A sar_* split-message parameter. Use message_payload instead.
0x00000061
ESME_RINVSCHED
schedule_delivery_time was set. Scheduling isn't supported over SMPP.
0x00000045
ESME_RSUBMITFAIL
Not enough balance, or the account is disabled. SMPP has no "out of credit" status, so this is it.
0x00000058
ESME_RTHROTTLED
Over the login's messages-per-second or unanswered-submit window. Safe to retry after a short pause.
0x00000008
ESME_RSYSERR
Our side. Try again later.
Limits & Behaviour
Field
Type
Required
Description
Connections
per login
optional
Default 2 at once. Over it, the new bind is refused.
Messages per second
per connection
optional
Default 10.
Window
per connection
optional
Default 10 submits sent but not yet answered. Don't send more before the responses come back.
Logins
per account
optional
Up to 3 per account. Make one per system you connect.
Keep-alive
seconds
optional
Send 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)
importreimportsslimportsmpplib.clientHOST,PORT="smpp.example.com",2776# from the portalSYSTEM_ID,PASSWORD="your-system-id","your-pass"client=smpplib.client.Client(HOST,PORT,ssl_context=ssl.create_default_context())defon_submit_response(pdu):# the message id; keep it to match the delivery receipt laterprint("accepted",pdu.message_id)defon_receipt(pdu):text=pdu.short_message.decode("ascii","replace")match=re.search(r"id:(\S+).*stat:(\w+)",text)ifmatch: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)
constsmpp=require("smpp");constsession=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);elseconsole.error("refused",response.command_status);});});});session.on("deliver_sm",(pdu)=>{session.send(pdu.response());// acknowledge every receiptconsttext=pdu.short_message.message.toString();constmatch=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-smpprequire__DIR__."/vendor/autoload.php";usesmpp\Address;usesmpp\ClientasSmppClient;usesmpp\Smpp;usesmpp\transport\Socket;$transport=newSocket(["smpp.example.com"],2776);// from the portal$transport->setRecvTimeout(10000);$smpp=newSmppClient($transport);$transport->open();$smpp->bindTransceiver("your-system-id","your-pass");$from=newAddress("MYBRAND",Smpp::TON_ALPHANUMERIC);$to=newAddress(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.
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.