Send a WhatsApp message
Sends a text or template message from one of your connected WhatsApp numbers.
Pick the sending number with EITHER number_id (a Sautikit number
id — the same one the rest of this API uses; we map it to Meta's
phone_number_id for you) OR connection_id (a WhatsApp connection,
whose WhatsApp number is used). Exactly one is required.
A number_id that is not yours, or that no connected WhatsApp
account carries, is rejected before anything reaches Meta.
Returns 202 with Meta's wamid. Delivery is asynchronous —
subscribe to the whatsapp.event.received workspace webhook, which
relays Meta's delivery-status payloads to you verbatim.
Sautikit does not store messages. The wamid is your handle for correlating the status events that follow.
WhatsApp only permits free-form text inside a 24-hour customer
service window opened by the contact messaging you. Outside it, use
type: template with a Meta-approved template.
Message types
type names the message, and an object of the SAME NAME carries its
contents. That object is forwarded to WhatsApp untouched, so its
shape is WhatsApp's — including any type added after this page was
written.
Text
{ "number_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0", "to": "254712345678", "type": "text", "text": { "body": "Habari Wanjiru — your order from Duka la Vitabu is ready for pickup on Kimathi Street until 6pm.", "preview_url": false }}Set preview_url: true to render a preview card for the first link
in the body.
Image, video, audio, document, sticker
Media takes EITHER id (from POST /v1/whatsapp/media) or link
(a public URL WhatsApp fetches). Prefer id: a link is re-fetched
on every send and cached for only 10 minutes.
Image with a caption — an M-PESA receipt:
{ "number_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0", "to": "254712345678", "type": "image", "image": { "id": "1234567890123456", "caption": "Receipt for KES 2,400 — Sarit Centre branch" }}Document — an invoice a customer can download:
{ "type": "document", "document": { "id": "1234567890123456", "filename": "Invoice-INV-2291.pdf", "caption": "Your invoice for July deliveries" }}Audio has no caption (WhatsApp renders it as a voice note); sticker
takes only id or link and must be WebP.
Location
Send a pickup point:
{ "type": "location", "location": { "latitude": -1.2833, "longitude": 36.8172, "name": "Mama Ngina Street pickup point", "address": "Mama Ngina St, Nairobi CBD" }}Contacts
Hand over a rider's details:
{ "type": "contacts", "contacts": [ { "name": { "formatted_name": "Otieno Odhiambo", "first_name": "Otieno" }, "phones": [{ "phone": "+254733000111", "type": "CELL", "wa_id": "254733000111" }], "org": { "company": "Nairobi Swift Deliveries", "title": "Rider" } } ]}Interactive: reply buttons
Up to three buttons. Confirming a delivery slot:
{ "type": "interactive", "interactive": { "type": "button", "body": { "text": "Your parcel arrives in Westlands tomorrow. Which slot suits you?" }, "action": { "buttons": [ { "type": "reply", "reply": { "id": "slot_morning", "title": "8am - 12pm" } }, { "type": "reply", "reply": { "id": "slot_afternoon", "title": "12pm - 5pm" } } ] } }}The tap arrives on your webhook as an inbound interactive message
carrying the id you chose.
Interactive: list
Up to ten rows, grouped in sections — good for a menu or a catalogue the customer scrolls:
{ "type": "interactive", "interactive": { "type": "list", "header": { "type": "text", "text": "Today's menu" }, "body": { "text": "Choose a dish and we'll deliver within 45 minutes." }, "action": { "button": "View menu", "sections": [ { "title": "Mains", "rows": [ { "id": "nyama_choma", "title": "Nyama choma", "description": "Half kilo, KES 750" }, { "id": "pilau", "title": "Pilau ya nyama", "description": "KES 450" } ] } ] } }}Interactive: CTA URL button
Puts a long or ugly link behind a button:
{ "type": "interactive", "interactive": { "type": "cta_url", "body": { "text": "Track your parcel from our Mombasa Road depot." }, "action": { "name": "cta_url", "parameters": { "display_text": "Track parcel", "url": "https://track.example.co.ke/p/9F2K" } } }}Interactive: location request
Ask a customer where to deliver, instead of parsing a typed address:
{ "type": "interactive", "interactive": { "type": "location_request_message", "body": { "text": "Where should the boda drop your order?" }, "action": { "name": "send_location" } }}Interactive: Flow
A multi-step form inside the chat — bookings, KYC, surveys. The answers arrive on your webhook as structured data:
{ "type": "interactive", "interactive": { "type": "flow", "body": { "text": "Book your service appointment at our Thika Road garage." }, "action": { "name": "flow", "parameters": { "flow_message_version": "3", "flow_id": "1234567890", "flow_cta": "Book a slot", "flow_action": "navigate" } } }}Reaction
React to a message the customer sent you:
{ "type": "reaction", "reaction": { "message_id": "wamid.HBgMMjU0NzEyMzQ1Njc4...", "emoji": "👍" }}Send an empty emoji to remove a reaction.
Replying in context
Any message can quote one the customer sent, which is worth doing when a conversation has several threads running:
{ "type": "text", "context": { "message_id": "wamid.HBgMMjU0NzEyMzQ1Njc4..." }, "text": { "body": "Yes — that size is in stock at our Nakuru branch." }}Templates
Outside the 24-hour window you may only send a template Meta has approved. Sautikit does not create templates — that happens in WhatsApp Manager or through Meta's Message Templates API — here you send one by name.
template.components is forwarded untouched, so its shape is
Meta's. Body parameters fill {{1}}, {{2}} in order, and the
count must match the approved template exactly or Meta rejects the
send.
Utility template
Transactional, tied to something the customer did — an order, a payment, a delivery:
{ "number_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0", "to": "254712345678", "type": "template", "template": { "name": "order_dispatched", "language_code": "en", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Wanjiru" }, { "type": "text", "text": "SK-4821" }, { "type": "text", "text": "Tuesday" } ] } ] }}Marketing template
Promotional. Subject to per-user marketing limits and the recipient's marketing opt-out, so a send can be accepted here and still not delivered:
{ "type": "template", "template": { "name": "back_to_school_offer", "language_code": "en", "components": [ { "type": "header", "parameters": [ { "type": "image", "image": { "id": "1234567890123456" } } ] }, { "type": "body", "parameters": [{ "type": "text", "text": "20%" }] }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [{ "type": "text", "text": "back-to-school" }] } ] }}Authentication template
One-time passwords. The body text is fixed by Meta — you supply only the code — and the same code repeats as the button parameter so the customer can copy or autofill it:
{ "type": "template", "template": { "name": "verification_code", "language_code": "en", "components": [ { "type": "body", "parameters": [{ "type": "text", "text": "418902" }] }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [{ "type": "text", "text": "418902" }] } ] }}Authentication templates have a 10-minute time-to-live (everything else is 30 days) — an undelivered code is dropped rather than arriving long after it expired. They are also delivered only to the customer's primary WhatsApp device.
Phone number format
Include the + and country code. Without a +, WhatsApp prepends
YOUR number's country code — for a Kenyan number 0712345678
becomes +2540712345678, which is not a real number. Send
+254712345678 or 254712345678.
What a 202 means
A 202 means WhatsApp ACCEPTED the request, not that the message
arrived. Delivery outcomes reach you on the
whatsapp.event.received webhook; correlate them by the wamid
returned here.
API-key scope: whatsapp.messages.
Authorization
bearerAuth Long-lived ES256 JWT minted from the dashboard (https://app.sautikit.com/developers/api-keys). Signed by the
platform keyring. Carries workspace_id and scopes claims;
revoked via the platform deny-list.
In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/v1/whatsapp/messages" \ -H "Content-Type: application/json" \ -d '{ "number_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0", "to": "254700000001", "type": "text", "text": { "body": "Your order is ready for pickup.", "preview_url": false } }'{ "wamid": "wamid.HBgMMjU0NzAwMDAwMDAxFQIAERgSQjBBMEEwMDAwMDAwMDAwMDAA", "status": "accepted"}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ], "resolution": "string", "reason": "invalid_characters", "suggested_e164": "+254727524723" }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ], "resolution": "string", "reason": "invalid_characters", "suggested_e164": "+254727524723" }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ], "resolution": "string", "reason": "invalid_characters", "suggested_e164": "+254727524723" }}Toggle transcription for a number's WhatsApp calls PUT
Sets the SautiKit-side "transcribe WhatsApp calls" flag. Like recording, this is SautiKit-side speech-to-text over our own SIP media leg — Meta's native transcription is Cloud/WebRTC-only and unavailable to a BYOC SIP integration.
Mark an inbound message as read POST
Sends a read receipt for an inbound message — the blue ticks the contact sees. Name the number the message arrived on with either `connection_id` or `number_id`, exactly as you would for a send. Sautikit does not retain messages, so it cannot infer the number from the `wamid` alone; the `wamid` comes from the `whatsapp.event.received` webhook that delivered the message. Meta's reference: https://developers.facebook.com/docs/whatsapp/cloud-api/guides/mark-message-as-read **API-key scope:** `whatsapp.messages`.