. Create an API key under API keys.Guide
Sending messages
Message types
POST /v1/sessions/{sessionId}/messages sends one message. type decides which other field is required:
type | Required | Optional |
|---|---|---|
text | body (up to 64 KB) | reply_to |
image, video | media_r2_key or url | caption |
document, audio, sticker | media_r2_key or url | filename (with url) |
location | location: {lat, lng} | |
poll | poll_name, choices (2–12) | |
contact | contact: {name, phone} |
curl -X POST "https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages" \
-H "Authorization: Bearer $CHATKOMODO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "+447700900123",
"type": "image",
"url": "https://example.com/promo.jpg",
"caption": "This week'\''s offer"
}'const res = await fetch('https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHATKOMODO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"chat_id": "+447700900123",
"type": "image",
"url": "https://example.com/promo.jpg",
"caption": "This week's offer"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
console.log(data);import os
import requests
res = requests.post(
"https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages",
headers={
"Authorization": f"Bearer {os.environ['CHATKOMODO_API_KEY']}",
},
json={
"chat_id": "+447700900123",
"type": "image",
"url": "https://example.com/promo.jpg",
"caption": "This week's offer",
},
timeout=30,
)
res.raise_for_status()
print(res.json())<?php
require 'vendor/autoload.php';
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('CHATKOMODO_API_KEY'),
],
'json' => [
'chat_id' => '+447700900123',
'type' => 'image',
'url' => 'https://example.com/promo.jpg',
'caption' => 'This week\'s offer',
],
]);
print_r(json_decode((string) $res->getBody(), true));curl -X POST "https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages" \
-H "Authorization: Bearer $CHATKOMODO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "+447700900123",
"type": "poll",
"poll_name": "Preferred delivery time?",
"choices": [
"Morning",
"Afternoon",
"Evening"
]
}'const res = await fetch('https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHATKOMODO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"chat_id": "+447700900123",
"type": "poll",
"poll_name": "Preferred delivery time?",
"choices": [
"Morning",
"Afternoon",
"Evening"
]
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
console.log(data);import os
import requests
res = requests.post(
"https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages",
headers={
"Authorization": f"Bearer {os.environ['CHATKOMODO_API_KEY']}",
},
json={
"chat_id": "+447700900123",
"type": "poll",
"poll_name": "Preferred delivery time?",
"choices": [
"Morning",
"Afternoon",
"Evening",
],
},
timeout=30,
)
res.raise_for_status()
print(res.json())<?php
require 'vendor/autoload.php';
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('CHATKOMODO_API_KEY'),
],
'json' => [
'chat_id' => '+447700900123',
'type' => 'poll',
'poll_name' => 'Preferred delivery time?',
'choices' => [
'Morning',
'Afternoon',
'Evening',
],
],
]);
print_r(json_decode((string) $res->getBody(), true));Buttons and list messages have their own endpoint: POST /v1/sessions/{sessionId}/messages/interactive.
Media files
Either pass a public url (downloaded by ChatKomodo, up to 25 MB), or upload once (up to 10 MB) and send by key — faster when the same file goes to many people:
curl -X POST "https://api.chatkomodo.com/v1/media/upload" \
-H "Authorization: Bearer $CHATKOMODO_API_KEY" \
-F "[email protected]"import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append('file', new Blob([await readFile('price-list.pdf')]), 'price-list.pdf');
const res = await fetch('https://api.chatkomodo.com/v1/media/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHATKOMODO_API_KEY}`,
},
body: form,
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
console.log(data);import os
import requests
res = requests.post(
"https://api.chatkomodo.com/v1/media/upload",
headers={
"Authorization": f"Bearer {os.environ['CHATKOMODO_API_KEY']}",
},
files={"file": open("price-list.pdf", "rb")},
timeout=30,
)
res.raise_for_status()
print(res.json())<?php
require 'vendor/autoload.php';
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://api.chatkomodo.com/v1/media/upload', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('CHATKOMODO_API_KEY'),
],
'multipart' => [
['name' => 'file', 'contents' => fopen('price-list.pdf', 'r')],
],
]);
print_r(json_decode((string) $res->getBody(), true));The response has the key to send as media_r2_key. Uploaded files are listed in GET /v1/media.
Message statuses
The send call answers as soon as the message is queued. Its status then moves on:
| Status | Meaning |
|---|---|
queued | Waiting in the device's queue (see pacing below). |
sent | Accepted by WhatsApp (one grey tick). Webhook message.sent. |
delivered | Reached the recipient's phone (two grey ticks). |
read | Opened, if the recipient shares read receipts (two blue ticks). |
failed | Not sent; error says why (for example not on WhatsApp, monthly quota used up). Webhook message.failed. Retry with POST /v1/sessions/{sessionId}/messages/{msgId}/retry. |
cancelled | Taken out of the queue with POST /v1/sessions/{sessionId}/messages/cancel. |
Safe retries: Idempotency-Key
A network error after you sent a request does not tell you whether the message went out. Add an Idempotency-Key header (an order id, a UUID) and simply repeat the request: the second call returns the first result with "idempotent_replay": true instead of sending twice.
curl -X POST "https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages" \
-H "Authorization: Bearer $CHATKOMODO_API_KEY" \
-H "Idempotency-Key: order-1042-shipped" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "+447700900123",
"type": "text",
"body": "Your order #1042 has shipped."
}'const res = await fetch('https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHATKOMODO_API_KEY}`,
'Idempotency-Key': 'order-1042-shipped',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"chat_id": "+447700900123",
"type": "text",
"body": "Your order #1042 has shipped."
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
console.log(data);import os
import requests
res = requests.post(
"https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages",
headers={
"Authorization": f"Bearer {os.environ['CHATKOMODO_API_KEY']}",
"Idempotency-Key": "order-1042-shipped",
},
json={
"chat_id": "+447700900123",
"type": "text",
"body": "Your order #1042 has shipped.",
},
timeout=30,
)
res.raise_for_status()
print(res.json())<?php
require 'vendor/autoload.php';
$client = new GuzzleHttp\Client();
$res = $client->request('POST', 'https://api.chatkomodo.com/v1/sessions/YOUR_DEVICE_ID/messages', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('CHATKOMODO_API_KEY'),
'Idempotency-Key' => 'order-1042-shipped',
],
'json' => [
'chat_id' => '+447700900123',
'type' => 'text',
'body' => 'Your order #1042 has shipped.',
],
]);
print_r(json_decode((string) $res->getBody(), true));Pacing and new numbers
WhatsApp blocks numbers that behave like bulk senders, so every device sends through its own queue at a human pace: a few seconds between messages to different chats (with a short typing indicator), at most 30 messages a minute and 1 000 a day. A newly linked number is warmed up for its first 24 hours (at most 10 a minute and 200 a day). Repeated send errors slow the queue down by themselves.
So the API answers fast, and delivery follows the queue. For many recipients use a broadcast rather than a loop of single sends: it adds consent checks, the send window and progress counters. To send faster, spread traffic over several devices with a rotator.
When a device is offline
Sends to a device that is not connected are refused with 409 CONFLICT, so you notice at once. Pass "connect_only": false to queue the message anyway; it goes out when the device reconnects. The webhook session.disconnected tells you when a phone logs out.
Opt-outs
People who reply STOP (and the same word in many languages) are opted out automatically. A one-to-one send to them still goes out and the response carries a warning:
{
"id": "01JA5W2B7C9D0E1F2G3H4J5K6M",
"status": "queued",
"warnings": [
{
"code": "RECIPIENT_OPTED_OUT",
"message": "This recipient has opted out of messages"
}
]
}With enforce_api switched on in PUT /v1/consent/config the send is refused instead (409 RECIPIENT_OPTED_OUT). Broadcasts always skip opted-out people.
Send window
Broadcast messages are only released inside the business's daily send window (setting send_window, in the business time zone or each recipient's own). Outside it they wait. One-to-one replies, scheduled and recurring messages are not held.
Personalization tokens
Broadcasts, templates, auto-replies and welcome messages fill {name}, {first_name}, {phone}, {var1}…{var5} and {attr.<field>} per recipient. Give a fallback with double braces: {{first_name|there}}.