Skip to content
donutchat.com

Event stream

Open one WebSocket connection to receive your bot’s chat activity as JSON events: new messages and thread replies from people, emoji reactions, and notices when the bot is added to or removed from a chat.

To receive message, thread reply and reaction events as HTTPS requests instead, use event webhooks.

WSS wss://api.donutchat.com/bots/v1/stream
  • Authenticate with an Authorization: Bearer donutbot_YOUR_TOKEN_HERE header. The scheme is case-sensitive: Bearer followed by one space. Send the token exactly as the app showed it, including donutbot_.
  • If your WebSocket client can’t set headers, pass the token as a query parameter instead: ?token=donutbot_YOUR_TOKEN_HERE. It’s used only when there’s no valid Bearer header.
  • To ask for events your bot may have missed, also add last_event_id. See Replay.

The stream covers every chat where the bot is active (up to 10,000 chats) and any chat it’s added to while connected. The stream is one-way: the server ignores the content of anything your client sends.

A connection goes through these stages:

  1. The server checks the token. If anything fails at this point, you get an HTTP error instead of a WebSocket connection. See Errors.

  2. If you passed last_event_id, buffered events that came after that event arrive first.

  3. A connected frame confirms that the stream is live. It has no event_id and is never replayed.

    { "type": "connected", "data": { "bot_id": 42 } }
  4. Events follow as they happen, one JSON object per text frame.

The server doesn’t send error frames once the connection is open.

To try the stream from a terminal, you can use a WebSocket client such as websocat:

Terminal window
websocat -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" wss://api.donutchat.com/bots/v1/stream
  • The server sends a ping every 30 seconds. If your client doesn’t answer with a pong within 10 seconds, the server closes the connection. Most WebSocket libraries answer pings automatically.
  • Currently, the server closes a connection without a close frame when your client hasn’t sent a text or binary message for 5 minutes, even if it answers pings. Send a small text frame regularly, for example every 60 seconds. The server ignores its content.
  • Reconnect automatically whenever the socket closes, waiting a little longer after each failed attempt. Add a random amount to each wait, so that many bots don’t reconnect at the same moment.
  • During service maintenance, the server closes every connection with code 1000 and reason server shutdown. For a few seconds, a new connection can be refused with HTTP 503 and the error code server_restarting. Retry as you would after any failed attempt.

Every event uses the same envelope:

{
"event_id": "evt_1793957400_3f9a1c2b7d4e",
"type": "message.new",
"timestamp": "2026-11-06T09:30:00Z",
"data": {}
}
FieldDescription
event_idUnique ID of the event. Treat it as an opaque string.
typeThe event type.
timestampWhen DonutChat sent the event, in UTC (RFC 3339, whole seconds). It isn’t when the message was written.
dataThe event’s fields.
  • The same event can arrive more than once, especially around reconnects. De-duplicate by event_id.
  • Events are sent as they’re processed, so their order isn’t guaranteed.
  • Frames can be large: message text can be up to 80,000 UTF-8 bytes, and JSON escaping adds more. If your WebSocket library limits the size of incoming messages, allow at least 1 MiB.
  • IDs are 64-bit integers, and chat IDs are larger than JavaScript’s Number.MAX_SAFE_INTEGER. In JavaScript, parse frames so that large integers stay exact, as the example does.
TypeSent when
message.newA person posts a top-level message, and the bot’s trigger mode lets it through.
thread.reply.newA person replies in any thread in the chat, and the reply passes thread filtering.
interaction.newA person selects a button on one of your bot’s interactive cards. Sent in every trigger mode, only to the bot that sent the card.
reaction.add, reaction.removeA person adds or removes an emoji reaction on a top-level message. Sent in every trigger mode.
chat_addedWhile the bot is connected, someone adds it to a chat or starts a private conversation with it from its profile.
chat_removedWhile the bot is connected, someone removes it from a chat.

There are no events for edits, deletions, people joining or leaving, typing, polls, or reactions on thread replies. Posts by bots (including your own), incoming webhooks, automated feeds and DonutChat itself never produce events.

