Webhooks ​
Webhooks connect Voxt to other systems over plain HTTPS, without an API key or a client library.
- An outgoing webhook sends events (new messages, reactions, members joining, and so on) to a URL you choose.
- An incoming webhook gives a chat a URL; anything that posts to it appears in the chat as a message from a bot.
Where webhooks live ​
Every webhook has a scope, which decides what it can see and who can manage it.
| Scope | Who can manage it | Outgoing events | Incoming |
|---|---|---|---|
| Chat | Chat moderators and owners. Open the chat's settings and select Integrations. | Events of that chat. | Yes. |
| Place | Place owners. Open the place's settings and select Integrations. | Events of all chats in the place, or of the chats you select. | No. |
| Personal | You. Open Settings → API & apps. | Your notifications and the chats you select. | No. |
In Integrations, select Add integration, then choose Outgoing webhook (Send events to a URL) or Incoming webhook (Post messages from a URL). A chat offers both; places and personal webhooks are outgoing only. Incoming webhooks cannot post to the main chat of a place.
A webhook has a name of up to 64 characters. It can be disabled and enabled again, and deleted. Deleting an outgoing webhook drops its pending deliveries. Deleting an incoming webhook removes its bot from the chat; the bot's earlier messages stay.
Outgoing webhooks ​
Create one ​
Enter:
- Name.
- URL. It must be an absolute
https://URL with a public host name. IP addresses and internal domains are rejected, and the check is repeated for every delivery. If the host later resolves to a private address, the webhook is disabled with the reason "the URL resolves to a private address". - Events. At least one of the groups below.
- Include message text. On by default. When off, payloads carry message metadata and attachments but no text.
- Chats (place and personal webhooks). All chats is available for places; a personal webhook must either subscribe to My notifications or list at least one chat.
- Under Advanced, an optional custom header (name and value) that is sent with every delivery, for example
Authorization. The nameswebhook-id,webhook-timestamp,webhook-signature,user-agent,content-type,host,content-length,transfer-encodingandconnectionare reserved.
After you select Create, Voxt shows the signing secret once. Copy it then; it cannot be shown again, but you can rotate it later.
Events ​
Event groups select one or more event types. The type field of a delivery carries the type.
| Group | Event types |
|---|---|
| Messages | message.posted, message.edited, message.removed |
| Reactions | reaction.added, reaction.removed |
| Members | member.joined, member.left |
| Chat changes | chat.created, chat.updated, chat.archived |
| Place changes | place.updated, place.member.joined, place.member.left |
| My notifications | notification (personal webhooks) |
Every webhook can also receive ping, which is sent when you select Send test event.
message.posted is sent when a message is complete: a message that is still being streamed produces it when streaming ends. message.edited is sent when the text or the attachments change. Messages posted by an incoming webhook or another bot are reported like any other message, so a receiver that replies into the same chat through an incoming webhook should ignore messages whose origin.kind is webhook. Bots are not reported by the member events, and the main chat of a place has no member events (use the place events).
What a delivery looks like ​
Each event is one HTTP POST with a JSON body (Content-Type: application/json, at most 256 KB) and these headers:
| Header | Value |
|---|---|
webhook-id | Delivery ID; the same as id in the body. It is stable across retries, so use it to ignore duplicates. |
webhook-timestamp | Unix time in seconds when the attempt was sent. |
webhook-signature | v1, and a base64 HMAC-SHA256 signature. During a secret rotation it holds two signatures separated by a space. |
user-agent | Voxt-Hooks/1 |
The body is an envelope:
{
"id": "k3Jd9sLm0pQxZ2aB:message.posted:1042:7",
"type": "message.posted",
"timestamp": "2026-10-16T09:30:12.1234567Z",
"hook": { "id": "k3Jd9sLm0pQxZ2aB", "scope": "chat", "scopeId": "..." },
"chat": {
"id": "...",
"title": "Weekend plans",
"kind": "group",
"placeId": null,
"url": "https://voxt.ai/chat/..."
},
"data": {
"message": { "id": 1042, "version": 7, "text": "See you at the fountain", "...": "..." },
"previous": null
}
}Fields that have no value are omitted. chat is absent for events that do not belong to one chat (ping, place.*). data depends on the type:
| Type | data |
|---|---|
message.posted, message.edited | message, the message model shared with MCP, with origin.kind set to user, api, webhook or bot; for edits, previous holds the earlier version and text. |
message.removed | message with only id, author and isRemoved: true. |
reaction.added, reaction.removed | emoji, messageId (the message's local ID) and the reacting author. |
member.joined, member.left, place.member.joined, place.member.left | author: the member (id, name, avatarUrl). |
chat.created, chat.archived | chat: the chat block as above. |
chat.updated, place.updated | changed (a list of title, description, pictureUrl, isPublic), and the current title, description, pictureUrl and isPublic. |
notification | kind, title, text, and message when the notification refers to one. |
ping | sentBy: the name of the person who sent the test. |
When Include message text is off, text is omitted. When a payload would exceed 256 KB, the message text is cut and textTruncated is true.
Verify the signature ​
Signatures follow the Standard Webhooks scheme. The signing secret looks like whsec_ followed by a base64 key.
- Take the raw request body, exactly as received, and the
webhook-idandwebhook-timestampheaders. - Build the string
{webhook-id}.{webhook-timestamp}.{body}. - Compute HMAC-SHA256 of it with the base64-decoded part of the secret after
whsec_, and encode the result in base64. - Compare
v1,plus that value with each space-separated entry ofwebhook-signature, in constant time. Accept the request if one matches. - Reject requests whose
webhook-timestampdiffers from your clock by more than a few minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, rawBody, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = 'v1,' + createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`).digest('base64');
return headers['webhook-signature'].split(' ').some(
s => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Responses, retries and ordering ​
Reply with any 2xx status within 10 seconds. The response body is ignored.
- Deliveries of one webhook are sent one at a time, in order. A failing delivery blocks the ones behind it until it succeeds or is given up.
- A network error, a timeout,
429or a5xxstatus is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, and then every 12 hours. - Any other status (for example
400or404) fails that delivery without retries, and the next one is sent. Redirects are not followed. 410 Gonedisables the webhook at once.- If a delivery keeps failing for 72 hours, the webhook is disabled with the reason "disabled after repeated delivery failures".
- At most 10,000 deliveries can be pending for a hook.
A disabled webhook is marked in the list. Fix the receiver and enable the webhook again: its status and failure count reset, and pending deliveries resume.
Test, redeliver and rotate the secret ​
Open the webhook to manage it:
- Send test event sends a
pingat once and reports the status and latency. - Recent deliveries lists the latest 20 deliveries with their status (Pending, Delivered, Failed, Abandoned), attempts and latency. Select Redeliver on a completed delivery to send it again. Deliveries are kept for 30 days.
Rotate the secret ​
Rotate secret creates a new signing secret and shows it once. The previous secret keeps working for 24 hours: during that time every delivery carries two signatures, so you can switch the receiver without losing events.
Incoming webhooks ​
Creating an incoming webhook shows its URL once:
https://voxt.ai/hooks/in/whin_...The token in the URL is the only credential; keep it secret. Rotate URL replaces it, and the old URL stops working immediately. Post test message posts a test message to the chat.
The webhook has a Display name and an Avatar. Messages appear in the chat from a bot with that name and avatar, and the bot is a member of the chat.
Post a message ​
POST to the URL with Content-Type: application/json, or application/x-www-form-urlencoded with a payload field that holds the JSON. The body can be up to 64 KB.
curl -X POST "$HOOK_URL" \
-H "Content-Type: application/json" \
-d '{
"text": "Build 482 finished",
"attachments": [{
"title": "Release notes",
"title_link": "https://example.com/releases/482",
"text": "Fixes the upload retry bug.",
"fields": [{ "title": "Duration", "value": "4 min" }],
"footer": "CI"
}]
}'The format is compatible with Slack's incoming webhooks, so many tools that can post to Slack can post to Voxt:
| Field | Meaning |
|---|---|
text | Message text, in Voxt text markup. |
replyTo | Local ID of a message in the same chat to reply to. |
attachments | Cards. Each can have pretext, title, title_link, text, fields (each with title and value), footer, image_url and thumb_url. |
Cards are folded into the message text: the pretext, the bold title with its link, the text, one line per field (Title: value) and the footer. Up to four images from image_url and thumb_url (http or https) are fetched and attached. The message must have text or at least one card, and must not exceed the maximum message length.
Responses ​
| Status | Meaning |
|---|---|
200 | Posted: { "ok": true, "id": <local ID of the message> }. |
400 | The body is not valid, is empty, is too long, or replyTo is not a message of this chat: { "ok": false, "error": "..." }. |
404 | The URL is not valid. |
410 | The webhook is disabled or deleted, or its chat is archived. |
413 | The body is larger than 64 KB. |
429 | More than 60 posts a minute; wait for the time in the Retry-After header. |
503 | Temporary failure; retry later. |
Do not retry after 200: the message is already posted.
Using webhooks from code ​
The app manages webhooks through the IWebHooks service of the RPC API (create, update, delete, list, rotate secret, test and redeliver); the same operations are available to your own code through that service. Messages posted through an incoming webhook carry origin.kind set to webhook.