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.
What you receive
Section titled “What you receive”message.new,thread.reply.new,interaction.new,reaction.addandreaction.removeevents, with the same JSON body as on the event stream.- No
chat_addedorchat_removedevents. To see the bot’s current chats, callGET /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.
Set up the webhook
Section titled “Set up the webhook”- Open the bot from My Bots. See Open My Bots.
- 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.
- Select Set Webhook URL.
- Enter your endpoint in Webhook URL. It must be a public
httpsURL. - Select Save.
- 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. - Select Done.
Manage the webhook
Section titled “Manage the webhook”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.
Request format
Section titled “Request format”DonutChat sends each event as a POST request with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | DonutChat-Webhook/1.0 |
X-Donut-Signature | sha256= followed by the hex-encoded HMAC-SHA256 of the raw body, keyed with the signing secret |
X-Donut-Event-Type | The event type, for example message.new |
X-Donut-Delivery-Id | An 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 }}Verify the signature
Section titled “Verify the signature”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.mjsimport { 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);# webhook.py: Python 3.9 or later, standard library only.# Run with: DONUT_WEBHOOK_SECRET=whsec_... python3 webhook.pyimport hashlibimport hmacimport jsonimport osfrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["DONUT_WEBHOOK_SECRET"].encode()
def is_signed(body: bytes, signature: str) -> bool: expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.encode())
class Handler(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not is_signed(body, self.headers.get("X-Donut-Signature", "")): self.send_response(401) self.end_headers() return self.send_response(200) self.end_headers()
event = json.loads(body) # Python integers keep 64-bit IDs exact print(event["type"], "in chat", event["data"]["chat_id"])
HTTPServer(("", 8080), Handler).serve_forever()Responses, retries and automatic disabling
Section titled “Responses, retries and automatic disabling”- Answer with any
2xxstatus 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, a5xxstatus or a redirect, DonutChat tries again, up to 3 attempts in total. Retries happen within a minute of the first attempt. Any other4xxresponse 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.
Best practices
Section titled “Best practices”- Verify the signature on every request, and keep the signing secret in your server’s secret storage.
- Answer with
2xxfirst and do slow work afterwards, for example through a queue, so you stay within 10 seconds. - Answer
2xxfor events you choose to ignore.4xxresponses count as failed deliveries and can turn the webhook off. - De-duplicate by the body’s
event_id. A retry has the sameevent_idandX-Donut-Delivery-Id. - The signature covers only the body. To reject a request that someone recorded and sent again, skip
event_idvalues you’ve already processed and events whosetimestampis 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.