Send WhatsApp messages, and pull the ones your customers send you.
WhatsApp is not SMS with a different transport. Two rules of the channel decide what your integration can do, and both come from Meta rather than from us. Read these two paragraphs before writing any code — almost every WhatsApp integration that fails in a confusing way fails on one of them.
The 24-hour window
When a customer sends your WhatsApp number a message, a 24-hour window opens. Inside it you may reply with anything — text, media, whatever you like. Outside it, WhatsApp will carry only a template Meta has already approved, and nothing else.
Every message the customer sends pushes the window out again. Messages you send do not: a business cannot keep a window open by talking to itself.
Sending text outside the window returns 409
windowOpen on the conversation and send a template when it is false.Templates are approved by Meta
A template is created in the Selanim portal and submitted to Meta, which approves or rejects it on its own schedule — usually minutes, sometimes several days. Nothing in this API can hurry that, and until you have at least one approved template you cannot start a conversation with anybody: you can only answer people who wrote to you first.
When you send one, templateParams must contain exactly as many values as the approved body has {{1}} placeholders. Meta rejects the whole message when the count differs — it does not ignore the extras.
Send a message
https://bulksmsapi.selanim.com/v1/whatsapp/messagesRequires the whatsapp:send scope. Answers 202: the message is accepted and queued, not yet delivered.
| Field | Type | Description |
|---|---|---|
torequired | string | E.164, with or without the leading plus. |
text | string | A free-form message. Only deliverable inside an open 24-hour window; outside one this returns 409. |
template | string | The name of a template Meta has approved. The only thing WhatsApp carries outside an open window. |
templateLanguage | string | |
templateParams | string[] | Values for the template's {{1}}, {{2}} … placeholders, in order. The count must match the approved template EXACTLY — Meta rejects the whole message otherwise rather than ignoring extras. |
mediaUrl | string (url) | A publicly reachable URL. Meta fetches it directly, so a link only your own network can resolve will fail. |
mediaType | string | One of: image, video, audio, document. |
mediaCaption | string | |
reference | string | Your own identifier, returned on every event about this message. |
Inside an open window — a plain reply:
curl -X POST "https://bulksmsapi.selanim.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $SBS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+255712345678",
"text": "Asante kwa ujumbe wako. Oda yako iko tayari kuchukuliwa."
}'Needs: curl, on every machine already.
Outside the window — an approved template, with its values in order:
curl -X POST "https://bulksmsapi.selanim.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $SBS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+255712345678",
"template": "order_ready",
"templateLanguage": "sw",
"templateParams": [
"Rehema",
"BF-2291"
]
}'{
"id": "9f1c2a44-1f7e-4d0b-9d1e-2c7a3b5e8f10",
"to": "+255712345678",
"status": "queued",
"cost": 12
}Pull the messages your customers send
https://bulksmsapi.selanim.com/v1/whatsapp/messagesWhy this endpoint exists
The whole contract is three steps:
- Call it with no cursor.
- Process
data. - Keep calling with
nextCursorwhilehasMoreis true.
Store the last cursor you received. That single value is your entire replication state — if your server is down for two days, you resume from it and get every message you missed, in order, exactly once.
| Query parameter | Type | Description |
|---|---|---|
cursor | string | The nextCursor from your previous call. Opaque: send it back verbatim and do not parse it. Omit to start from the newest message. |
direction | string | inbound (the default) is what customers sent you, outbound is what you sent, all returns both in one stream. Defaults to "inbound". One of: inbound, outbound, all. |
since | string (ISO 8601) | RFC3339 timestamp. Only messages at or after this. |
limit | integer | Defaults to 50. Maximum 200. |
curl -X GET "https://bulksmsapi.selanim.com/v1/whatsapp/messages?direction=inbound&limit=50" \
-H "Authorization: Bearer $SBS_API_KEY"{
"data": [
{
"id": "6b2e0f19-2c44-4a67-9a1f-0d3c5f7b8e21",
"conversationId": "1d8a4c60-9f21-4b3e-8c7a-5e2f1b9d0a34",
"whatsappId": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAEhgg",
"direction": "inbound",
"from": "+255712345678",
"type": "text",
"body": "Habari, bei ya bundle ni ngapi?",
"status": "received",
"receivedAt": "2026-08-26T09:12:44Z"
}
],
"nextCursor": "eyJ0IjoiMjAyNi0wOC0yNlQwOToxMjo0NFoiLCJpZCI6IjZiMmUwZjE5In0",
"hasMore": true
}Cursors, not page numbers
Prefer push? Subscribe to the whatsapp.received event on your delivery webhook. Both read the same stored rows, so doing both misses nothing and duplicates nothing.
Fetch one message
https://bulksmsapi.selanim.com/v1/whatsapp/messages/{messageId}An unrecognised message type arrives as unsupported rather than being dropped, so a customer’s turn in the conversation is never lost. Branch on the types you handle and keep the rest.
Conversations
https://bulksmsapi.selanim.com/v1/whatsapp/conversationsOne thread per customer. The two fields to build on are windowOpen and windowRemainingSeconds: they tell you whether you may reply freely or must use a template. Both are computed against the server clock on every read, because the window closes by the passage of time — a value you cached ten minutes ago may already be wrong.
| Query parameter | Type | Description |
|---|---|---|
state | string | One of: open, closed, archived. |
unread | boolean | Only threads with unread messages. |
page | integer | Defaults to 1. |
pageSize | integer | Defaults to 25. Maximum 200. |
curl -X GET "https://bulksmsapi.selanim.com/v1/whatsapp/conversations?state=open&unread=true" \
-H "Authorization: Bearer $SBS_API_KEY"{
"data": [
{
"id": "1d8a4c60-9f21-4b3e-8c7a-5e2f1b9d0a34",
"contact": "+255712345678",
"contactName": "Asha Mwinyi",
"lastMessage": "Habari, bei ya bundle ni ngapi?",
"lastMessageAt": "2026-08-26T09:12:44Z",
"unreadCount": 1,
"state": "open",
"windowOpen": true,
"windowRemainingSeconds": 78436
}
],
"meta": { "page": 1, "pageSize": 20, "total": 1 }
}Delivery states
WhatsApp reports one state SMS has no equivalent of: read. It is the receipt most clients end up judging a campaign on.
| deliveryStatus | Type | Description |
|---|---|---|
sent | string | Meta accepted it. |
delivered | string | It reached the customer's device. |
read | string | The customer opened it. |
failed | string | It did not arrive. Credits for a message Meta accepted and then failed to deliver are returned automatically. |
Opt-outs
A customer replying STOP, ACHA, SITAKI or a similar single word is opted out automatically, and further messages to them are refused with 403. You do not need to implement this yourself, and you should not try to work around it: repeatedly messaging somebody who asked you to stop is the fastest way to have Meta downgrade your number’s quality rating.
Scopes
| Scope | Type | Description |
|---|---|---|
whatsapp:send | string | Send messages and replies. |
whatsapp:read | string | Pull messages and read conversations. Separate from send on purpose — on this channel, reading means your customers' conversations, so a key that only replicates an inbox should not be able to message anybody. |

