Skip to content
For developers

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 /contacts returns the fictional set below plus whatever you have written with a test key. A test key is meant to sit in a .env file 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 is 422, 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 the media_id it 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 answers scheduled, then queued, sent and so on once it has gone, or failed, with error.message saying 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 to in a recipient is the template's values for them, as template takes them on a single send: body, header, header_media, links, coupon_code, offer_expires_at, cards. A value in template applies to everyone who does not give their own.
  • from and send_at apply to the whole batch.
  • The Idempotency-Key covers 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.source and 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, and reason says 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-resource and /.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:

  1. 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, assistant or agent) and who the customer is, signed as every webhook is.
  2. Answering. POST /api/v1/conversations/{id}/reply with {"text": "…"}. It goes the way the conversation came, and the inbox marks it as your agent's.
  3. Handing over. POST /api/v1/conversations/{id}/handover with {"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. Watch status for 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: status becomes failed, and even the right code is refused after that.
  • Five codes an hour may go to one number. A sixth is 429, and Retry-After says 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.