ChatKomodo Docs Home Sign in

Guide

Sending messages

Message types

POST /v1/sessions/{sessionId}/messages sends one message. type decides which other field is required:

typeRequiredOptional
textbody (up to 64 KB)reply_to
image, videomedia_r2_key or urlcaption
document, audio, stickermedia_r2_key or urlfilename (with url)
locationlocation: {lat, lng}
pollpoll_name, choices (2–12)
contactcontact: {name, phone}
Send an image
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));
Send a poll
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:

Upload a file
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:

StatusMeaning
queuedWaiting in the device's queue (see pacing below).
sentAccepted by WhatsApp (one grey tick). Webhook message.sent.
deliveredReached the recipient's phone (two grey ticks).
readOpened, if the recipient shares read receipts (two blue ticks).
failedNot 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.
cancelledTaken 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.

Send once
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.

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:

Response
{
  "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}}.