A webhook makes EPAV Desk send an HTTP POST request with a JSON body to an address of yours each time something happens to a conversation or a message, for example a new conversation, a status change or a new message. Use webhooks to keep a CRM, an order system or your own reports in step with your support desk.
Create a webhook
- Open Admin > Integrations > Webhooks and click New webhook.
- In Name, type a name you will recognize in the list.
- In URL, enter the address of your endpoint. Use an
https://address: requests carry customer names, email addresses, phone numbers and message text. - Under Events, tick at least one event. Events are grouped under Conversation and Message.
- In Secret, enter a long random string if you want every request signed. This is optional but recommended.
- Click Create.
A new webhook is active at once. Agents need the Manage webhooks permission in their role to see and change webhooks, see Roles and permissions.
Pause, edit, test or delete a webhook
On Admin > Integrations > Webhooks, the menu at the end of each row has Edit, Disable (Enable for a paused webhook), Send test and Delete. On the edit page, the Active checkbox pauses or resumes the webhook in the same way; click Save afterwards.
A disabled webhook receives nothing, including requests from automation rules. Deleting a webhook cannot be undone.
Events you can subscribe to
| Event | Checkbox in the form | Sent when |
|---|---|---|
conversation.created |
Conversation created | a new conversation starts: a customer writes in any channel, or an agent starts a conversation |
conversation.status_changed |
Conversation status changed | the status changes, for example from Open to Resolved, and each time a conversation is snoozed |
conversation.tags_changed |
Conversation tags changed | tags are added to a conversation or removed from it |
conversation.assigned |
Conversation assigned | a conversation is assigned to an agent or to the AI assistant |
conversation.unassigned |
Conversation unassigned | the assigned agent or assistant is removed, which also happens when the conversation moves to another team or the AI assistant hands it over to your team |
message.created |
Message created | any message is stored in a conversation |
message.updated |
Message updated | the delivery status of a message changes |
There is no event for a team change. Assigning a conversation to a different team, including the first team of a new conversation, removes its assigned agent, so it sends conversation.unassigned, even when no agent was assigned. A conversation that reopens because the customer writes again, or that returns to Open when its snooze time ends, sends no conversation.status_changed; in the first case the customer's message still arrives as message.created.
Each event is a separate request: a webhook subscribed to three events can receive three requests about one conversation.
What a webhook request contains
Every webhook request is a POST whose JSON body has three fields:
event: the event name, for examplemessage.created;timestamp: when the request was sent, in UTC, for example2026-10-04T09:15:02Z;payload: the data of the event.
The payload depends on the event:
conversation.created: the conversation itself.- The other conversation events:
conversation_uuid, the fields that describe the change (previous_status,new_statusandsnooze_until;previous_tagsandnew_tags;assigned_to),actor_idwith the ID of whoever made the change, and the fullconversation. message.createdandmessage.updated: the message itself.
A conversation object holds, among other fields, id, uuid, reference_number, status, priority, subject, inbox_id, inbox_name, inbox_channel, assigned_user_id, assigned_team_id, tags, custom_attributes, SLA deadlines and a contact object with the customer's name, email address and phone number.
Example: a status change
A conversation.status_changed request, with the conversation object shortened:
{
"event": "conversation.status_changed",
"payload": {
"actor_id": 12,
"conversation": {
"id": 311,
"uuid": "9b2e7d10-3c4a-4f8b-8e21-5a6b7c8d9e0f",
"reference_number": "1042",
"status": "Resolved",
"inbox_name": "Website chat",
"inbox_channel": "livechat",
"contact": { "id": 87, "first_name": "Maria", "last_name": "Rossi", "email": "maria@example.com" }
},
"conversation_uuid": "9b2e7d10-3c4a-4f8b-8e21-5a6b7c8d9e0f",
"new_status": "Resolved",
"previous_status": "Open",
"snooze_until": ""
},
"timestamp": "2026-10-04T10:02:45Z"
}
Example: a new message from a customer
A message.created request for a chat message, with the author shortened:
{
"event": "message.created",
"payload": {
"id": 5120,
"created_at": "2026-10-04T09:15:01Z",
"updated_at": "2026-10-04T09:15:01Z",
"uuid": "4f1c2a8e-6b0d-4e57-9a1c-2d3e4f5a6b7c",
"type": "incoming",
"status": "received",
"conversation_id": 311,
"conversation_uuid": "9b2e7d10-3c4a-4f8b-8e21-5a6b7c8d9e0f",
"content": "Where is my order 10452?",
"text_content": "Where is my order 10452?",
"content_type": "text",
"private": false,
"sender_id": 87,
"sender_type": "contact",
"meta": {},
"attachments": [],
"author": { "id": 87, "first_name": "Maria", "last_name": "Rossi", "type": "contact" }
},
"timestamp": "2026-10-04T09:15:02Z"
}
Tell messages apart in message.created
The message.created webhook event fires for every message stored in a conversation, not only for what the customer writes. These fields tell the kinds apart:
type:incomingfor the customer's messages,outgoingfor replies,activityfor system lines such as a status change or an assignment.private:truefor private notes and activity lines, which the customer never sees.author.type:ai_assistantfor replies written by the AI assistant,agentfor replies written by people,contactorvisitorfor the customer. Both kinds of reply havesender_typeset toagent.status: an outgoing message starts aspendingand then becomessentorfailed. Each of these changes arrives as amessage.updatedevent.
Verify the webhook signature
When a webhook has a Secret, every request carries the header X-Support-Signature. Its value is sha256= followed by the HMAC-SHA256 of the raw request body, computed with your secret as the key and written in lowercase hex.
To check a request, compute the same value from the bytes you received, before parsing or reformatting the JSON, and compare it with the header in constant time. Reject the request if they differ.
Without a secret, the header is not sent. On the edit page the stored secret is shown masked: leave it as it is to keep it, type a new value to replace it, or empty the field and save to stop signing.
Request headers of a webhook
| Header | Value |
|---|---|
Content-Type |
always application/json |
User-Agent |
always Support-Webhook/1.0 |
X-Support-Signature |
sha256= and the hex signature, only when the webhook has a secret |
Check the signature in Python
import hashlib
import hmac
def signature_ok(raw_body: bytes, header_value: str, secret: str) -> bool:
"""raw_body must be the request body exactly as received."""
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + digest, header_value or "")
# In Flask, for example:
# ok = signature_ok(request.get_data(), request.headers.get("X-Support-Signature", ""), SECRET)
Receive webhooks in Node.js with Express
This receiver keeps the body as raw bytes, checks X-Support-Signature, answers at once and does the work afterwards:
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.SUPPORT_WEBHOOK_SECRET;
app.post('/hooks/support', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const received = req.get('X-Support-Signature') || '';
const valid = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).end();
res.status(200).end(); // answer first
const delivery = JSON.parse(req.body.toString('utf8'));
setImmediate(() => {
if (delivery.event === 'conversation.created') {
// open a record in your CRM
} else if (delivery.event === 'message.created' && !delivery.payload.private) {
// store the message
}
});
});
app.listen(8080);
Check the signature in Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func supportWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("SUPPORT_WEBHOOK_SECRET")))
mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Support-Signature"))) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent) // any 2xx status counts as delivered
go handleDelivery(body) // slow work after the answer
}
func handleDelivery(body []byte) { /* parse the JSON and act on it */ }
func main() {
http.HandleFunc("/hooks/support", supportWebhook)
http.ListenAndServe(":8080", nil)
}
Answer within 3 seconds
EPAV Desk gives your endpoint 3 seconds to accept the connection, 3 seconds for the TLS handshake and 3 seconds after the request is sent for your response to begin. The whole request may take at most 15 seconds. These limits are the same for every customer and cannot be changed.
Any 2xx status counts as delivered. Another status, a refused connection or a timeout is a failed delivery, and a failed delivery is not sent again. So check the signature, answer 200 straight away, and do slow work such as calls to other systems afterwards.
Deliveries can be lost or arrive out of order
- Several deliveries are sent at the same time, so events can arrive in a different order than they happened. If order matters, read the current state of the conversation through the REST API before you act.
- Events are not stored. If your endpoint is down, the events of that period are lost, and the panel keeps no history of deliveries.
- Under a very heavy load, events that do not fit into the delivery queue are dropped.
When you cannot afford to miss data, also sync through the REST API from time to time.
Test a webhook
Open a saved webhook and click Send test, or choose Send test in its row menu. EPAV Desk sends a request with the event webhook.test and a payload holding only the webhook's id and name, signed like any other request, and waits for your endpoint's answer. The test uses the saved address and secret, so save your changes first. It is sent even while the webhook is disabled.
{ "event": "webhook.test", "payload": { "id": 3, "name": "Orders CRM" }, "timestamp": "2026-10-04T09:20:11Z" }
The message after the test shows how your endpoint answered:
- "Webhook sent successfully": the endpoint answered with a
2xxstatus. - "Test webhook not accepted: the endpoint answered with status 401." (with the status your endpoint returned): the request arrived, but the endpoint refused it, for example because the signature check failed.
- "Test webhook not delivered: the endpoint could not be reached.": no answer came, for example because the address is wrong, the server is down or it did not answer in time.
To see real events before your endpoint is ready, you can point a webhook at a request inspection service such as Webhook.site or RequestBin. Real events carry your customers' personal data, so do this only with test conversations and delete the webhook afterwards.
Send a webhook from an automation rule
Automation rules have a Trigger webhook action. It sends one request to the webhook you pick, with an event name you type, for example priority.escalated, and a payload with the conversation and the actor_id. The event name does not need to be one of the events ticked on the webhook, but the webhook must be active. See Automation rules. A worked example: Send handoffs to your CRM.