{
"event_id": "evt_1793957400_3f9a1c2b7d4e",
"type": "message.new",
"timestamp": "2026-11-06T09:30:00Z",
"data": {
"message_id": 5678,
"chat_id": 1234,
"chat_name": "Release team",
"chat_type": "group",
"sender": { "id": 42, "kind": "user", "name": "Alice Kim", "username": "alice" },
"text": "@releasebot what's blocking the 2.4 release?",
"content_type": "TX",
"message_type": "text",
"attachments": [],
"mentions_bot": true,
"thread_reply_count": 0,
"linked_messages": []
}
}
FieldTypeDescription
message_idintegerThe message. Use it as parent_message_id to reply in its thread.
chat_idintegerThe chat.
chat_namestringThe chat’s title. Can be empty.
chat_typestringgroup, direct, feed or self.
senderobjectThe person who wrote it: id, kind (always user), name (display name) and username.
textstringThe message text.
content_typestringHow the text is stored. Usually TX; other values can appear, for example for a ladder game.
message_typestringtext, image, audio, video or file.
attachmentsarrayFiles on the message, or []. See Attachments.
mentions_botbooleantrue when the text contains @ and the bot’s username, in any letter case, with no letter, digit or underscore directly after it.
thread_reply_countintegerHow many replies the message’s thread had when the event was sent.
linked_messagesarrayThe messages that the text links to, or []. See Thread and linked messages.
{
"event_id": "evt_1793957470_a81c4e902d3f",
"type": "thread.reply.new",
"timestamp": "2026-11-06T09:31:10Z",
"data": {
"reply_id": 9877,
"parent_message_id": 5678,
"chat_id": 1234,
"chat_name": "Release team",
"chat_type": "group",
"sender": { "id": 42, "kind": "user", "name": "Alice Kim", "username": "alice" },
"text": "@releasebot can you check the iOS build too?",
"content_type": "TX",
"mentions_bot": true,
"attachments": [],
"parent": {
"message_id": 5678,
"sender": { "id": 57, "kind": "user", "name": "Bora Lee", "username": "bora" },
"text": "The 2.4 release is blocked on the Android build.",
"attachments": []
},
"linked_messages": []
}
}

The fields match message.new, with these differences:

  • reply_id identifies the reply, and parent_message_id is the thread’s top-level message. To answer in the same thread, use that parent_message_id.
  • There’s no message_type or thread_reply_count.
  • parent is the thread’s top-level message. See Thread and linked messages.
  • mentions_bot is true only for a standalone mention: @yourbot with no letter, digit or underscore directly before or after it. @everyone and @here don’t count.

A bot receives each event by itself, and there’s no API for reading earlier messages. Two fields give a reply its context:

  • parent, on thread.reply.new, is the thread’s top-level message. Your bot may never have received that message as a message.new event: someone else may have written it without mentioning the bot, or the bot may have joined the chat later. parent is left out when that message was deleted.
  • linked_messages, on both events, has the messages that the text links to with a DonutChat message link, such as https://donutchat.com/l/c/1234/m/5678. Only links to messages in the same chat are included, at most three per event, and a link to the thread’s own top-level message isn’t repeated here. Your bot can’t open these links itself, because they open the app.

Each of these objects has the same fields:

FieldTypeDescription
message_idintegerThe top-level message. For a linked thread reply, the top-level message of its thread.
reply_idintegerOnly on a linked thread reply: the reply.
senderobjectWho wrote it: id, kind, name and username. kind is user for a person. Other values, such as bot or webhook, mark other senders, and username is then empty.
textstringThe text.
attachmentsarrayIts files, or []. Download them with this object’s message_id, and its reply_id when it has one.

Treat this content like any other message text: it’s untrusted input, and people other than the sender may have written it.

