Receive webhooks

Add a webhook endpoint in Settings → Developers or over the API, verify the Standard Webhooks signature in Node.js, and understand retries, redelivery, and auto-disable.

Webhooks push signed HTTPS POSTs to your server the moment something happens — an order lands, a ticket checks in, an event goes live — so you never have to poll.

Add an endpoint

In the dashboard (Admin role or above), go to SettingsDevelopers and open the Webhooks tab.

  1. Choose Add endpoint.
  2. Enter the Endpoint URL — a public HTTPS URL; private and internal addresses are rejected.
  3. Optionally add a Description, pick the Events to send (at least one), and choose Add endpoint.
  4. Copy the whsec_... signing secret and store it with your integration — you can Reveal or Rotate it later from the endpoint page.

Or do the same over the API: POST /v1/webhook_endpoints with url, optional description, and event_types returns the endpoint with its raw secret once — every later read shows only a secret_preview. Manage endpoints with GET/PATCH/DELETE /v1/webhook_endpoints/..., rotate with POST /v1/webhook_endpoints/.../rotate_secret (the old secret stops verifying immediately; the new one is in that response only), and read the delivery log with GET /v1/webhook_endpoints/.../deliveries.

The event types

| Type | Fired when | | --- | --- | | order.created | An order was placed (storefront, box office, kiosk, or API) | | order.paid | A previously-unpaid order was settled in full (orders paid at creation emit only order.created) | | order.refunded | A refund was issued | | ticket.issued | E-tickets were generated and sent to the buyer | | ticket.checked_in | A ticket was admitted at the door | | event.published | A performance went on sale | | event.updated | An existing event's details were changed (drafts included) |

Each delivery POSTs a JSON body of the form type + timestamp + data, where data is a compact summary — ids plus a few headline fields. Fetch the full resource from the API when you need more. A real order.created body:

{
  "type": "order.created",
  "timestamp": "2026-07-06T11:03:58.954Z",
  "data": {
    "order_id": "84a40659-a5b7-4eae-9e8a-03be476fd58f",
    "order_no": "ZNR-8NZFPRZ8",
    "status": "unpaid",
    "channel": "boxOffice",
    "currency": "USD",
    "total_minor": 14887
  }
}

Verify the signature

Deliveries follow the Standard Webhooks convention, with three headers: webhook-id (stable for the event across every retry and redelivery — your dedup key), webhook-timestamp (unix seconds, fresh per attempt), and webhook-signature (v1, followed by a base64 HMAC). Recompute HMAC-SHA256 over the string id.timestamp.body using the base64-decoded part of your secret after the whsec_ prefix. A complete receiver:

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const secret = process.env.ZUNARO_WHSEC; // "whsec_..." from endpoint creation
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const TOLERANCE_S = 5 * 60;

createServer((req, res) => {
  let body = "";
  req.on("data", (chunk) => (body += chunk));
  req.on("end", () => {
    const id = req.headers["webhook-id"];
    const ts = req.headers["webhook-timestamp"];
    const sig = req.headers["webhook-signature"] ?? "";
    const expected = `v1,${createHmac("sha256", key)
      .update(`${id}.${ts}.${body}`)
      .digest("base64")}`;
    const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= TOLERANCE_S;
    let valid = false;
    try {
      valid = fresh && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
    } catch {
      valid = false;
    }
    if (!valid) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(body);
    // Dedupe on the webhook-id header: retries and redeliveries reuse it.
    console.log(event.type, event.data);
    res.writeHead(200).end();
  });
}).listen(3000);

Retries and auto-disable

  • Any 2xx within 15 seconds counts as delivered. Redirects are not followed — a 3xx is a failure.
  • A failed attempt retries up to six times, backing off 30s, 2m, 10m, 1h, 6h, then 24h (about 31 hours in total).
  • Respond 410 Gone to unsubscribe: the endpoint is auto-disabled immediately and nothing more is sent.
  • An endpoint that keeps failing across many events for days is auto-disabled; the dashboard shows Auto-disabled. Fix the receiver, then Enable it again (or PATCH its status to enabled over the API).
  • The endpoint page lists Recent deliveries with status, HTTP code, and latency; Redeliver (or POST /v1/webhook_deliveries/.../redeliver) queues a fresh attempt with a new retry budget — under the same webhook-id, so your dedup still holds.