ResytechResytech Docs
Plugins

Webhooks developer reference

Payload format, signature verification, retry schedule, and event catalog for building a Resytech webhook receiver.

This page is for the developer building the receiver — the system on the other end of a webhook endpoint registered in the Webhooks plugin. It covers the request format, how to verify signatures, how retries behave, and every event type you can subscribe to.

The design deliberately mirrors Stripe's webhooks: the same envelope shape and the same t=…,v1=… signature scheme. If you've integrated Stripe webhooks before, everything here will feel familiar — an existing Stripe verification snippet works by changing the header name.

The request

Every event is an HTTPS POST to your endpoint URL with:

  • Content-Type: application/json; charset=utf-8
  • A Resytech-Signature header (see Verifying signatures)
  • A JSON body in the envelope format below

Requirements on your side:

  • HTTPS only, on a publicly resolvable host. Private and internal addresses are rejected at registration and again at delivery time.
  • Redirects are not followed. A 3xx response counts as a failed delivery — point the endpoint at the final URL.
  • Respond within 15 seconds. Acknowledge first, process after: return a 2xx as soon as you've durably queued the event, not after you've finished acting on it.
  • Response bodies are read up to 64 KB (the first part is stored in the delivery log for debugging); the body content is otherwise ignored.

The envelope

{
  "id": "evt_9f1c0d2ab34e4f5a8b6c7d8e9f0a1b2c",
  "type": "Booking Created",
  "created": 1785000000,
  "apiVersion": "2026-08-01",
  "locationId": "22222222-2222-2222-2222-222222222222",
  "data": {
    "object": {
      "bookingId": "11111111-1111-1111-1111-111111111111",
      "locationId": "22222222-2222-2222-2222-222222222222",
      "customerId": "33333333-3333-3333-3333-333333333333",
      "activityId": "44444444-4444-4444-4444-444444444444",
      "totalAmount": 149.00,
      "currency": "USD",
      "startTime": "2026-07-01T15:00:00.0000000Z",
      "endTime": "2026-07-01T17:00:00.0000000Z",
      "status": "Confirmed"
    }
  }
}
FieldMeaning
idUnique event id (evt_ + 32 hex chars). Stable across retries and resends — use it as your idempotency key.
typeThe event type, exactly as listed in the event catalog.
createdUnix timestamp (seconds) when the event was recorded.
apiVersionPayload schema version. Currently 2026-08-01.
locationIdThe Resytech location the event belongs to.
data.objectThe event detail — fields per type are in the catalog below.

Verifying signatures

Each request carries:

Resytech-Signature: t=1785000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

where v1 is the lowercase hex HMAC-SHA256 of the string {t}.{body} — the timestamp, a literal ., and the raw request body — keyed with the endpoint's signing secret (rwhk_…, shown in the dashboard).

To verify:

  1. Parse t and v1 out of the header.
  2. Reject if t is more than 5 minutes from your current time (replay protection).
  3. Compute HMAC-SHA256 over {t}.{raw body} with the signing secret and compare to v1 using a constant-time comparison.

Verify against the raw bytes of the request body, not a re-serialized parse of it. Frameworks that parse JSON before your handler runs (Express's express.json(), for example) will change the bytes and the signature will never match — capture the raw body.

Node.js

const crypto = require("crypto");

function verifyResytechSignature(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = {};
  for (const piece of header.split(",")) {
    const idx = piece.indexOf("=");
    if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
  }

  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  return (
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  );
}

// Express: use express.raw() on the webhook route so req.body is the raw bytes.
app.post("/hooks/resytech", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyResytechSignature(
    req.header("Resytech-Signature"),
    req.body.toString("utf8"),
    process.env.RESYTECH_WEBHOOK_SECRET
  );
  if (!ok) return res.status(400).send("bad signature");

  const event = JSON.parse(req.body);
  // Queue event for processing, keyed on event.id, then acknowledge.
  res.sendStatus(200);
});

Python

import hashlib, hmac, time

def verify_resytech_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)

Rotation

Rotating a secret in the dashboard invalidates the old one immediately — there is no dual-secret grace window. Deploy the new secret to your receiver first, then rotate, and expect the small window in between to produce signature failures that will be retried (see below) and succeed once the receiver has the new secret.

