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

From there, the reference is grouped by resource:

  • SMS API: send SMS, list messages, fetch a single message.
  • OTP API: send and verify one-time passcodes.
  • 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.

Authentication

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:

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.

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, 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.
messagesarrayrequiredOne or more { receiver, content } objects. Non-empty.
messages[].receiverstringrequiredRecipient phone number. Normalized to 255… international format.
messages[].contentstringrequiredMessage text. 160 characters = 1 billed SMS unit; longer content is billed as ceil(length / 160) parts.
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.

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

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.

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", "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

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.
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").
brand_namestringoptionalBrand 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

StatuserrorMeaning
400validation_errorphone_number was missing or blank.
400creation_errorsender_name isn't approved/owned, or a branded OTP was requested without an authenticated caller.
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.

Webhooks

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:

POST <your webhook_url>
{ "id": "6b2f6e2a-4b0e-4e9a-9a2e-1a2b3c4d5e6f", "status": "delivered" }
FieldTypeRequiredDescription
iduuidrequiredThe message id. Call Get Message by ID to fetch full details if you need them.
statusstringrequiredThe 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.

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.