{
"event_id": "evt_1793957580_2b7e4c91d0aa",
"type": "interaction.new",
"timestamp": "2026-11-06T09:33:00Z",
"data": {
"interaction_id": 311,
"chat_id": 1234,
"chat_name": "Release team",
"chat_type": "group",
"sender": { "id": 42, "kind": "user", "name": "Alice Kim", "username": "alice" },
"source": { "kind": "reply", "id": 9876 },
"parent_message_id": 5678,
"card_id": "publish-draft",
"card_title": "Publish the draft?",
"action_id": "approve",
"action_label": "Approve",
"answer_reply_id": 9877
}
}
FieldTypeDescription
interaction_idnumberThe recorded answer. A person answers a card once, so you get one event per person per card.
senderobjectThe person who selected the button.
sourceobjectWhere the card is: kind is reply (a thread reply) or message (a top-level message), and id is that reply’s or message’s ID.
parent_message_idnumberThe thread’s top-level message. Reply there.
card_id, card_titlestringThe card’s id and title.
action_id, action_labelstringThe selected button’s id and label.
answer_reply_idnumberThe visible thread reply that DonutChat posted for this answer.
  • The same answer also appears in the thread as a reply from the person. If your bot receives that reply as thread.reply.new, the reply has the same interaction_id. Handle one of the two. See Handle the answer.
  • The event isn’t sent for answers from the web app or from older versions of the other apps. Those arrive only as a thread reply.
  • An answer isn’t an approval. Anyone who can reply in the thread can select a button.
{
"event_id": "evt_1793957520_5d0b7a3c9e12",
"type": "reaction.add",
"timestamp": "2026-11-06T09:32:00Z",
"data": {
"message_id": 5678,
"chat_id": 1234,
"emoji": "👍",
"reactor": { "id": 43, "name": "Bob Lee" }
}
}

reaction.remove has the same fields. reactor has the person’s id and display name, but no username.

{
"event_id": "evt_1793957580_c3e9f1a07b44",
"type": "chat_added",
"timestamp": "2026-11-06T09:33:00Z",
"data": { "chat_id": 1234, "chat_name": "Release team" }
}
{
"event_id": "evt_1793957640_7f2d6b18a9c0",
"type": "chat_removed",
"timestamp": "2026-11-06T09:34:00Z",
"data": { "chat_id": 1234 }
}
  • chat_name is left out when the chat has no title.
  • These events are sent only while the bot is connected. If someone adds or removes the bot while it’s disconnected, no event is sent, so after reconnecting, call GET /bots/v1/chats to see the bot’s current chats. It returns at most the 100 most recently added chats. See List the bot’s chats.
  • They don’t count toward the rate limit.

A bot’s trigger mode decides which messages and thread replies reach it. The bot’s owner sets a default, and a chat’s owner and managers can override it for that chat, in the iPhone, Android and web apps. There’s no API for it. See Choose when a bot responds.

Modemessage.newthread.reply.new
When mentioned (default)Only messages where mentions_bot is trueOnly replies with a standalone mention of the bot
All human messagesEvery message from a person, including messages addressed to another botEvery reply from a person, except replies addressed only to someone else
Manual onlyNoneNone
  • Reactions, interaction.new and membership events arrive in every mode.
  • The trigger mode doesn’t limit what your bot can send.
  • A private conversation that someone starts from the bot’s profile uses All human messages. Its chat_type is group.
  • The web app’s bot details and per-chat picker label All human messages as All messages.

DonutChat checks every reply that a person posts in any thread of the chat, not only in threads your bot started:

  1. Hand-off check, in every mode. The reply is skipped if it addresses someone else and doesn’t address your bot. A reply addresses someone else when it mentions another @name (@everyone and @here don’t count) or starts with a command for another bot, such as /deploy@otherbot. It addresses your bot when it has a standalone @yourbot, or starts with /deploy or /deploy@yourbot.
  2. Mode check. In Manual only, the reply is never delivered. In When mentioned, it’s delivered only with a standalone @yourbot; a leading /deploy@yourbot isn’t enough. In All human messages, it’s delivered.
  3. Replies with no text and no attachments are skipped.

In practice:

  • In When mentioned mode, every follow-up in a thread must mention the bot. A plain “thanks”, “yes” or “stop” doesn’t reach it, and the apps don’t add the mention for people.
  • In All human messages mode, plain follow-ups arrive unless they mention someone else.
  • Answers from interactive cards start with @yourbot, so they reach your bot in both modes. Other bots skip them. Such a reply has an interaction_id when the same answer was also sent as interaction.new.

When you reconnect, add last_event_id with the event_id of the last event you processed:

