Webhooks
Signed webhooks
HMAC-signed webhooks Zono sends to your endpoints when tickets, customers and conversations change: delivery, headers, payload, events and signature verification.
Delivery
Team admins add endpoints under Workspace settings > Developers > Webhooks, pick events, and get a signing secret (shown once). Each endpoint has a delivery log, a "Send test event" button and secret rotation with a 24-hour overlap. Automation rules can also call an endpoint with the "Call webhook" action, and integrations subscribe through the webhook subscriptions endpoints.
- Zono sends a POST with a JSON body to each active endpoint subscribed to the event, from a queue, usually within seconds.
- Answer with any 2xx status within 10 seconds. Anything else (including redirects, which are never followed) is a failure.
- Failed deliveries are retried 7 times over about 1.6 days, each retry after a longer delay with random jitter. Order is not guaranteed; use created_at and Zono-Event-Id.
- An endpoint whose deliveries keep failing for more than 24 hours (15 failed attempts in a row), or that answers 410 Gone, is disabled and the team's admins are emailed. Enable it again from Settings > Webhooks.
- Endpoint URLs must be public http(s) addresses (https in production); private, loopback, link-local and cloud metadata addresses are refused, when saving and again on every delivery.
- Payloads over 256 KB are sent with data.truncated = true and just the object's id; fetch it from the API.
- Deliveries are kept for 30 days in the delivery log, where you can inspect the request and response and resend any of them.
Failed deliveries are retried 7 times (after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 17 hours, each +/- 10%), 8 attempts in total over about 1.6 days.
Request headers
Zono-Signature | t=<unix timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">. During a secret rotation a second v1 value is sent. |
Zono-Event | The event type, e.g. ticket.created. |
Zono-Event-Id | The event id (evt_...). The same on every retry and manual resend: use it to ignore duplicates. |
Zono-Delivery | This delivery's id. Changes on every resend. |
User-Agent | Zono-Webhooks/1.0 |
Payload
Every event has the same envelope. data.object uses the same shape as the API returns for that object.
{
"id": "evt_01j9x3m4k7d2c8b5a6f0e1g2h3",
"type": "ticket.replied",
"api_version": "v1",
"created_at": "2026-01-15T10:24:07Z",
"team": {
"id": "01j9x0a1b2c3d4e5f6g7h8j9k0"
},
"data": {
"object": {
"object": "ticket",
"id": "01j9x2p3q4r5s6t7u8v9w0x1y2",
"number": 1042,
"subject": "Cannot activate my license",
"description": "<p>My key says it is already in use.</p>",
"status": "answered",
"priority": "high",
"close_reason": null,
"close_reason_detail": null,
"customer": {
"id": "01j9x1c2d3e4f5g6h7j8k9m0n1",
"name": "Ada Lovelace",
"email": "[email protected]"
},
"assignee": {
"id": "01j9x0u1v2w3x4y5z6a7b8c9d0",
"name": "Sam Agent",
"email": "[email protected]"
},
"department": {
"id": "01j9x0d1e2p3t4a5b6c7d8e9f0",
"name": "Billing"
},
"product": {
"id": 12,
"name": "Zono Desktop"
},
"channel": null,
"tags": [
{
"id": "01j9x0t1a2g3b4c5d6e7f8g9h0",
"name": "licensing",
"color": "#6366f1"
}
],
"custom_fields": {
"order_number": "A-1042"
},
"sla": null,
"triage": null,
"conversation_id": null,
"spam": null,
"merged_into": null,
"created_at": "2026-01-15T09:58:12Z",
"updated_at": "2026-01-15T10:24:07Z",
"last_reply_at": "2026-01-15T10:24:07Z",
"first_response_at": "2026-01-15T10:24:07Z",
"closed_at": null,
"url": "https://zono.support/tickets/01j9x2p3q4r5s6t7u8v9w0x1y2"
},
"reply": {
"object": "reply",
"id": "01j9x3m4k7d2c8b5a6f0e1g2h4",
"ticket_id": "01j9x2p3q4r5s6t7u8v9w0x1y2",
"body": "<p>Thanks Ada, a new key is on its way.</p>",
"is_internal": false,
"source": "web",
"author": {
"type": "user",
"id": "01j9x0u1v2w3x4y5z6a7b8c9d0",
"name": "Sam Agent"
},
"created_at": "2026-01-15T10:24:07Z"
},
"actor": {
"type": "staff",
"id": "01j9x0u1v2w3x4y5z6a7b8c9d0"
}
}
}Events
Tickets
ticket.created | A ticket was created by staff, a customer, the API, a channel or a split. |
ticket.updated | Subject, description, product, priority, department, tags or custom fields changed. `data.changes` lists what changed. |
ticket.status_changed | The ticket status changed. `data.changes.status` has the old and new status. |
ticket.closed | The ticket was closed (sent alongside ticket.status_changed). |
ticket.assigned | The ticket was assigned, reassigned or unassigned. |
ticket.replied | A public reply was posted by staff, the AI Agent or the customer. `data.reply` holds it. |
ticket.note_added | An internal note or internal reply was added. `data.note` or `data.reply` holds it. |
ticket.merged | A ticket was merged into this one. `data.merged_ticket` names the source. |
ticket.split | Replies were split off into this new ticket. `data.split_from` names the original. |
ticket.classified | AI Triage classified the ticket. `data.triage` has the intent, sentiment, language and confidence. |
ticket.sentiment_dropped | AI Triage saw the customer's sentiment drop sharply. `data.triage` has the new and previous sentiment scores. |
ticket.linked_issue_status_changed | A Linear or GitHub issue linked to the ticket changed status. `data.issue` has the provider, identifier, URL and the new and previous status. |
Customers
customer.created | A customer record was created, from any channel, import or the API. |
Conversations
conversation.promoted | An Inbox conversation was promoted to a ticket. |
Satisfaction
csat.rated | A customer answered (or changed) a satisfaction survey. `data.object` is the ticket, or the live chat conversation when the chat never became a ticket; `data.rating` has the score. |
SLA
sla.warning | An SLA target on the ticket is due soon. |
sla.breached | An SLA target on the ticket was missed. |
Other
ping | Sent when you click "Send test event". Never sent otherwise. |
automation.webhook_called | Sent by an automation rule's "Call webhook" action. `data.rule` names the rule. |
Verifying signatures
Compute HMAC-SHA256 of "{timestamp}.{raw body}" with your endpoint secret and compare it, in constant time, with each v1 value in Zono-Signature. Reject requests whose timestamp is more than 5 minutes old to stop replays, and de-duplicate on Zono-Event-Id.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (not re-serialised JSON).
export function verifyZonoSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((part) => part.trim().split('='));
const timestamp = Number(parts.find(([key]) => key === 't')?.[1]);
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return signatures.some(
(signature) => signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
);
}
// Express: app.post('/webhooks/zono', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyZonoSignature(req.body.toString('utf8'), req.get('Zono-Signature'), process.env.ZONO_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// const event = JSON.parse(req.body);
// res.sendStatus(204);
// });