Workspace webhooks reference
Events, payloads, headers and signature verification for workspace webhooks.
A workspace webhook tells one of your own endpoints when something happens to a conversation. You add endpoints in the dashboard's workspace settings, on the webhooks page. Each endpoint is one of four kinds and subscribes to the events you choose.
| Kind | What it receives |
|---|---|
| Discord | A formatted card posted through a Discord webhook URL |
| Slack | A formatted message posted through a Slack incoming webhook URL |
| Telegram | A formatted message sent by your bot to a chat |
| Custom | A signed JSON body sent to your HTTPS endpoint |
The rest of this page describes the Custom kind, which is the one meant for code.
Events
| Event | Sent when |
|---|---|
conversation.created | A visitor starts a conversation, or answers a proactive message |
message.received | A visitor sends a message |
conversation.escalated | Aiva hands the conversation to your team |
conversation.assigned | A teammate becomes responsible for the conversation |
conversation.unassigned | The conversation no longer has a responsible teammate |
handoff.overdue | Nobody picked up a handoff within the response target you set for the team. Never sent for a team without one |
conversation.transferred | A teammate transferred the conversation to another team |
conversation.resolved | The conversation is resolved |
conversation.closed | The conversation is closed |
An endpoint subscribed to conversation.assigned before conversation.unassigned existed receives both. conversation.transferred reaches only the endpoints subscribed to it; no endpoint is subscribed to it for you. The dashboard offers it once your workspace has more than one team.
When a team assigns automatically, a conversation handed to it goes to one member who has the dashboard open, and that assignment is a conversation.assigned with no assignerName. So is a move to another member when the first one leaves before replying, or when nobody picks it up within the team's response target. When the conversation goes back to the whole team instead, it is a conversation.unassigned with no assignerName.
Body
Every delivery has the same envelope:
{
"event": "conversation.assigned",
"timestamp": "2026-09-28T14:02:11.000Z",
"data": {
"conversationId": "c2779aa20b318-476a1184e6fc2",
"contactName": "Ada",
"agentName": "Grace",
"assignerName": "Alan",
"team": { "id": "cm1team8x0001", "name": "Support" },
"assignee": { "id": "cm1mbr4k20007", "name": "Grace" }
},
"links": {
"conversation": "https://dash.assistify.chat/open/…"
}
}Fields in data, by event:
| Field | Events | Meaning |
|---|---|---|
conversationId | all | The conversation's id |
contactName | all | The visitor's name, or a fallback when they have not given one |
messagePreview | message.received | The first 300 characters of the message |
reason | conversation.escalated | Why Aiva handed the conversation over |
summary | conversation.escalated | Aiva's summary for the team |
isUrgent | conversation.escalated | Whether the request was marked urgent |
agentName | conversation.assigned | The teammate now responsible |
assignerName | conversation.assigned, conversation.unassigned, conversation.transferred | The teammate who made the change, absent when the product made it |
team | all, once a team holds the conversation | The team that holds the conversation: id and name |
assignee | all, while a teammate is responsible | The teammate responsible for the conversation: id and name |
handoff | conversation.escalated, handoff.overdue, conversation.transferred | The handoff: its id, and dueAt, when the team's response target passes. For a transfer, the receiving team's handoff |
reassigned | handoff.overdue | Whether the handoff went to another teammate, who then holds it; when false, the team's lead has been told |
fromTeam | conversation.transferred | The team the conversation left: id and name. Absent when it had none |
toTeam | conversation.transferred | The team the conversation reached: id and name. team names it too |
note | conversation.transferred | The note the teammate left for the receiving team, when there is one |
team and assignee describe the conversation when the event is sent. An assignee id identifies a member of your workspace; the same person has a different id in another workspace.
reason is one of user_requested, cannot_answer, frustrated_user, requires_authorization, unresolved_attempts, sensitive_topic, qualified_lead, wrong_scope or ai_unavailable.
Fields are only ever added. Ignore fields you do not recognize.
Headers
| Header | Value |
|---|---|
X-Assistify-Event | The event name, the same as event in the body |
X-Assistify-Timestamp | Unix seconds at signing |
X-Assistify-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body> |
X-Assistify-Delivery-Id | A UUID that stays the same across retries of one delivery |
Verifying the signature
Compute the HMAC over the timestamp, a dot and the raw request body, with the endpoint's signing secret from the dashboard. Compare it with the header in constant time, and reject a timestamp that is too old for your needs.
import { createHmac, timingSafeEqual } from 'node:crypto';
function isFromAssistify(rawBody, headers, secret) {
const timestamp = headers['x-assistify-timestamp'];
const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const received = headers['x-assistify-signature'] ?? '';
return expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}Retries
A delivery that does not get a 2xx response is retried up to five attempts in all, with an exponential backoff starting at two seconds. Every attempt carries the same X-Assistify-Delivery-Id, so you can ignore one you have already processed.
Test delivery
The test button on an endpoint sends the event test.ping, in the header and in the body:
{ "event": "test.ping", "timestamp": "…", "data": { "message": "This endpoint receives Assistify events." } }