Skip to content
On this page

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-Signaturet=<unix timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">. During a secret rotation a second v1 value is sent.
Zono-EventThe event type, e.g. ticket.created.
Zono-Event-IdThe event id (evt_...). The same on every retry and manual resend: use it to ignore duplicates.
Zono-DeliveryThis delivery's id. Changes on every resend.
User-AgentZono-Webhooks/1.0

Payload

Every event has the same envelope. data.object uses the same shape as the API returns for that object.

ticket.repliedJSON
{
    "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.createdA ticket was created by staff, a customer, the API, a channel or a split.
ticket.updatedSubject, description, product, priority, department, tags or custom fields changed. `data.changes` lists what changed.
ticket.status_changedThe ticket status changed. `data.changes.status` has the old and new status.
ticket.closedThe ticket was closed (sent alongside ticket.status_changed).
ticket.assignedThe ticket was assigned, reassigned or unassigned.
ticket.repliedA public reply was posted by staff, the AI Agent or the customer. `data.reply` holds it.
ticket.note_addedAn internal note or internal reply was added. `data.note` or `data.reply` holds it.
ticket.mergedA ticket was merged into this one. `data.merged_ticket` names the source.
ticket.splitReplies were split off into this new ticket. `data.split_from` names the original.
ticket.classifiedAI Triage classified the ticket. `data.triage` has the intent, sentiment, language and confidence.
ticket.sentiment_droppedAI Triage saw the customer's sentiment drop sharply. `data.triage` has the new and previous sentiment scores.
ticket.linked_issue_status_changedA 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.createdA customer record was created, from any channel, import or the API.

Conversations

conversation.promotedAn Inbox conversation was promoted to a ticket.

Satisfaction

csat.ratedA 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.warningAn SLA target on the ticket is due soon.
sla.breachedAn SLA target on the ticket was missed.

Other

pingSent when you click "Send test event". Never sent otherwise.
automation.webhook_calledSent 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.

Verify a delivery
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);
// });