API reference
Connect your own systems to Sema: send a message from the till when an order is ready, push today's customers in from the shop system, check whether a message arrived.
Everything is JSON over HTTPS. There is one version, /api/v1.
The whole thing is also described in OpenAPI, served at
/api/v1/openapi.json. Point Postman, Insomnia, or your own generator at
it. A test refuses to pass if that document stops matching these routes, so it
is safe to build against.
For TypeScript there is a generated client, @sema/client. It is not on npm
yet; until it is, generate your own from the OpenAPI document. Once published,
npm install @sema/client, and:
import { Sema } from '@sema/client';
const sema = new Sema({ key: process.env.SEMA_KEY! });
await sema.sendMessage(
{ to: '254712345678', type: 'text', text: { body: 'Your order is ready.' } },
`order-${order.id}`,
);
A key
Settings → API keys, as an owner or admin. Name the key for the system that will hold it ("Till at the Ngong Road shop"), and tick only what that system needs.
The key is shown once. It is stored only as a hash, so nobody (including us) can read it back; if it is lost, revoke it and make another.
Authorization: Bearer sema_live_…
Anyone holding a key can spend the workspace's credit and read its customers. Keep it on your server, never in a browser, an app, or a repository.
Live keys and test keys
Every key is one or the other, and the token says which:
sema_live_… sends real messages, spends real credit
sema_test_… validates everything, then stops
Build against a test key. It checks the same things a live key checks: the number, the template, its variables, your scopes, your idempotency key. It refuses the same requests with the same codes. Then it stops at the edge:
- nothing is handed to WhatsApp, so nobody's phone rings;
- nothing is charged, so a loop in your code costs nothing;
- nothing is written to the workspace's records: no message, no conversation, no contact. Your inbox and your reports do not fill up with scaffolding;
- it cannot read your real customers.
GET /contactsreturns the fictional set below plus whatever you have written with a test key. A test key is meant to sit in a.envfile and be pasted into chats; one that could list every customer's number would undo the point of having it.
Every response carries "mode": "live" or "mode": "test", so your integration
can refuse to start if it finds itself holding the wrong kind.
A test key does not need a connected WhatsApp number, so you can build the whole integration while Meta is still reviewing your app.
Asking for a particular outcome
With a test key, these numbers do something specific, so you can write the error handling you need and actually reach it:
to |
What happens |
|---|---|
254799999001 |
failed, with error code 131026 |
254799999002 |
comes back read |
254799999003 |
refused, 422 window_closed |
254799999004 |
refused, 402 insufficient_credit |
254799999005 |
refused, 422 invalid_request: that person opted out |
| anything else | delivered |
They are also the fictional contacts GET /contacts returns, so you can find
them from either end. Test traffic is kept for a fortnight and then swept.
Scopes
| Scope | Lets the key |
|---|---|
messages:send |
send messages and batches, read the balance |
messages:read |
read messages, conversations and files |
contacts:read |
list contacts |
contacts:write |
add and update contacts |
templates:read |
list approved templates and what they need |
templates:write |
write, change and remove templates, with a live key |
verify |
send and check one-time codes |
webhooks |
add and remove webhooks, with a live key |
campaigns |
list campaigns built in Sema, and start one |
knowledge |
read and keep in step what the AI answers from |
Limits
Each key has its own limit, 120 requests a minute by default, set when you
create it. Over it you get 429 with a Retry-After header. The limit is per
key: one integration's mistake cannot slow down another.
Sending
curl https://sema.datacare.co.ke/api/v1/messages \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Idempotency-Key: order-4171" \
-H "Content-Type: application/json" \
-d '{
"to": "254712345678",
"type": "text",
"text": { "body": "Your order is ready for collection." }
}'
{
"id": "0b1c25a4-33e7-414a-b413-0c492fb50a4a",
"status": "queued",
"to": "254712345678",
"type": "text",
"created_at": "2026-09-22T19:23:06.938Z"
}
to is the customer's number. A customer who wrote to you by your
username and kept their number hidden has no number to give: send to their
WhatsApp user id instead, as webhooks and GET /conversations name them
("to": "KE.1349120865530274").
Idempotency-Key is required. A till that retries a request (because the
network dropped, because it is not sure) must not charge the shop for a second
message. Send the same key with the same body and you get the first answer
back. Send it with a different body and you get 409, rather than something
you did not mean to send. A key is 8 to 255 characters, and is remembered for
seven days.
Text, or a template
A text reaches a customer only inside the 24 hours after their own last
message. Outside that window WhatsApp allows only an approved template, and
422 window_closed says so.
{
"to": "254712345678",
"type": "template",
"template": {
"name": "order_ready",
"language": "en",
"body": ["Wanjiku", "A-1042"],
"header": "A-1042",
"links": { "0": "A-1042" }
}
}
body: one value per{{1}},{{2}}… in the template's message, no fewer and no more: either is422, saying how many the template takes. A template with named variables ({{first_name}}) also takes an object keyed by name:"body": { "first_name": "Wanjiku", "order_number": "A-1042" }.header: only if the template's header has a variable.links: the end of a tracking link, by button position, starting at"0".coupon_code: for a limited-time offer with a copy-code button: the code the customer copies, up to 15 characters.offer_expires_at: for a limited-time offer that shows when it ends: that moment, as an ISO 8601 time with its offset ("2026-10-04T23:59:00+03:00"). It has to be after the message is sent.
GET /api/v1/templates tells you exactly what each template needs, so you do
not have to keep that in step by hand:
{
"data": [
{
"name": "order_ready",
"language": "en",
"category": "utility",
"needs": {
"body_variables": 2,
"body_names": null,
"header": "text",
"link_indexes": [0],
"one_time_code": false,
"coupon_code": false,
"offer_expires_at": false,
"cards": null
}
}
]
}
A template that opens with a picture, video or document ("header": "image",
"video" or "document") takes its file as header_media, and a carousel
takes one per card in cards, as needs.cards describes. See
Files. One-time-code templates are sent through
Verify, which makes the code and checks it for you.
from picks which of your numbers sends, by its display number. With one
number, leave it out.
Files
A picture, a video, a document or audio goes one of two ways:
- Uploaded, with
POST /api/v1/media, and then sent by themedia_idit returns. A JPEG or PNG picture, an MP4 or 3GP video, a PDF, or audio as MP3, M4A, AAC, AMR or Ogg Opus, of 4 MB at most. What the file is, is read from the file itself, not from its name. - By link: a full
https://address WhatsApp can fetch without signing in. WhatsApp fetches it when the message goes; Sema never does. This is the way to send a file over 4 MB, or one already on your own site.
curl https://sema.datacare.co.ke/api/v1/media \
-H "Authorization: Bearer $SEMA_KEY" \
-F "file=@invoice-4171.pdf"
{
"id": "5b0e9c1d-2f43-4c1a-9a57-0d7f3c2a8e61",
"status": "processing",
"type": "document",
"mime_type": "application/pdf",
"filename": "invoice-4171.pdf",
"size_bytes": 48213,
"created_at": "2026-09-30T10:04:11Z",
"mode": "live"
}
It is processing for a few seconds while it goes to WhatsApp, then ready:
GET /api/v1/media/{id} says which. A send that uses it before then is refused
with 409 media_not_ready and a Retry-After, and nothing is charged. A file
stays sendable for 25 days (expires_at), five days short of when WhatsApp
forgets it; after that, upload it again. With more than one number, give the
upload the same from you will send with: WhatsApp may keep a file for the
number it was uploaded on.
Inside the 24-hour window, send it on its own, with a caption:
{
"to": "254712345678",
"type": "document",
"document": {
"media_id": "5b0e9c1d-2f43-4c1a-9a57-0d7f3c2a8e61",
"caption": "Your invoice. Thank you!",
"filename": "Invoice A-1042.pdf"
}
}
"type": "image" with "image": {…} and "type": "video" with
"video": {…} work the same way; only a document takes a filename, and it
defaults to the uploaded file's own. By link instead of media_id:
"image": { "link": "https://example.co.ke/p/4171.jpg" }.
Audio is "type": "audio" with "audio": {…} and no caption: WhatsApp shows
none on a voice note.
Outside the window, use a template that opens with one:
{
"to": "254712345678",
"type": "template",
"template": {
"name": "invoice_ready",
"body": ["Wanjiku", "A-1042"],
"header_media": { "media_id": "5b0e9c1d-2f43-4c1a-9a57-0d7f3c2a8e61" }
}
}
And a carousel, one object per card, in the template's order:
"template": {
"name": "new_arrivals",
"body": ["Wanjiku"],
"cards": [
{ "header_media": { "link": "https://example.co.ke/p/1.jpg" }, "body": ["KSh 1,200"] },
{ "header_media": { "link": "https://example.co.ke/p/2.jpg" }, "body": ["KSh 950"] }
]
}
A test key checks an upload the same way and keeps nothing; its media_id
works with test sends, and a live key's does not, nor the other way round.
Buttons and lists
Inside the 24-hour window, ask a question the customer answers with a tap. Up to three buttons:
{
"to": "254712345678",
"type": "interactive",
"interactive": {
"body": "Is your order A-1042 still wanted?",
"footer": "Reply by 5pm",
"buttons": [
{ "id": "keep", "title": "Yes, keep it" },
{ "id": "cancel", "title": "Cancel it" }
]
}
}
or, for more choices, a list of up to ten rows:
"interactive": {
"body": "Which size?",
"button": "Sizes",
"sections": [
{
"title": "In stock",
"rows": [{ "id": "s40", "title": "Size 40", "description": "KES 2,500" }]
}
]
}
The id is yours. When the customer taps, message.received arrives with
"reply": {"kind": "button", "id": "keep", "title": "Yes, keep it"} and
in_reply_to, the message they tapped on, so your system knows which question
was answered.
WhatsApp's own limits are checked before anything is accepted: a button title is 20 characters, a row title 24 and its description 72, a header or footer 60, and two buttons cannot share an id or a title. With more than one section, each needs a title.
A place, and a reaction
{
"to": "254712345678",
"type": "location",
"location": {
"latitude": -1.2605,
"longitude": 36.8025,
"name": "Our shop",
"address": "Sarit Centre"
}
}
{ "to": "254712345678", "type": "reaction", "reaction": { "message_id": "7cca…", "emoji": "👍" } }
A reaction goes on a message that customer sent you, named by the id the API
gave for it; an empty emoji takes it back. Both go only inside the 24-hour
window, and a reaction cannot be scheduled.
What happened to it
curl https://sema.datacare.co.ke/api/v1/messages/0b1c25a4-33e7-414a-b413-0c492fb50a4a \
-H "Authorization: Bearer $SEMA_KEY"
{
"id": "0b1c25a4-…",
"status": "delivered",
"to": "254712345678",
"type": "text",
"created_at": "…",
"sent_at": "…",
"delivered_at": "…"
}
status moves queued → sent → delivered → read, or failed with an error.
A message id belonging to another workspace is 404, the same as one that does
not exist.
What a message holds
A message, from GET /messages/{id}, GET /messages or a webhook, says what
is in it. Only the fields that apply are there:
| Field | When |
|---|---|
text |
the words: typed, the label of a button or list row they tapped, or the body of one you sent |
reply |
a choice: {"kind": "button", "id": "confirm", "title": "Confirm"}, "list", or "form" with its response |
media |
a file: mime_type, filename, caption, size_bytes, status and download (see below) |
location |
latitude, longitude, and a name and address if they chose a place |
contacts |
contact cards they shared: name, phones, emails |
reaction |
an emoji on message_id, or "removed": true |
order |
a basket from your catalogue: catalog_id and items with product_id, quantity, price |
in_reply_to |
the message this answers: the one they swiped to reply to, or the one whose button they tapped |
sender |
on one you sent: agent (a person in the inbox), bot, ai, system or api |
On a template's quick-reply button, reply.id is the button's own text, so
branch on what the button says. On a button or list row an automation or the
inbox sent, it is the id that was given to it.
Reading back
Webhooks are the way to hear about a message as it happens. When one was missed (your server was down for longer than our retries), read it back:
curl "https://sema.datacare.co.ke/api/v1/messages?direction=inbound&since=2026-10-01T00:00:00%2B03:00" \
-H "Authorization: Bearer $SEMA_KEY"
{
"data": [
{
"id": "7cca…",
"direction": "inbound",
"type": "interactive",
"status": "delivered",
"wa_id": "254712345678",
"conversation_id": "1b9d…",
"text": "Confirm",
"reply": { "kind": "button", "id": "confirm", "title": "Confirm" },
"in_reply_to": "0b1c…",
"created_at": "2026-10-01T06:15:04.221Z",
"mode": "live"
}
],
"next": "2026-10-01T06:15:04.221000Z,7cca…",
"mode": "live"
}
Filter by wa_id, conversation_id, direction and since; limit is up
to 100, newest first. When next is there, pass it back as cursor for the
following page; when it is not, there are no more. A note your staff left in
the inbox is never part of it.
GET /conversations lists them the same way, latest first: the customer, the
state, when they last wrote, and window_open_until, until when something
other than a template may be sent.
A customer's file
A picture, voice note, video or document a customer sends is fetched from
WhatsApp a moment after it arrives. media.status is processing until then,
and ready after. Ask for it at its download path:
curl https://sema.datacare.co.ke/api/v1/messages/7cca…/media \
-H "Authorization: Bearer $SEMA_KEY"
{
"url": "https://…",
"expires_at": "2026-10-01T06:20:04Z",
"mime_type": "image/jpeg",
"size_bytes": 182311,
"mode": "live"
}
The url needs no key and works for five minutes: fetch the file from it
straight away, and keep your own copy if you need one. Before the file is
ready the answer is 409 media_not_ready with a Retry-After; after the
workspace's retention has removed it, 410 media_gone.
Sending later
Add send_at, a time with its offset from a minute to 30 days ahead, and
the message waits until then:
curl https://sema.datacare.co.ke/api/v1/messages \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Idempotency-Key: reminder-4171" \
-H "Content-Type: application/json" \
-d '{
"to": "254712345678",
"type": "template",
"template": { "name": "delivery_tomorrow", "body": ["Wanjiku"] },
"send_at": "2026-09-25T09:00:00+03:00"
}'
{ "id": "7d2e…", "status": "scheduled", "send_at": "2026-09-25T06:00:00Z", … }
- It is checked now, exactly as an immediate send is: a mistake is answered while you are still there to read it, not at nine tomorrow.
- It is checked again when it falls due. Someone who opted out in between does not get it; neither does anyone if the key that scheduled it has been revoked.
- It is charged when it goes, at that moment's price. If the credit has run out by then it does not go.
- Text is accepted only for a time the customer's 24-hour window will still be open. For anything later, schedule a template.
- One id throughout. Poll
GET /messages/{id}with the id you were given: it answersscheduled, thenqueued,sentand so on once it has gone, orfailed, witherror.messagesaying why it could not go.
To cancel while it waits:
curl -X POST https://sema.datacare.co.ke/api/v1/messages/7d2e…/cancel \
-H "Authorization: Bearer $SEMA_KEY"
Nothing was charged, so nothing is refunded. Once it has gone it cannot be
called back: the answer is 409.
Many at once
The balance first
curl https://sema.datacare.co.ke/api/v1/balance \
-H "Authorization: Bearer $SEMA_KEY"
{
"balance": 7181.75,
"currency": "KES",
"country": "KE",
"prices": { "marketing": 3.8025, "utility": 0.676, "authentication": 0.676 },
"mode": "live"
}
What is left, and what one message of each kind costs from it, all in, to a
customer in your own country. Check before a run: 10,000 statements at the
utility price need that much credit, and finding out at the 4,000th is
worse. It takes the messages:send scope. A test key reads the real
balance and spends none of it.
A batch
One approved template to up to 10,000 people, each with their own values:
curl https://sema.datacare.co.ke/api/v1/batches \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Idempotency-Key: statements-2026-10" \
-H "Content-Type: application/json" \
-d '{
"template": { "name": "statement_ready", "language": "en" },
"recipients": [
{ "to": "254712345678", "body": ["Wanjiku", "KES 4,200"] },
{ "to": "254733000111", "body": ["Otieno", "KES 1,050"] }
]
}'
{
"id": "4b58c300-9727-4514-b47c-2c8f889913ba",
"status": "sending",
"template": "statement_ready",
"total": 2,
"accepted": 0,
"refused": 0,
"pending": 2,
"created_at": "2026-10-01T04:04:50Z",
"mode": "live"
}
It is accepted at once and Sema sends it, a few hundred a second, at the
pace WhatsApp allows your number. Each recipient is exactly the send
POST /messages would make: checked, charged and sent on its own, with a
result of its own, so a customer who opted out or a mistyped number does not
stop the rest.
- Everything but
toin a recipient is the template's values for them, astemplatetakes them on a single send:body,header,header_media,links,coupon_code,offer_expires_at,cards. A value intemplateapplies to everyone who does not give their own. fromandsend_atapply to the whole batch.- The
Idempotency-Keycovers the whole batch: sent again, the same batch answers, and nobody gets it twice. - The batch counts once against the key's limit.
Follow it, and read each recipient's result:
curl https://sema.datacare.co.ke/api/v1/batches/4b58c300-9727-4514-b47c-2c8f889913ba \
-H "Authorization: Bearer $SEMA_KEY"
{
"id": "4b58c300-…",
"status": "done",
"total": 2,
"accepted": 1,
"refused": 1,
"pending": 0,
"results": [
{
"index": 0,
"to": "254712345678",
"id": "41165e5a-…",
"status": "queued",
"type": "template"
},
{
"index": 1,
"to": "254733000111",
"status": "failed",
"error": { "code": "invalid_request", "message": "that customer has opted out of messages" }
}
],
"mode": "live"
}
A recipient is pending until Sema reaches them. One that went has the
message's id, to follow like any other message, and its webhooks arrive as
usual. Results come up to 1,000 at a time: next says where the following
page starts, passed back as from.
Contacts
curl "https://sema.datacare.co.ke/api/v1/contacts?query=0712&limit=50" \
-H "Authorization: Bearer $SEMA_KEY"
query matches a name or part of a number; limit is up to 100; before
takes the next page, using the created_at of the last row you saw.
curl https://sema.datacare.co.ke/api/v1/contacts \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{
"wa_id": "254712345678",
"name": "Wanjiku Kamau",
"tags": ["wholesale"],
"attributes": { "estate": "Kileleshwa" },
"opt_in": { "source": "checkout form" }
}'
Adding somebody does not opt them in. Consent is recorded only when you say
where it came from, in opt_in.source, and that source is kept as evidence:
WhatsApp requires you to be able to show it. Somebody who has opted out cannot
be opted back in by an upload: they have to do it themselves, by replying START
or tapping the button.
One contact, by number:
curl https://sema.datacare.co.ke/api/v1/contacts/254712345678 \
-H "Authorization: Bearer $SEMA_KEY"
And when a customer asks to stop somewhere WhatsApp did not hear it (a phone call, an email, at the counter), record it:
curl https://sema.datacare.co.ke/api/v1/contacts/254712345678/opt-out \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{ "source": "phone call to the shop" }'
It is as final as a STOP on WhatsApp: they are not messaged again, by the API,
the inbox or a campaign, and only they can opt back in. source is required
and is kept as the evidence.
Welcoming a new lead
Use this when someone signs up on your site, orders in WooCommerce, or fills in a Facebook lead form (through Zapier). Name one of your lead forms (Automations, Welcome new leads), and the call does four things:
- adds the person;
- records their yes, with your
consent.sourceand the form's own words; - sends the form's welcome template at once;
- starts the form's follow-up, if it has one.
curl https://sema.datacare.co.ke/api/v1/leads \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{
"form": "a1b2c3d4e5f6a7b8c9",
"phone": "+254712345678",
"name": "Wanjiku",
"consent": { "source": "checkout form" }
}'
form is the form's id, or the key in its address (/f/<key>). The answer
says what became of the lead:
welcomed: the welcome was sent.skipped: nothing was sent, andreasonsays why. It is skipped when the number was welcomed by this form in the last 30 days, when the person opted out (a form never opts anyone back in), or when the form's hourly limit has been reached.failed: the send did not go, for example because there was not enough credit.
The key needs contacts:write and messages:send, and must be a live key.
The pipeline
Deals are the sales a business is working on, in stages (Pipeline). Add one for a customer by their number; it goes into the first open stage unless you name one:
curl https://sema.datacare.co.ke/api/v1/deals -H "Authorization: Bearer $SEMA_KEY" -H "Content-Type: application/json" -d '{ "phone": "+254712345678", "title": "20 school uniforms",
"value": 18000, "stage": "Quote sent" }'
Move one, by stage name or id; a stage that ends in a loss needs a reason:
curl -X PATCH https://sema.datacare.co.ke/api/v1/deals/$DEAL_ID -H "Authorization: Bearer $SEMA_KEY" -H "Content-Type: application/json" -d '{ "stage": "Lost", "lost_reason": "Bought elsewhere" }'
GET /deals lists them, newest first: open ones unless status is won,
lost or all, and one stage's with stage. Reading needs
contacts:read; adding and moving, contacts:write and a live key. A
customer added this way has agreed to nothing. Webhooks hear
deal.created, deal.stage_changed, deal.won and deal.lost.
Appointments
Ask for the free starts of a service on a day, then book one for a customer by their number. Each start is free with somebody who does the service, clear of other bookings and of busy times in their connected calendar:
curl "https://sema.datacare.co.ke/api/v1/appointments/slots?service=Haircut&date=2026-11-14" \
-H "Authorization: Bearer $SEMA_KEY"
curl https://sema.datacare.co.ke/api/v1/appointments \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{ "service": "Haircut", "starts_at": "2026-11-14T07:30:00Z",
"phone": "+254712345678", "name": "Wanjiku" }'
A start somebody has just taken answers 409 already_exists: ask for the
slots again. The customer is told on WhatsApp as for any booking, and
reminded the day and the hour before. Confirm, cancel, mark, or move one:
curl -X PATCH https://sema.datacare.co.ke/api/v1/appointments/$APPOINTMENT_ID \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{ "starts_at": "2026-11-15T08:00:00Z" }'
status is confirmed, cancelled (with an optional reason),
no_show or done; "tell_customer": false changes it without a
message. GET /appointments lists them from from (now) to to (14 days
on, at most 62). Reading needs contacts:read; booking and changing,
contacts:write and a live key. Webhooks hear appointment.booked and
appointment.changed.
Telling Sema what a customer did
Your shop's events start the workspace's journeys (Automations, Journeys): an order update and then an offer, back in stock, a cart left. An order placed, with what it was worth, is also how each journey shows what it earned against the people it held back.
curl https://sema.datacare.co.ke/api/v1/events -H "Authorization: Bearer $SEMA_KEY" -H "Content-Type: application/json" -d '{
"event": "cart_abandoned",
"phone": "+254712345678",
"name": "Wanjiku",
"data": { "url": "https://example.co.ke/cart/8812" }
}'
curl https://sema.datacare.co.ke/api/v1/events -H "Authorization: Bearer $SEMA_KEY" -H "Content-Type: application/json" -d '{ "event": "order_placed", "phone": "+254712345678",
"value": 2500, "currency": "KES", "ref": "order-1042" }'
event is one of order_placed, order_shipped, back_in_stock and
cart_abandoned. data holds up to 20 fields, named in lower case, that a
journey's template can say as {{event.url}} or {{event.product}}; a
journey whose template needs a field the event did not send does not send.
The customer is added if Sema does not have them, without any consent: an
event is not a yes to marketing, and a marketing journey only writes to
people who agreed. The same ref for the same event counts once, and the
answer says duplicate. A customer who asked to be erased stays erased: the
answer says ignored. Otherwise it says recorded, with how many journeys
they entered and how many journeys the order converted for.
The key needs contacts:write and must be a live key.
Today's numbers
GET /api/v1/today (scope messages:read) is the workspace's day so far, in
its own time zone: sent, delivered, read, failed, received, and
conversations that began today, are open, are open with nobody
assigned (unassigned), or are pending. A test key reads zeros.
Campaigns
A campaign is built in Sema (its audience, its template and its values).
With the campaigns scope, GET /api/v1/campaigns lists them, latest first,
with how each did (?status=draft narrows), and
POST /api/v1/campaigns/{id}/start starts a draft or scheduled one, or
resumes a paused one, with every check the app makes when someone presses
Start: its audience frozen, its cost checked against your credit.
It is started on behalf of the person who made the key, and only while they
may start campaigns themselves (an owner, admin or manager): a key made by
someone who has since left, or become an agent, is refused with 403. A
test key is refused with 422.
What the AI answers from
If your prices or product details live in your own website or catalogue,
send them to Sema instead of typing them in twice, so the AI never quotes an
old price. With the knowledge scope and a live key:
curl -X PUT https://sema.datacare.co.ke/api/v1/knowledge \
-H "Authorization: Bearer sema_live_..." -H "Content-Type: application/json" \
-d '{"prune": true, "answers": [
{"id": "m365", "title": "Microsoft 365", "answer": "From KES 950 a person a month."}
]}'
Each answer has your own id. Sent again, it is replaced; with
"prune": true, an answer you sent before and no longer send is removed.
Answers written in Sema, under Settings, AI, are never touched. Up to 200
at a time. The reply says how many were sent, changed and removed.
GET /api/v1/knowledge lists every shared answer, with synced true for
yours.
ChatGPT, Claude and other assistants
Sema has an MCP (Model Context Protocol) server at
https://sema.datacare.co.ke/api/mcp, so an AI assistant can work with your
workspace: read today's numbers, find a contact, read a conversation, list
templates, send a message or template, send one to many people, list
campaigns and start one. It is the API underneath, with the same key: an
assistant can do exactly what its key's scopes allow, is offered only those
tools, and is told to ask you before it sends or starts anything.
Two ways to connect:
- With a click, where the assistant's own connector screen asks for an
address (ChatGPT, Claude): give it
https://sema.datacare.co.ke/api/mcp. It sends you to Sema, where you sign in, choose the workspace and what it may do, and it is given an API key for exactly that, named after it. You see it, and can revoke it, in Settings, API keys. (OAuth 2.1 with PKCE and dynamic client registration; metadata at/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server.) - With a key, where the assistant takes a header (Claude Code, Cursor,
VS Code): make a key in Settings, API keys, with the scopes you want, and
send it as
Authorization: Bearer sema_live_…. For example:
claude mcp add --transport http sema https://sema.datacare.co.ke/api/mcp \
--header "Authorization: Bearer $SEMA_KEY"
Your own AI agent
A bot or AI agent of your own (built on OpenAI, Claude, or anything) can answer your customers in Sema, in WhatsApp conversations and in the chat on your website, the way Sema's own assistant does. Three parts:
- Told. Add a webhook (below) for the event
conversation.agent_turn. That endpoint is your agent. When a customer writes and it is the agent's turn, it is posted the message, the conversation's last twenty messages (from:customer,team,assistantoragent) and who the customer is, signed as every webhook is. - Answering.
POST /api/v1/conversations/{id}/replywith{"text": "…"}. It goes the way the conversation came, and the inbox marks it as your agent's. - Handing over.
POST /api/v1/conversations/{id}/handoverwith{"summary": "…"}: a note your team reads first, the people who would answer are told, and your agent is not asked again in that conversation until a person has written in it.
Both need a live key with messages:send.
{
"event": "conversation.agent_turn",
"data": {
"conversation_id": "…",
"channel": "whatsapp",
"customer": { "contact_id": "…", "name": "Wanjiru", "wa_id": "254712345678", "tags": ["vip"] },
"message": { "message_id": "…", "type": "text", "text": "Do you deliver to Ruiru?" },
"history": [{ "from": "customer", "text": "Hello", "at": "…" }],
"reply": {
"answer": "/api/v1/conversations/…/reply",
"hand_over": "/api/v1/conversations/…/handover"
}
}
}
It is not the agent's turn, and nothing is posted, when the customer has
opted out, a flow or an automation is answering, a person on your team has
the conversation (they replied, or switched the AI off in the thread), or it
was handed over. With an agent, Sema's own assistant stands aside: one
answers, not two. channel is web for the chat on your website, whose
visitors have no wa_id until they leave a number.
Writing templates
A platform that manages templates for a business can write them through the
API, with a live key that has templates:write. Each is held to every
rule the template studio is: it is the same check.
curl https://sema.datacare.co.ke/api/v1/templates \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "delivery_today",
"category": "utility",
"language": "en",
"body": "Hello {{1}}, your delivery comes today.",
"examples": { "1": "Wanjiku" },
"buttons": [{ "type": "QUICK_REPLY", "text": "Thank you" }]
}'
{
"id": "83a6…",
"name": "delivery_today",
"language": "en",
"category": "utility",
"status": "pending",
"needs": { "body_variables": 1, "header": null, "link_indexes": [] },
"mode": "live"
}
It goes to Meta at once ("submit": false keeps it a draft), and Meta decides
within minutes to a day. Listen for template.status_changed, or ask:
GET /templates/{id}, and GET /templates?all=true for every template
whatever its status, with the reason Meta gave for a rejection.
- Every variable needs a real example in
examples: Meta refuses a template without them. - A button is
{"type": "QUICK_REPLY", "text": "…"},{"type": "URL", "text": "…", "url": "https://…"}or{"type": "PHONE_NUMBER", "text": "…", "phone_number": "254…"}. - A template that opens with a picture, video or document, a carousel, a limited-time offer and a one-time code each need more than a request can carry (a sample file, for one): write those in Sema, under Templates.
PATCH /templates/{id} changes one, taking the whole template as it should
now read. A draft is saved; any other goes back to Meta and cannot be sent
until Meta approves the change, and an approved template may be edited once a
day and ten times in 30 days. DELETE /templates/{id} removes one, and is
409 template_in_use while a campaign is scheduled or sending with it.
When something is wrong
{
"error": {
"code": "invalid_request",
"message": "the template needs \"template\".body[1]: 2 values in all"
}
}
| Status | code |
What to do |
|---|---|---|
| 401 | unauthorized |
The key is missing, unknown or revoked. |
| 403 | forbidden |
The key does not have that scope. |
| 402 | insufficient_credit |
Top up. Nothing was sent or charged. |
| 404 | not_found |
Nothing with that id in this workspace. |
| 409 | idempotency_conflict |
That Idempotency-Key was used for a different message. |
| 409 | media_not_ready |
A file is still going to WhatsApp. Wait Retry-After. |
| 409 | not_scheduled |
Too late to cancel: it has already gone, or ended. |
| 410 | media_gone |
That file is no longer kept. |
| 409 | template_in_use |
A campaign is using that template. Finish or pause it. |
| 413 | too_large |
Over 4 MB for an upload. Send it by link instead. |
| 422 | invalid_request |
The message says what is wrong. Fix it before retrying. |
| 422 | window_closed |
Outside the 24 hours; send an approved template instead. |
| 429 | rate_limited |
Wait Retry-After seconds. |
| 500 | server_error |
Ours. Retry; if it persists, tell us. |
Retry 429, media_not_ready and 500. Do not retry 4xx otherwise: the
answer will not change until the request does.
Notes
- Numbers are in full international form without
+:254712345678. - Times are UTC, ISO 8601.
- A send is accepted with
202: it has been queued and charged, not yet handed to WhatsApp. Watchstatusfor the rest.
Verify: one-time codes
Confirm a phone number at checkout or sign-in. Sema makes a six-digit code,
sends it through your approved authentication template, and tells you whether
the code your customer typed is the right one. Needs a key with the verify
scope.
curl https://sema.datacare.co.ke/api/v1/verifications \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Idempotency-Key: signin-4171" \
-H "Content-Type: application/json" \
-d '{ "to": "254712345678", "ip": "197.232.1.4" }'
{
"id": "5f0c…",
"to": "254712345678",
"status": "pending",
"expires_at": "2026-09-24T12:10:00Z",
"mode": "live"
}
Then check what they typed:
curl https://sema.datacare.co.ke/api/v1/verifications/5f0c…/check \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }'
{ "id": "5f0c…", "status": "approved", "valid": true, "attempts_remaining": 4, … }
Let them in on valid: true, and only then. It is true exactly once, on
the check that approves the code. Checking the same code again answers
approved with valid: false, so a form submitted twice fails safe rather
than letting one code in twice.
The rules
- A code lasts ten minutes.
- Five wrong codes and it is dead:
statusbecomesfailed, and even the right code is refused after that. - Five codes an hour may go to one number. A sixth is
429, andRetry-Aftersays exactly how many seconds until the next is allowed. - Send
ip(the address of the person asking) and one address is limited to ten codes an hour, whichever numbers it asks for. It is optional, and worth sending: it is what stops one visitor walking through a list of numbers. - A new code for a number cancels the one still waiting, so there is only ever one good code at a time.
- Each code is charged as an authentication message.
Nobody at the business sees the code
Not you, not your agents. The message appears in the customer's conversation,
which is useful when they write "I didn't get my code", but it shows ••••••. The
code itself is kept only until it has been sent, and what Sema stores to check
against is a keyed hash, not the code.
That is also why /messages refuses authentication templates: a code sent
that way would be one Sema did not make, cannot check, and would sit in the
conversation in plain sight.
Before the first code
The workspace needs an approved authentication template with a copy-code
button: Templates → New → Authentication. Without one, the first request is
refused and says so. Name one with template if you have several.
With a test key
Nothing is sent and nothing is charged, and the code is always 000000,
so you can build the whole flow without a phone. It goes through the same
checks as a live code (wrong codes, five tries, ten minutes), so what works
against a test key works against a live one. Test and live codes are counted
separately: trying a real customer's number with a test key cannot use up
their live allowance.
Webhooks
Rather than asking us what happened, be told. Settings → Webhooks, as an owner or admin: give an https address, choose what it should hear about, and keep the signing secret shown once.
| Event | When |
|---|---|
message.received |
a customer sends you a message |
message.sent |
a message you sent left for WhatsApp |
message.delivered |
a message you sent reached the phone |
message.read |
they read it |
message.failed |
it could not be delivered |
contact.opted_out |
somebody asked to stop hearing from you |
payment.received |
a customer paid you by M-Pesa from a conversation, confirmed with M-Pesa itself: amount, receipt, phone, reference, conversation |
template.status_changed |
Meta approved, rejected, paused or disabled a template, or refused a change to one: template_id, name, status, previous_status, reason |
deal.created |
a deal was added to the pipeline: the deal, as GET /deals gives it |
deal.stage_changed |
a deal moved to another stage: the deal, with from_stage |
deal.won |
a deal was won |
deal.lost |
a deal was lost, with its lost_reason |
appointment.booked |
an appointment was booked: the appointment, as GET /appointments gives it |
appointment.changed |
an appointment was confirmed, moved, cancelled or marked as come or not |
We POST JSON:
{
"id": "6f1e…",
"event": "message.received",
"occurred_at": "2026-09-23T08:15:04.221Z",
"data": {
"message_id": "7cca…",
"conversation_id": "1b9d…",
"wa_id": "254712345678",
"direction": "inbound",
"type": "text",
"status": "delivered",
"text": "Bei ya unga?"
}
}
data for message.received holds everything in
What a message holds: a tapped button arrives with its
reply, a picture with its media, a pin with its location.
with headers:
sema-event: message.received
sema-delivery: 6f1e…
sema-signature: t=1758579000,v1=1f2c…
Check the signature
The signature is an HMAC-SHA256 of "<timestamp>.<raw body>" with your
secret. Check it against the raw body, before any JSON parsing, and
reject anything with an old timestamp. That is what stops a delivery
captured today being replayed tomorrow.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!parts.t || !parts.v1 || age > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
Answering
Answer 2xx as soon as you have the delivery. Store it and do the work
afterwards. Anything else, or no answer within ten seconds, and we try again
after 10s, 30s, 2m, 5m, 15m, 1h and 6h, then give up and say so in the
delivery log under the endpoint.
Expect the same delivery twice. Retries mean at-least-once: use id (or
sema-delivery, the same value) to recognise one you have already handled.
Deliveries are listed in Settings → Webhooks for a fortnight, with the answer your server gave and how many attempts it took.
What we will not post to
An address inside a private network (127.0.0.1, 10.x, 192.168.x,
169.254.x, .internal) is refused when you add it, and checked again
against the address the name actually resolves to when we deliver. A webhook
is a request our servers make because you asked, and that is not a way into
anybody's network.
Adding and removing webhooks with a key
What Zapier, Make and n8n do when a workflow with a Sema trigger is turned on
and off, and what your own system can do too. It takes a live key with the
webhooks scope: a webhook is real, and a test key changes nothing real.
curl https://sema.datacare.co.ke/api/v1/webhooks \
-H "Authorization: Bearer $SEMA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/sema",
"events": ["message.received", "contact.opted_out"],
"description": "Order system"
}'
{
"id": "9b2c7e14-5a3d-4f81-b6c0-2e9d8a7f1c35",
"url": "https://hooks.example.com/sema",
"events": ["message.received", "contact.opted_out"],
"description": "Order system",
"enabled": true,
"added_by": "api",
"created_at": "2026-09-30T10:04:11Z",
"secret": "whsec_…"
}
The secret is in this answer and never again: keep it, and check every
delivery with it. GET /api/v1/webhooks lists them all, without secrets, and
DELETE /api/v1/webhooks/{id} removes one (204).
- The rules are the ones Settings keeps: an https address outside any private network, at least one event, 25 webhooks a workspace.
- A key removes only webhooks added through the API. One a person added in
Settings is
403, and removed there. - A webhook a key added is removed when that key is revoked.
GET /api/v1/key answers which workspace a key is for, with its name, scopes
and mode. Any valid key may ask; it is how a platform checks a key when
someone connects Sema.