Appearance
Webhooks
Each product can send its ticket events to your own endpoints: an issue tracker, a chat channel, a data warehouse, a script of yours. Endpoints are added per product on the panel (Admin → Products → Webhooks) or through the admin API, up to 10 a product. Each endpoint picks the events it takes; none picked means all of them.
Events
| Event | Sent when | entry |
|---|---|---|
ticket.created | a user reports a problem | none |
ticket.replied | a user replies on a ticket (which reopens it if it was waiting on them or resolved) | the reply |
ticket.answered | support answers | the answer |
ticket.status_changed | support changes the status | the change, with the new status |
ticket.deleted | support deletes a ticket | none |
ticket.live_started | a ticket becomes a live conversation (support switched it, or took the user's request) | the change, with mode: "live" |
ticket.live_ended | a live ticket goes back to an ordinary one (switched back, or quiet for too long) | the change, with mode: "tickets" |
webhook.test | someone presses Test by the endpoint on the panel. Every endpoint gets it, whatever its events | none (data has product, webhook, by) |
Erasing a user sends nothing: their tickets and any deliveries still queued about them simply go.
The payload
Every delivery is a POST with a JSON body of type, timestamp and data, as Standard Webhooks has it.
By default, only what happened to which ticket. No text, no telemetry, no user id:
json
{
"type": "ticket.answered",
"timestamp": "2026-11-05T14:12:09.481Z",
"data": {
"product": "road-builder",
"ticket": {
"id": 1,
"status": "in_progress",
"mode": "tickets",
"app_version": "2.3.1",
"created_at": "2026-11-05T11:02:44Z",
"updated_at": "2026-11-05T14:12:09Z",
"url": "https://support.example.com/desk/q/road-builder/1"
},
"entry": {
"id": 7,
"author": "support",
"kind": "comment",
"created_at": "2026-11-05T14:12:09Z"
}
}
}url is the ticket on the panel, there when the deployment's public_url is set. entry.kind is comment, status or mode; entry.author is user or support; entry.agent is true when support's entry came from an agent (a service account), not a person.
With content on for the endpoint, it also carries what the user wrote and sent, after the product's redaction rules: the ticket's user (the anonymous id), note, format, context, telemetry and attachments, and the entry's body, format and attachments, with the support person's name on an answer:
json
{
"type": "ticket.created",
"timestamp": "2026-11-05T11:02:44.120Z",
"data": {
"product": "road-builder",
"ticket": {
"id": 1, "status": "open", "mode": "tickets", "app_version": "2.3.1",
"created_at": "2026-11-05T11:02:44Z", "updated_at": "2026-11-05T11:02:44Z",
"url": "https://support.example.com/desk/q/road-builder/1",
"user": "01a1abda-c81a-700e-b9e3-6d36065367d0",
"note": "The game froze when I pressed **save** right after placing the bridge.",
"format": "text/markdown",
"context": { "level": "Coastline 4", "pieces": 1184, "autosave": false },
"telemetry": { "fps": 24, "gpu": "Adreno 740", "account": "h:vo4lqptr7u4robiy" },
"attachments": [
{ "name": "screenshot.jpg", "type": "image/jpeg", "size": 50211 },
{ "name": "game.log", "type": "text/plain", "size": 132 }
]
}
}
}A delivery never carries a file's data, a secret, or a user's contact.
Headers and verifying
| Header | |
|---|---|
webhook-id | msg_…, the same on every retry of a delivery: drop one you've already had |
webhook-timestamp | unix seconds: refuse one more than a few minutes off, against replays |
webhook-signature | v1, and the base64 HMAC-SHA256 of <id>.<timestamp>.<body> under the endpoint's secret. After a rotation there are two, space-separated, for a day: either one matching is good |
The secret (whsec_ and base64) is shown once, when the endpoint is made or its secret rotated; decode the part after whsec_ for the key. Any Standard Webhooks library verifies it, or by hand:
python
import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes, tolerance=300) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
wid, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
mac = hmac.new(key, f"{wid}.{ts}.".encode() + body, hashlib.sha256).digest()
good = base64.b64encode(mac).decode()
return any(hmac.compare_digest(s.split(",", 1)[1], good)
for s in headers["webhook-signature"].split() if s.startswith("v1,"))js
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, headers, body /* Buffer */, tolerance = 300) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const id = headers['webhook-id'], ts = headers['webhook-timestamp']
if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false
const good = createHmac('sha256', key).update(`${id}.${ts}.`).update(body).digest()
return headers['webhook-signature'].split(' ').some((s) => {
const [v, sig] = s.split(',')
const b = Buffer.from(sig || '', 'base64')
return v === 'v1' && b.length === good.length && timingSafeEqual(b, good)
})
}go
func verify(secret string, h http.Header, body []byte, tolerance time.Duration) bool {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
ts, err2 := strconv.ParseInt(h.Get("webhook-timestamp"), 10, 64)
if err != nil || err2 != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
m := hmac.New(sha256.New, key)
fmt.Fprintf(m, "%s.%d.", h.Get("webhook-id"), ts)
m.Write(body)
good := base64.StdEncoding.EncodeToString(m.Sum(nil))
for _, s := range strings.Fields(h.Get("webhook-signature")) {
if v, sig, ok := strings.Cut(s, ","); ok && v == "v1" && hmac.Equal([]byte(sig), []byte(good)) {
return true
}
}
return false
}Verify the body exactly as it arrived, before parsing it.
Delivery
- Answer with a
2xxand it's delivered. Do the work afterwards: a try times out after 15 seconds. - Otherwise it's retried, at once, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h: a little over a day. Then it's marked failed, and can be retried by hand from the endpoint's deliveries on the panel.
410 Goneturns the endpoint off. No redirect is followed.- Order isn't guaranteed across retries: use
timestamp, or the ticket'supdated_at, to tell which is newer. - Endpoints must be
https://, and never a loopback, link-local or multicast address. A self-hosted deployment can allowhttp://and block private addresses too: see Deploying. - Deliveries are kept a week, and are erased with the user they're about.