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, list and inspect messages, send and verify OTPs, and receive delivery status webhooks. Account, billing and reseller management happen 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.
Delivery tracking: every message moves through a clear status lifecycle, queryable by ID or pushed to your own webhook URL.
OTP verification: generate and verify one-time passcodes for login, signup or transaction confirmation flows, delivered by SMS and optionally email.
Custom sender names: send under your own approved brand name instead of a shared system sender.
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.
Webhooks: receive delivery status updates on your own server.
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.
Every endpoint except a small set marked public requires an API key, sent as a standard Authorization header.
API Keys
Generate a non-expiring API key from your account in the Keltap portal, not from the API itself: log in, open the API section, and click Generate API Key. The raw key is shown once:
Generated key
{"success":true,"status_code":200,"status_text":"OK","message":"api key has been generated","data":{"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, generate a new one.
⚠️One active key per account
Generating a new key immediately invalidates whatever key you had before, there's only ever one valid key per account. Rotate keys by generating a new one; there's no separate revoke action.
Using an API key
Send it in the Authorization header, prefixed txf_, on any protected endpoint:
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, 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.
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. 160 characters = 1 billed SMS unit; longer content is billed as ceil(length / 160) parts.
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.
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.
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.
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","whatsapp_number":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.
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").
brand_name
string
optional
Brand name interpolated into the OTP message. Defaults to "Keltap".
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.
Errors
Status
error
Meaning
400
validation_error
phone_number was missing or blank.
400
creation_error
sender_name isn't approved/owned, or a branded OTP was requested without an authenticated caller.
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
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
Status
error
Meaning
400
validation_error
code was missing or 0.
400
verification_error
No matching, unexpired OTP was found for that code and phone/email.
Register a webhook URL to get pushed delivery-status updates instead of polling GET /messages/{id}.
Registering a webhook URL
Set your webhook URL from your account in the Keltap portal (the API section). This is account configuration, not an API call, there's no endpoint to set or change webhook_url programmatically.
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 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.
The request is sent with Content-Type: application/json and a 30-second send timeout. Respond quickly with any 2xx status to acknowledge receipt.
⚠️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 message with Get Message by ID rather than trusting the payload alone, and keep your endpoint fast and reliably reachable, since there's no backoff or redelivery.
Status values you'll see
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.
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.