# Webhooks developer reference (/docs/plugins/webhooks-reference)



This page is for the developer **building the receiver** — the system on the other end of a webhook endpoint registered in the [Webhooks plugin](/docs/plugins/webhooks). 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 [#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](#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 [#the-envelope]

```json
{
  "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"
    }
  }
}
```

| Field         | Meaning                                                                                                          |
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
| `id`          | Unique event id (`evt_` + 32 hex chars). **Stable across retries and resends** — use it as your idempotency key. |
| `type`        | The event type, exactly as listed in the [event catalog](#event-catalog).                                        |
| `created`     | Unix timestamp (seconds) when the event was recorded.                                                            |
| `apiVersion`  | Payload schema version. Currently `2026-08-01`.                                                                  |
| `locationId`  | The Resytech location the event belongs to.                                                                      |
| `data.object` | The event detail — fields per type are in the catalog below.                                                     |

Verifying signatures [#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.

<Callout type="warn">
  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.
</Callout>

Node.js [#nodejs]

```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 [#python]

```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 [#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-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:

| Attempt | Delay after previous failure |
| ------- | ---------------------------- |
| 1       | immediate                    |
| 2       | 1 minute                     |
| 3       | 5 minutes                    |
| 4       | 30 minutes                   |
| 5       | 2 hours                      |
| 6       | 6 hours                      |
| 7       | 12 hours                     |
| 8       | 24 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 &#x2A;*`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 [#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.

| Type                        | Fired when                                 | `data.object` fields                                                                                                                                                                                    |
| --------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Booking Created`           | A new booking is created                   | `bookingId`, `locationId`, `customerId`, `activityId`, `totalAmount`, `currency`, `startTime`, `endTime`, `status`                                                                                      |
| `Booking Status Changed`    | A booking's status changes                 | `bookingId`, `locationId`, `customerId`, `oldStatus`, `newStatus`, `changedBy`, `reason`                                                                                                                |
| `Booking Cancelled`         | A booking is cancelled                     | `bookingId`, `locationId`, `customerId`, `cancelledBy`, `reason`, `cancelledAt`                                                                                                                         |
| `Booking Rescheduled`       | A booking moves to a new time              | `bookingId`, `locationId`, `customerId`, `oldStartTime`, `newStartTime`, `oldEndTime`, `newEndTime`, `rescheduledBy`                                                                                    |
| `Booking Activity Changed`  | A booking is moved to a different activity | `bookingId`, `locationId`, `customerId`, `oldActivityId`, `newActivityId`, `oldStartTime`, `oldEndTime`, `newStartTime`, `newEndTime`, `oldGrandTotal`, `newGrandTotal`, `priceDifference`, `changedBy` |
| `Booking Checked In`        | A booking is checked in                    | `bookingId`, `locationId`, `customerId`, `activityId`, `checkedInAtUtc`, `checkedInBy`                                                                                                                  |
| `Booking Check-In Reverted` | A check-in is undone                       | `bookingId`, `locationId`, `customerId`, `activityId`, `revertedAtUtc`, `originalCheckedInAtUtc`, `revertedBy`                                                                                          |
| `Booking Guest Added`       | A guest is added to a booking's guest list | `bookingId`, `locationId`, `customerId`, `guestCount`                                                                                                                                                   |
| `Booking Tag Added`         | A tag is added to a booking                | `bookingId`, `locationId`, `tag`, `addedBy`                                                                                                                                                             |
| `Booking Tag Removed`       | A tag is removed from a booking            | `bookingId`, `locationId`, `tag`, `removedBy`                                                                                                                                                           |
| `Booking Survey Submitted`  | A customer submits a post-booking survey   | `bookingId`, `locationId`, `customerId`, `surveyId`, `submissionId`, `overallRating`, `submittedAt`                                                                                                     |
| `Booking Dispute Created`   | A payment dispute (chargeback) is opened   | `bookingId`, `locationId`, `companyId`, `customerUuid`, `disputeId`, `paymentIntentId`, `amount`, `reason`, `confirmationCode`, `evidenceDueByUtc`                                                      |
| `Gift Card Purchased`       | A gift card is purchased                   | `giftCardId`, `companyId`, `locationId`, `amount`, `purchaseSource`                                                                                                                                     |

<Callout type="info">
  One naming quirk to code around: `Booking Dispute Created` spells its customer field `customerUuid`, while every other booking event uses `customerId`.
</Callout>

The test event [#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) [#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`:

```json
"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.
