Send messages
Post messages as your bot in the chats it belongs to, find those chats, and show people that the bot is working. To answer inside a thread or show a reply while it’s written, see Thread replies and streaming.
Every request needs the bot’s token in an Authorization: Bearer header. See Authentication.
Post a message
Section titled “Post a message”https://api.donutchat.com/bots/v1/messages Posts a new message in the chat as the bot. The bot must be in the chat.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | integer | Yes | The chat to post in. The bot must be an active member. |
content | string | Yes | The message text, up to 80,000 UTF-8 bytes. Can’t be empty. |
content_type | string | No | TX for plain text (the default) or MD for Markdown. Not case-sensitive. |
username | string | No | Sender name for this message. Defaults to the bot’s name. |
avatar_url | string | No | Sender picture for this message, as an absolute http or https URL. Defaults to the bot’s picture. |
The Node.js and Python examples read the token and chat ID from the DONUT_BOT_TOKEN and DONUT_CHAT_ID environment variables:
curl -X POST https://api.donutchat.com/bots/v1/messages \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{ "chat_id": 1234, "content": "**Deploy finished**\n\n- API: healthy\n- Web: healthy", "content_type": "MD" }'// Write BigInt values as plain JSON numbers (Node.js 22 or later).const toJSON = (value) => JSON.stringify(value, (key, v) => (typeof v === 'bigint' ? JSON.rawJSON(v.toString()) : v));
const res = await fetch('https://api.donutchat.com/bots/v1/messages', { method: 'POST', headers: { Authorization: `Bearer ${process.env.DONUT_BOT_TOKEN}`, 'Content-Type': 'application/json', }, body: toJSON({ chat_id: BigInt(process.env.DONUT_CHAT_ID), // BigInt keeps every digit content: '**Deploy finished**\n\n- API: healthy\n- Web: healthy', content_type: 'MD', }),});const text = await res.text();if (!res.ok) throw new Error(`Send failed (${res.status}): ${text}`);console.log(text);import jsonimport osimport urllib.request
body = { "chat_id": int(os.environ["DONUT_CHAT_ID"]), "content": "**Deploy finished**\n\n- API: healthy\n- Web: healthy", "content_type": "MD",}request = urllib.request.Request( "https://api.donutchat.com/bots/v1/messages", data=json.dumps(body).encode(), headers={ "Authorization": "Bearer " + os.environ["DONUT_BOT_TOKEN"], "Content-Type": "application/json", },)with urllib.request.urlopen(request) as response: print(json.load(response))A successful request returns HTTP 200 with the new message’s ID:
{ "ok": true, "message_id": 5678, "chat_id": 1234}What happens when you post
Section titled “What happens when you post”- The message shows the bot’s name and picture, or your
usernameandavatar_url, never the name of the bot’s owner. - Members see the message right away. Members who don’t have the app open get a push notification unless they muted the chat. That includes the bot’s owner.
- The bot’s trigger mode doesn’t limit posting. A bot set to Manual only can still post.
- Bots never receive messages from bots, including their own, so a post can’t trigger another bot.
- There’s no idempotency key. Retrying a request that timed out can post the message twice.
- This endpoint can’t reply in a thread, attach files, or edit or delete a message. To answer in a thread, use thread replies.
Errors
Section titled “Errors”| HTTP | error | Cause |
|---|---|---|
| 400 | invalid_json | The body isn’t valid JSON, or a field has the wrong type, such as chat_id sent as a string. |
| 400 | missing_chat_id | chat_id is missing, zero or negative. |
| 400 | missing_content | content is missing or empty. |
| 400 | content_too_long | content is over 80,000 UTF-8 bytes. |
| 400 | invalid_avatar_url | avatar_url isn’t an absolute http or https URL. |
| 400 | invalid_content_type | content_type isn’t TX or MD. |
| 401 | unauthorized | The Authorization header is missing or malformed, the token is wrong or was regenerated, or the bot is paused. |
| 403 | not_in_chat | The bot isn’t an active member of the chat, or the chat was deleted. |
| 405 | None (empty body) | The method isn’t POST. |
| 500 | send_failed | The sender name is longer than 32 bytes, or something went wrong on the server. |
| 503 | None (plain text) | DonutChat is briefly unavailable. Retry with increasing delays. |
List the bot’s chats
Section titled “List the bot’s chats”https://api.donutchat.com/bots/v1/chats Returns the chats the bot belongs to, most recently added first. There are no parameters.
curl https://api.donutchat.com/bots/v1/chats \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE"// Read integers too large for JavaScript numbers as BigInt (Node.js 22 or later).const parseJSON = (text) => JSON.parse(text, (key, value, context) => Number.isInteger(value) && !Number.isSafeInteger(value) ? BigInt(context.source) : value);
const res = await fetch('https://api.donutchat.com/bots/v1/chats', { headers: { Authorization: `Bearer ${process.env.DONUT_BOT_TOKEN}` },});const text = await res.text();if (!res.ok) throw new Error(`Request failed (${res.status}): ${text}`);for (const chat of parseJSON(text).chats) { console.log(String(chat.chat_id), chat.chat_name);}import jsonimport osimport urllib.request
request = urllib.request.Request( "https://api.donutchat.com/bots/v1/chats", headers={"Authorization": "Bearer " + os.environ["DONUT_BOT_TOKEN"]},)with urllib.request.urlopen(request) as response: for chat in json.load(response)["chats"]: print(chat["chat_id"], chat["chat_name"]){ "ok": true, "chats": [ { "chat_id": 1234, "chat_name": "Release team", "chat_type": "group", "is_active": true, "member_count": 6 } ], "total": 1}| Field | Description |
|---|---|
chats[].chat_id | The chat’s ID. |
chats[].chat_name | The chat’s name. |
chats[].chat_type | group, direct, feed or self. A private conversation that someone opens from the bot’s profile is a group chat. |
chats[].is_active | false if the bot is switched off in this chat. It can’t post there. |
chats[].member_count | The number of members in the chat. |
total | The number of chats the bot belongs to. Can be more than 100. |
- The list has at most 100 chats and no pagination. If
totalis more than 100, you get the 100 most recently added. - Chats the bot was removed from aren’t listed.
- Errors: 401
unauthorizedfor token problems and 500list_failedif the list couldn’t be loaded.
Show a typing indicator
Section titled “Show a typing indicator”https://api.donutchat.com/bots/v1/typing Shows people in a chat that the bot is typing, with an optional short status, while it prepares a message.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | integer | Yes | The chat to show the indicator in. The bot must be an active member. |
is_typing | boolean | No | true (the default) shows the indicator. false clears it. |
status_text | string | No | Short status, such as Checking the build. Up to 80 UTF-8 bytes. |
curl -X POST https://api.donutchat.com/bots/v1/typing \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"chat_id": 1234, "status_text": "Checking the build"}'{ "ok": true, "chat_id": 1234}- When the bot is the only one typing, Android, the desktop app and the web app show its username followed by
status_text. The iPhone app shows only that the bot is typing. - The indicator doesn’t last. On iPhone it disappears about 10 seconds after the last request, so send the request again every 3 to 9 seconds while the bot works. Send
"is_typing": falsewhen you’re done. - DonutChat shows at most one start request per bot and chat every 3 seconds. Extra requests still return 200 but have no effect. Requests with
"is_typing": falsealways go through. - Typing isn’t saved and doesn’t send notifications.
- The indicator belongs to the chat, not to a thread. To show progress on a reply in a thread, use a streaming thread reply.
| HTTP | error | Cause |
|---|---|---|
| 400 | invalid_json | The body isn’t valid JSON, or a field has the wrong type. |
| 400 | missing_chat_id | chat_id is missing, zero or negative. |
| 400 | status_text_too_long | status_text is over 80 UTF-8 bytes. The message says “80 characters”, but bytes are counted. |
| 401 | unauthorized | The header or token isn’t valid, or the bot is paused. |
| 403 | not_in_chat | The bot isn’t an active member of the chat. |
| 500 | typing_failed | The typing event couldn’t be sent. |
How messages render
Section titled “How messages render”content_type | What readers see by default |
|---|---|
TX | Plain text. Markdown characters appear as typed. |
MD | Formatted Markdown: headings, bold, italic, links, lists (including task lists), block quotes, code blocks, tables and horizontal rules. |
- In the iPhone and Android apps, images in
MDmessages appear inline. The desktop app shows the image’s alt text with an Open image link instead. - In the iPhone, Android and desktop apps, readers choose how text appears on their device under Message display: As sent (the default), Formatted or Plain text. With As sent,
MDmessages are formatted andTXmessages are plain text. Readers can also switch a single message with View formatted or View plain text. - Push notifications and the chat list show
MDmessages as plain text, cut at 240 characters. - An
MDmessage can include interactive cards. An answer to a card in the main chat goes into that message’s thread. The web app and older app versions turn off the buttons on cards in the main chat. Desktop app versions without card support show the card’s JSON as a code block.