Skip to content
Guides

Send Your First Message

A practical guide to POST /v1/messages covering every supported content type and the gotchas you’ll hit in production.

  • A Kirim API key (see Quickstart).
  • At least one connected WhatsApp Business account in your organisation.
  • For free-form messages: an open 24-hour conversation window with the recipient (i.e. they’ve messaged you in the last 24 h).
  • For template messages: the template name + language, approved by Meta. Browse yours via GET /v1/templates.

If your organisation has more than one connected WhatsApp number, you must specify which one to send from:

{
"from": "+628511760008029",
"to": "+628111111111",
"type": "text",
"text": { "body": "..." }
}

Single-account orgs may omit from; Kirim auto-resolves the only connected number. If you have zero connected numbers, the call returns 422 whatsapp_number_not_verified.

{
"to": "+628111111111",
"type": "text",
"text": { "body": "Halo dari Kirim" }
}
FieldNotes
text.body1-4096 characters. Plain text only — Meta strips most formatting except *bold*, _italic_, ~strike~, and `code`.

For sending outside the 24-hour window or for transactional notifications (OTP, order updates), use a template:

{
"to": "+628111111111",
"type": "template",
"template": {
"name": "order_confirmation",
"language": "id_ID",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "John" },
{ "type": "text", "text": "ORD-12345" }
]
}
]
}
}

components follows Meta’s exact shape — Kirim forwards verbatim. See Meta’s template message docs for the full schema.

{
"to": "+628111111111",
"type": "image",
"image": {
"url": "https://your-cdn.example.com/screenshot.jpg",
"caption": "Lihat tangkapan layar"
}
}
FieldNotes
image.urlPublic HTTPS URL Meta can fetch from. Must be served as the correct Content-Type.
image.captionOptional, max 1024 characters.

Same shape for document (allows extra filename field), video, audio. Maximum sizes per Meta:

TypeMax size
image5 MB (JPEG, PNG)
document100 MB (PDF, DOCX, XLSX, …)
video16 MB (MP4, 3GPP)
audio16 MB (AAC, M4A, MP3, OPUS, AMR)
{
"to": "+628111111111",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": { "text": "Klik untuk melacak pesanan Anda." },
"action": {
"name": "cta_url",
"parameters": {
"display_text": "Lacak Pesanan",
"url": "https://your-app.example.com/orders/12345"
}
}
}
}
{
"to": "+628111111111",
"type": "interactive",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Pilih layanan" },
"body": { "text": "Layanan apa yang Anda butuhkan?" },
"footer": { "text": "Pilih satu" },
"action": {
"button": "Lihat opsi",
"sections": [
{
"title": "Bantuan",
"rows": [
{ "id": "billing", "title": "Tagihan", "description": "Tanya soal tagihan" },
{ "id": "tech", "title": "Teknis", "description": "Bug, error, kesulitan" }
]
}
]
}
}
}

The customer’s selection arrives back as a message.received event with type: "interactive" containing the chosen id.

Every successful send returns:

{
"data": {
"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX",
"object": "message",
"to": "+628111111111",
"type": "text",
"status": "pending",
"created_at": "2026-05-24T08:00:00.000Z"
},
"request_id": "req_…"
}

status starts at pending and transitions via webhook callbacks (message.status events) through sentdeliveredread. Set up webhooks rather than polling.

If the send hits a fatal upstream error (recipient blocked, template disabled, outside 24 h window for text), the message persists with status: "failed" and an error block:

{
"data": {
"id": "msg_…",
"status": "failed",
"error": {
"code": "outside_24h_window",
"message": "Message failed to send because more than 24 hours...",
"provider_code": 131047
}
}
}

The structured error.code is one of the stable codes from the error catalogue. Use it to drive retry or fallback logic — e.g. on outside_24h_window, fall back to a template:

async function sendWithFallback(payload: TextSendBody) {
const res = await send(payload)
if (res.data.status === 'failed' && res.data.error?.code === 'outside_24h_window') {
return send({
to: payload.to,
type: 'template',
template: { name: 'reengagement', language: 'id_ID' },
})
}
return res
}

Every POST /v1/messages should carry an Idempotency-Key so a network blip doesn’t send the message twice:

Terminal window
curl https://api-kckit.kirim.chat/v1/messages \
-H "Authorization: Bearer $KIRIM_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8e1a2c30-f0a4-4c70-9c2d-7b5e3aef9201" \
-d '{"to":"+628111111111","type":"text","text":{"body":"Halo"}}'

See Idempotency for the full contract.