wss://api.donutchat.com/bots/v1/stream?last_event_id=evt_1793957400_3f9a1c2b7d4e
  • Buffered events that came after it arrive before the connected frame.
  • The buffer holds the bot’s most recent 100 events that are less than 5 minutes old.
  • If the ID is unknown or too old, you get every buffered event, so de-duplicate by event_id.
  • An event that arrives while you’re reconnecting can come twice: once replayed and once live.
  • Replayed messages and replies are checked again first. They’re dropped if they were edited or deleted, if the thread’s top-level message was deleted, or if the bot is no longer active in the chat.
  • Each bot can receive a burst of 100 events, refilled at 100 events per minute. The limit covers message.new, thread.reply.new, interaction.new and reaction events.
  • Events over the limit aren’t sent, and the stream doesn’t tell you. They stay in the replay buffer for a short time.
  • Read frames promptly and do slow work elsewhere. If your client falls about 100 frames behind, newer events are dropped, and a frame that takes more than 10 seconds to send closes the connection.

Problems before the connection opens come back as HTTP responses. JSON bodies have the form {"ok": false, "error": "<code>", "message": "<text>"}.

HTTPBodyCauseWhat to do
401unauthorizedThe token is missing or invalid, or the bot is paused.Check the token and that the bot is active.
405EmptyThe request wasn’t a GET.Open a WebSocket connection.
400 or 426Plain textThe token is valid, but the request isn’t a WebSocket upgrade.Use a WebSocket client.
500internal_errorThe server couldn’t load the bot’s chats.Retry with backoff.
503Plain textService maintenance.Retry with backoff.

Browser-style WebSocket clients, including the one built into Node.js, can’t show you these responses; the connection just fails. If it fails before the connected frame arrives, check the token with GET /bots/v1/chats, which returns the same 401 as JSON.

This client uses only built-in Node.js features. It keeps the connection open, reconnects with a growing delay, asks for replay, de-duplicates events and keeps 64-bit IDs exact.

// stream.mjs: Node.js 22 or later, no dependencies.
// Run with: DONUT_BOT_TOKEN=donutbot_YOUR_TOKEN_HERE node stream.mjs
const TOKEN = process.env.DONUT_BOT_TOKEN;
// Chat IDs can exceed Number.MAX_SAFE_INTEGER, so parse those integers as BigInt.
const parseJSON = (text) =>
JSON.parse(text, (key, value, context) =>
Number.isInteger(value) && !Number.isSafeInteger(value) ? BigInt(context.source) : value);
const seen = new Set(); // recent event IDs
let lastEventId = '';
let retryDelay = 1_000;
function connect() {
const url = new URL('wss://api.donutchat.com/bots/v1/stream');
// The built-in WebSocket follows the browser API, which can't set headers.
url.searchParams.set('token', TOKEN);
if (lastEventId) url.searchParams.set('last_event_id', lastEventId);
const ws = new WebSocket(url);
let keepalive;
ws.addEventListener('open', () => {
// The server closes connections that send no data for 5 minutes.
keepalive = setInterval(() => ws.send('keepalive'), 60_000);
});
ws.addEventListener('message', ({ data }) => {
const frame = parseJSON(data);
if (frame.type === 'connected') {
retryDelay = 1_000;
console.log(`Connected as bot ${frame.data.bot_id}`);
return;
}
if (seen.has(frame.event_id)) return;
seen.add(frame.event_id);
if (seen.size > 1_000) seen.delete(seen.values().next().value);
lastEventId = frame.event_id;
handleEvent(frame);
});
ws.addEventListener('close', ({ code }) => {
clearInterval(keepalive);
console.log(`Disconnected (${code}). Reconnecting in ${retryDelay / 1000} s.`);
setTimeout(connect, retryDelay * (0.5 + Math.random())); // random, so bots don't all return at once
retryDelay = Math.min(retryDelay * 2, 60_000);
});
}
function handleEvent({ type, data }) {
switch (type) {
case 'message.new':
case 'thread.reply.new':
console.log(`[${data.chat_id}] @${data.sender.username}: ${data.text}`);
break;
case 'interaction.new':
console.log(`[${data.chat_id}] @${data.sender.username} chose ${data.action_id} on ${data.card_id}`);
break;
case 'reaction.add':
case 'reaction.remove':
console.log(`${type} ${data.emoji} on ${data.message_id} by ${data.reactor.name}`);
break;
case 'chat_added':
case 'chat_removed':
console.log(`${type} ${data.chat_id}`);
break;
}
}
connect();

To answer, post a thread reply. The Quickstart puts both together.