Skip to content
donutchat.com

Event webhooks

Have DonutChat send your bot’s chat events to your HTTPS endpoint as signed POST requests, so you don’t need to keep an event stream connection open. This suits serverless functions and existing web services.

  • message.new, thread.reply.new, interaction.new, reaction.add and reaction.remove events, with the same JSON body as on the event stream.
  • No chat_added or chat_removed events. To see the bot’s current chats, call GET /bots/v1/chats (see List the bot’s chats).
  • The same filtering as the stream: only posts by people, the bot’s trigger modes and thread filtering. A message that’s edited or deleted before delivery isn’t sent.
  • If the bot also has an event stream connection open, each event goes to both.
  1. Open the bot from My Bots. See Open My Bots.
  2. Find the Webhook card: on iPhone under Advanced developer settings, on Android under Advanced · developer settings, and in the web app on the bot’s Bot Details page.
  3. Select Set Webhook URL.
  4. Enter your endpoint in Webhook URL. It must be a public https URL.
  5. Select Save.
  6. In Webhook Signing Secret, select Copy and store the secret with your server’s other secrets. The secret starts with whsec_ and is shown only once.
  7. Select Done.

The Webhook card shows your URL and the delivery status: Active, Paused, or Auto-disabled after repeated failures (date). Use the switch next to the status to pause or resume deliveries. Turning it back on also clears an automatic disable.

From the ⋯ menu on the card:

  • Edit URL changes the endpoint and keeps the signing secret. Saving a URL also turns deliveries back on.
  • Regenerate Secret replaces the signing secret after you confirm with Regenerate. The old secret stops working immediately, and the new one is shown once, so update your endpoint right away.
  • Remove stops deliveries and clears the URL and the secret, after you confirm with Remove in Remove Webhook.

DonutChat sends each event as a POST request with these headers:

HeaderValue
Content-Typeapplication/json
User-AgentDonutChat-Webhook/1.0
X-Donut-Signaturesha256= followed by the hex-encoded HMAC-SHA256 of the raw body, keyed with the signing secret
X-Donut-Event-TypeThe event type, for example message.new
X-Donut-Delivery-IdAn ID for this delivery. Retries of a delivery use the same ID.

The body is the event envelope, exactly as on the event stream:

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

Check the X-Donut-Signature header on every request before you act on it. Event webhook signatures describes the check step by step. Each of these minimal servers checks the signature against the raw body before it parses the JSON:

// webhook.mjs: Node.js 22 or later, no dependencies.
// Run with: DONUT_WEBHOOK_SECRET=whsec_... node webhook.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.DONUT_WEBHOOK_SECRET;
// 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);
function isSigned(body, signature = '') {
const digest = createHmac('sha256', SECRET).update(body).digest('hex');
const expected = Buffer.from(`sha256=${digest}`);
const received = Buffer.from(signature);
return received.length === expected.length && timingSafeEqual(received, expected);
}
const seen = new Set(); // recent event IDs
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks);
if (req.method !== 'POST' || !isSigned(body, req.headers['x-donut-signature'])) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // answer first, then do the work
const event = parseJSON(body.toString('utf8'));
if (seen.has(event.event_id)) return; // a retry of an event you already have
if (Date.now() - Date.parse(event.timestamp) > 5 * 60_000) return; // too old
seen.add(event.event_id);
if (seen.size > 1_000) seen.delete(seen.values().next().value);
console.log(`${event.type} in chat ${event.data.chat_id}`);
}).listen(8080);

Responses, retries and automatic disabling

Section titled “Responses, retries and automatic disabling”
  • Answer with any 2xx status within 10 seconds. DonutChat ignores the response body; to post in the chat, use the bot API, for example a thread reply.
  • DonutChat doesn’t follow redirects. A redirect counts as a failed attempt.
  • If a request times out, can’t connect, or gets 408, 429, a 5xx status or a redirect, DonutChat tries again, up to 3 attempts in total. Retries happen within a minute of the first attempt. Any other 4xx response is final and isn’t retried.
  • A delivery fails when every attempt fails or your endpoint returns a final 4xx. After about 20 failed deliveries in a row, DonutChat turns the webhook off and the card shows Auto-disabled after repeated failures (date). Fix your endpoint, then turn the switch back on. A successful delivery resets the count.
  • Delivery is best effort. Events whose delivery fails, or that happen while the webhook is off, aren’t sent again, and replay doesn’t cover webhooks.
  • Verify the signature on every request, and keep the signing secret in your server’s secret storage.
  • Answer with 2xx first and do slow work afterwards, for example through a queue, so you stay within 10 seconds.
  • Answer 2xx for events you choose to ignore. 4xx responses count as failed deliveries and can turn the webhook off.
  • De-duplicate by the body’s event_id. A retry has the same event_id and X-Donut-Delivery-Id.
  • The signature covers only the body. To reject a request that someone recorded and sent again, skip event_id values you’ve already processed and events whose timestamp is more than 5 minutes old, as the Node.js example does.
  • In JavaScript, parse the body so that 64-bit IDs stay exact. See IDs are 64-bit integers.