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 Settings → Developers 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.