Webhooks

Signed events when an order, deposit or voucher changes.

Instead of polling, let Shyni call your server when something changes. An organization registers endpoints with /webhook-endpoints (see Register a webhook endpoint). A studio's owner sets them up in the studio's admin instead, on the Pro subscription, with up to 5 endpoints and per endpoint the events it receives.

  • The endpoint URL must be https. Private and internal addresses are refused.
  • Creating an endpoint shows its signing secret (whsec_...) once.
  • Rotating the secret keeps the previous one signing for another 24 hours, so you can switch without a gap.

The request

Shyni sends POST with a JSON body:

{
  "id": "f3a1c2d4-6b7e-4a80-9c15-0e2d4b6a8000",
  "type": "order.confirmed",
  "createdAt": "2026-10-09T08:53:20Z",
  "apiVersion": "v1",
  "data": {}
}

data is exactly the object the REST API returns for that resource at the moment of the change: the response of Get an order for order.* and deposit.*, the response of Get a gift voucher for gift_voucher.*.

For orders, data includes the attribution and metadata the order was created with, and holdUntil while an unpaid deposit still holds the slot.

Headers: Content-Type: application/json, User-Agent: Shyni-Webhooks/1, Shyni-Event-Id (the envelope id), Shyni-Event-Type (the envelope type) and Shyni-Signature.

Verify the signature

Shyni-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]

v1 is the lowercase hex HMAC-SHA256 of the string "<t>.<raw body>". The key is the endpoint secret as a UTF-8 string, exactly as shown and including the whsec_ prefix. Do not Base64-decode it. There are two v1 values only while a rotated-out secret is still honoured: accept the request if any of them matches.

  1. Verify against the raw request body bytes, before any JSON parsing or re-serialisation.
  2. Compare in constant time.
  3. Reject if t differs from your clock by more than 5 minutes.
import crypto from "node:crypto";

function verify(header, rawBody, secret, toleranceSec = 300) {
  const parts = header.split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  return parts
    .filter((p) => p.startsWith("v1="))
    .map((p) => p.slice(3))
    .some((v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
}

Check your code against this worked example (illustrative values, not a real event: the id and date differ from the envelope above). Secret whsec_example_secret_0123456789abcdef, t = 1760000000, body on one line with no trailing newline:

{"id":"01J9ZEXAMPLE","type":"webhook.test","createdAt":"2025-10-09T08:53:20Z","apiVersion":"v1","data":{"message":"Test z Shyni"}}

gives

Shyni-Signature: t=1760000000,v1=a0c684f0a52a2f641fd6c7b621c052322d0f1dd8e40e3a3367759fb54d2d341f

Delivery

  • At-least-once. The same event can arrive more than once: dedupe on Shyni-Event-Id. Order between events is not guaranteed.
  • Answer with a 2xx within 15 seconds (5 s to connect, 10 s to read). Redirects are not followed, so a 3xx is a failure. Any other answer or a timeout is a failure too.
  • A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 10 h and 6 h. The last wait is shorter, so the plan ends about 24 hours after the first attempt. That is 8 attempts in all. Then the delivery is marked failed.
  • After 5 failed deliveries in a row Shyni switches the endpoint off and e-mails the studio owner. A successful delivery resets the count.
  • The admin has a test ping (webhook.test), a delivery log and a manual resend.

Events

EventWhen
order.createdA new order was placed. An auto-confirmed order is followed by order.confirmed.
order.confirmedThe order was confirmed.
order.rescheduledThe order was moved to a new time.
order.cancelledThe order was cancelled; data.cancelledBy is STUDIO, CUSTOMER or HOLD_EXPIRY; a customer no-show recorded with POST /orders/{id}/no-show arrives with data.cancellationReason NO_SHOW.
order.readyThe order is ready for pickup.
order.revivedA cancelled order was brought back to CONFIRMED with POST /orders/{id}/revive, the only way back from CANCELLED. Only an order the studio or the customer cancelled can be revived, not one whose booking link expired.
order.completedThe order was completed.
deposit.paidThe deposit was paid.
deposit.refundedThe deposit was refunded, via the gateway or recorded manually.
order.payment_receivedA payment on the order was received.
order.work_startedWork started; sent per work interval.
order.work_stoppedWork stopped; sent per work interval.
order.worker_assignedA worker was assigned; data.assignedWorkerId names them.
gift_voucher.soldA gift voucher was sold.
gift_voucher.redeemedA gift voucher was redeemed.
gift_voucher.refundedA gift voucher was refunded.
customer.anonymizedA customer was anonymized (erasure by the studio, or the retention job). Erase your copy of data.id.
webhook.testTest ping from the admin. Not subscribable.

On this page