Delivery, retries, and idempotency

Delivery is at-least-once. A delivery counts as successful on any 2xx response. Anything else — a non-2xx status, a redirect, a timeout, a connection failure — is retried on this schedule:

AttemptDelay after previous failure
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 hours

After the 8th failed attempt (roughly 45 hours after the event) the delivery is marked exhausted and can only be resent manually from the dashboard.

What this means for your receiver:

  • Deduplicate on id. The evt_ id never changes across retries or manual resends, and at-least-once delivery means you will eventually see a duplicate. Track processed ids and skip repeats.
  • Don't rely on ordering. Retries mean an older event can arrive after a newer one. Use the created timestamp (or fields inside data.object) if order matters to you.
  • A resend replays the identical payload — the event content is frozen when the event is recorded.

Two special cases:

  • Responding 410 Gone tells Resytech the endpoint is permanently gone: delivery stops and the endpoint is disabled, no retries.
  • 20 consecutive exhausted deliveries auto-disable the endpoint (any 2xx resets the count). Re-enabling it in the dashboard resumes delivery for new events.

Event catalog

Payloads are thin by design: IDs and booking scalars only. Fields not listed here are never sent — in particular, customer names, emails, and phone numbers are absent unless the operator opts in (next section), and sensitive values like gift card codes are never sent at all. Use the IDs to look up further detail in Resytech.

Subscribe to only the types you need. The type string must match exactly.

TypeFired whendata.object fields
Booking CreatedA new booking is createdbookingId, locationId, customerId, activityId, totalAmount, currency, startTime, endTime, status
Booking Status ChangedA booking's status changesbookingId, locationId, customerId, oldStatus, newStatus, changedBy, reason
Booking CancelledA booking is cancelledbookingId, locationId, customerId, cancelledBy, reason, cancelledAt
Booking RescheduledA booking moves to a new timebookingId, locationId, customerId, oldStartTime, newStartTime, oldEndTime, newEndTime, rescheduledBy
Booking Activity ChangedA booking is moved to a different activitybookingId, locationId, customerId, oldActivityId, newActivityId, oldStartTime, oldEndTime, newStartTime, newEndTime, oldGrandTotal, newGrandTotal, priceDifference, changedBy
Booking Checked InA booking is checked inbookingId, locationId, customerId, activityId, checkedInAtUtc, checkedInBy
Booking Check-In RevertedA check-in is undonebookingId, locationId, customerId, activityId, revertedAtUtc, originalCheckedInAtUtc, revertedBy
Booking Guest AddedA guest is added to a booking's guest listbookingId, locationId, customerId, guestCount
Booking Tag AddedA tag is added to a bookingbookingId, locationId, tag, addedBy
Booking Tag RemovedA tag is removed from a bookingbookingId, locationId, tag, removedBy
Booking Survey SubmittedA customer submits a post-booking surveybookingId, locationId, customerId, surveyId, submissionId, overallRating, submittedAt
Booking Dispute CreatedA payment dispute (chargeback) is openedbookingId, locationId, companyId, customerUuid, disputeId, paymentIntentId, amount, reason, confirmationCode, evidenceDueByUtc
Gift Card PurchasedA gift card is purchasedgiftCardId, companyId, locationId, amount, purchaseSource

One naming quirk to code around: Booking Dispute Created spells its customer field customerUuid, while every other booking event uses customerId.

The test event

The dashboard's Send test button pushes a Webhook Test event through the real signing and delivery pipeline. It can't be subscribed to — it's push-only — so your receiver should accept (2xx) any type it doesn't recognize rather than erroring. That also keeps you forward-compatible as new event types are added.

Customer contact details (opt-in)

If — and only if — the operator ticked Include customer contact details on the endpoint, booking events additionally carry the booking customer's contact info as data.object.customer:

"data": {
  "object": {
    "bookingId": "11111111-1111-1111-1111-111111111111",
    "customer": {
      "name": "Alex Rivera",
      "email": "alex@example.com",
      "phone": "+15555550123"
    }
  }
}

Individual fields are null when Resytech has nothing on file, and the block is absent entirely when the event has no associated customer or the opt-in is off. The values are captured when the event is recorded, so retries and resends carry identical data even if the customer record changes later.

On this page