Skip to content

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 ​

EventSent whenentry
ticket.createda user reports a problemnone
ticket.replieda user replies on a ticket (which reopens it if it was waiting on them or resolved)the reply
ticket.answeredsupport answersthe answer
ticket.status_changedsupport changes the statusthe change, with the new status
ticket.deletedsupport deletes a ticketnone
ticket.live_starteda ticket becomes a live conversation (support switched it, or took the user's request)the change, with mode: "live"
ticket.live_endeda live ticket goes back to an ordinary one (switched back, or quiet for too long)the change, with mode: "tickets"
webhook.testsomeone presses Test by the endpoint on the panel. Every endpoint gets it, whatever its eventsnone (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-idmsg_…, the same on every retry of a delivery: drop one you've already had
webhook-timestampunix seconds: refuse one more than a few minutes off, against replays
webhook-signaturev1, 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 2xx and 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 Gone turns the endpoint off. No redirect is followed.
  • Order isn't guaranteed across retries: use timestamp, or the ticket's updated_at, to tell which is newer.
  • Endpoints must be https://, and never a loopback, link-local or multicast address. A self-hosted deployment can allow http:// and block private addresses too: see Deploying.
  • Deliveries are kept a week, and are erased with the user they're about.