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.
- Verify against the raw request body bytes, before any JSON parsing or re-serialisation.
- Compare in constant time.
- Reject if
tdiffers 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=a0c684f0a52a2f641fd6c7b621c052322d0f1dd8e40e3a3367759fb54d2d341fDelivery
- 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
2xxwithin 15 seconds (5 s to connect, 10 s to read). Redirects are not followed, so a3xxis 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
| Event | When |
|---|---|
order.created | A new order was placed. An auto-confirmed order is followed by order.confirmed. |
order.confirmed | The order was confirmed. |
order.rescheduled | The order was moved to a new time. |
order.cancelled | The 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.ready | The order is ready for pickup. |
order.revived | A 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.completed | The order was completed. |
deposit.paid | The deposit was paid. |
deposit.refunded | The deposit was refunded, via the gateway or recorded manually. |
order.payment_received | A payment on the order was received. |
order.work_started | Work started; sent per work interval. |
order.work_stopped | Work stopped; sent per work interval. |
order.worker_assigned | A worker was assigned; data.assignedWorkerId names them. |
gift_voucher.sold | A gift voucher was sold. |
gift_voucher.redeemed | A gift voucher was redeemed. |
gift_voucher.refunded | A gift voucher was refunded. |
customer.anonymized | A customer was anonymized (erasure by the studio, or the retention job). Erase your copy of data.id. |
webhook.test | Test ping from the admin. Not subscribable. |