Webhooks
A webhook sends case events to your own systems as they happen: a CRM, a chat bot, a data warehouse. Each request is signed with a secret only you and the desk know, and failed deliveries are retried.
Webhooks are set up by admins under Settings → Webhooks, and belong to one organization: they get events for cases in that organization and every unit below it.
Creating a webhook
- Under Settings → Webhooks, choose New webhook.
- Fill in the fields below.
- Choose Create webhook.
- Copy the signing secret (
whsec_…) and store it with the receiving service.
| Field | What to enter |
|---|---|
| Name | For you, like the system it feeds. |
| Endpoint URL | Where the desk posts. In production it must be https:// and resolve to a public address (not loopback, private, link-local or reserved ranges), and must not carry a user name or password. The address is checked again before every delivery. |
| Organization | Shown when there is more than one. |
| Events | At least one (below). |
[!IMPORTANT] The signing secret is shown once. Afterwards the desk only shows its first and last characters.
Events
| Event | When | data |
|---|---|---|
case.created | A new case: from a form or portal, logged by an agent, or created through the API or an assistant. A held request counts when an agent accepts it. | {} |
case.customer_replied | The requester wrote, on the case page or by email. | message |
case.agent_replied | An agent replied to the requester. | message |
case.status_changed | The status changed. | from, to |
case.assigned | The assignee changed. | assignee_id (null when unassigned) |
case.rated | The requester answered the satisfaction survey. | score (1 to 3), label (bad, okay, great) |
agreement.sent | An agreement was sent to a contact for signing. | see below |
agreement.signed | An agreement was signed, or recorded as signed. | see below |
agreement.declined | A contact declined an agreement. | see below, and reason |
ping | Send test event on the webhook page. | message |
The agreement events aren't about a case: instead of case and data, the body has org_id and agreement, with
id, title, status, account (id, name), signer (name, title, email), sha256, signed_at,
valid_until and url.
Plugins that are on add events of their own, such as Software Development's dev.task.done; the webhook editor lists
them under each plugin's name. Their payload is described in building a plugin.
[!NOTE] Events are sent after automations and service levels have run, so the case in the payload is the one after rules have routed, labelled or assigned it.
Payload
Every request is a POST with a JSON body:
{
"id": "cm3x9k2a40001…",
"event_id": "cm3x9k2a40001…",
"event": "case.agent_replied",
"created_at": "2026-10-21T09:14:03.512Z",
"case": {
"ref": "ABC-123",
"subject": "Can't sign in",
"status": "pending",
"priority": "normal",
"labels": ["login"],
"source": "form",
"channel": null,
"locale": "en",
"unit": { "id": "…", "name": "Support" },
"fields": { "message": "I can't sign in since this morning." },
"props": { "erp_order": "SO-40112" },
"created_at": "2026-10-21T08:52:40.118Z",
"url": "https://support.example.com/app/cases/ABC-123"
},
"data": {
"message": {
"id": "…",
"body": "Hi Sam, we've reset your account …",
"author": { "id": "…", "name": "Alex Kim" },
"created_at": "2026-10-21T09:14:03.400Z"
}
}
}
| Field | Meaning |
|---|---|
id | The delivery's id; it's also in the X-Desk-Delivery header. Automatic retries of one delivery keep the same id. |
event_id | Identifies the event, and stays the same when a delivery is sent again with Redeliver: it is the id of the event's first delivery. It's also in the X-Desk-Event-Id header. |
case.url | Opens the case in the agent app (sign-in needed); it is not the requester's link. |
case.source | form, portal, agent or api. |
case.channel | How a case logged by an agent reached them (phone, email, in_person, chat, other), otherwise null. |
case.assignee | null when nobody is assigned. |
A ping has no case.
[!TIP] Store the event ids you have handled and ignore an event you've seen, so a redelivery isn't processed twice.
Headers on every request:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Desk-Event | The event, like case.created. |
X-Desk-Delivery | The delivery id. |
X-Desk-Event-Id | The event id: the same on every delivery of the same event, redeliveries included. |
X-Desk-Timestamp | Unix time in seconds when this attempt was sent. |
X-Desk-Signature | sha256= and the HMAC-SHA256, in hex, of the timestamp, a dot and the raw body, with the signing secret. |
Verifying requests
Check the signature on the raw body, before parsing the JSON, and refuse old timestamps so a captured request can't be replayed. In Node with Express:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = process.env.DESK_WEBHOOK_SECRET; // whsec_…
function verify(req) {
const ts = req.get('X-Desk-Timestamp') ?? '';
const sig = req.get('X-Desk-Signature') ?? '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(ts + '.').update(req.body).digest('hex');
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
const app = express();
app.post('/hooks/desk', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req)) return res.status(401).end();
const event = JSON.parse(req.body);
// Answer quickly; do slow work in the background.
res.status(204).end();
handle(event);
});
The same snippet is on the webhook page under Verifying requests.
Answering, retries and backoff
First attempt → Delivered
First attempt → Retrying: another status, a timeout, a refused connection or a redirect
Retrying → Delivered: a later attempt gets a 2xx within 10 seconds
Retrying → Gave up: after six attempts in all
- Any
2xxanswer within 10 seconds counts as delivered. Redirects are not followed and count as a failure. - Anything else (another status, a timeout, a refused connection) is retried up to five times, waiting longer each time: about 30 seconds to a minute before the first retry, roughly doubling after that, so the last retry comes about half an hour after the first attempt at most. After six attempts in all, the delivery is given up.
- Retries send the same body and delivery id, with a new timestamp and signature. Use the delivery id to ignore a delivery you have already handled.
- Deliveries are not guaranteed to arrive in order. Use
created_atand the case's current state rather than the order of arrival.
[!NOTE] A webhook that is turned off, or whose URL fails the address check at delivery time, gets no retries: the delivery fails right away with the reason.
The delivery log
The webhook page lists deliveries, newest first, under Deliveries: the event, the answer's status code (or no answer), how long it took and when. Refresh reloads the list.
Open one to see the request body, the response (its first 1,000 characters) and the outcome, with the error:
| Outcome | Meaning |
|---|---|
| Delivered | The endpoint answered with a 2xx in time. |
| Attempt 2 failed; retrying | An attempt failed and another is planned. |
| Gave up after 6 attempts | Every attempt failed; no more retries. |
The badge next to the webhook's name shows the last real delivery (test events don't count): Healthy, Sending, Failing, No deliveries yet, or Off.
Testing and redelivering
- Send test event posts a
pingto the endpoint, signed like any other delivery, so you can check the receiving side. - Redeliver on a delivery sends the same event and data again as a new delivery, with a new delivery id and
created_atbut the sameevent_id. Use it after fixing the receiving side.
Turning off, changing and deleting
- The switch at the top turns the webhook On or Off. While it is off, events are not sent and pending retries fail.
- Change the name, URL or events under Settings and save.
- Delete (in the menu at the top) stops deliveries and removes the log.
Rotating the secret
In the menu at the top, Rotate secret creates a new signing secret and shows it once.
[!WARNING] The old secret stops working right away, so update the receiving service at the same moment. Deliveries that fail in between are retried with the new secret, or can be redelivered.
Every change to a webhook, and every rotation, is in the audit log.
Through the API
Admins and tokens with the admin scope manage webhooks through the REST API under /api/v1/webhooks. See
API tokens and /api/docs on your desk.
| Endpoint | What |
|---|---|
/api/v1/webhooks | List, create (the answer carries the secret once), change, delete. |
POST /api/v1/webhooks/{id}/test | Send a test event. |
POST /api/v1/webhooks/{id}/secret | Rotate the secret. |
GET /api/v1/webhooks/{id}/deliveries | The delivery log. |
POST /api/v1/webhooks/deliveries/{id}/redeliver | Redeliver a delivery. |