# Guide to Guesty Webhooks: Features and Best Practices

Guesty webhooks tell your systems when something changes in a short-term rental business: a booking is created or altered, a payment succeeds or fails, a guest sends a message, or a listing's calendar changes. If you run properties on Guesty and keep your own channel tools, owner portals, cleaning schedules or analytics in sync with it, webhooks are how those systems hear about changes without polling the Guesty Open API.

This guide covers how Guesty subscriptions are created and signed, the events you can subscribe to and how their payloads differ, the delivery rules that catch out new integrations, and the best practices for running a Guesty receiver in production.

## What are Guesty webhooks?

A Guesty webhook subscription is a URL plus a list of event names, created through the Open API. When one of those events happens in your Guesty account, Guesty sends a JSON `POST` to the URL. Delivery runs on Svix, so every request carries three Svix headers: `svix-id`, `svix-timestamp` and `svix-signature`.

Guesty does not describe the signature scheme itself. Its docs tell you to retrieve your secret and then "Follow Svix's instructions" for validation. In practice that means the Svix scheme: a base64 HMAC-SHA256 over the message ID, the timestamp and the raw body, keyed with the decoded secret.

The event name is in the body's top-level `event` field. Beyond that field there is no common envelope: a v2 reservation event puts the reservation under `data`, a payment event uses `payment`, a guest event uses `guest`, and so on. Guesty also says its payloads "vary in their levels of detail", and recommends fetching the full object from the API after each webhook.

## Guesty webhook features

| Feature | Details |
| --- | --- |
| Configuration | Open API only: `POST https://open-api.guesty.com/v1/webhooks` with a `url` and an `events` list |
| URL rules | HTTPS on port 443 with a publicly resolvable hostname. Keep each subscription's URL unique |
| Delivery platform | Svix |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | HMAC-SHA256, base64, over `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | One per subscribed endpoint, from `GET /webhooks-v2/secret` or the webhooks developer dashboard |
| Event name | Top-level `event` field in the body |
| Response window | A `2xx` within about 15 seconds. Any other status, including `3xx`, is a failure |
| Retries | Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Auto-disable | After 5 days of failures. Only Guesty API support can re-enable the endpoint |
| Ordering | Not guaranteed. Recovery processes can also re-emit events you already received |
| Replay | Failed messages can be replayed from the webhooks dashboard (**Developer tools > Webhooks**) |

Guesty publishes no source IP list for webhooks, so the signature is how you know a request is genuine.

## Common events

Guesty's create-subscription reference lists 31 event names. They fall into eight domains:

| Domain | Events | Notes |
| --- | --- | --- |
| Reservations (v2) | `reservation.created.v2`, `reservation.updated.v2` | Lean payloads under `data`, with `meta.eventId`, `meta.publishedAt` and, on updates, `meta.changedFields` and `meta.subType` |
| Reservations (legacy) | `reservation.new`, `reservation.updated`, `reservation_update_shortlist` | Full reservation object, plus `reservationBefore` on updates. Being phased out by the end of February 2027 |
| Messages | `reservation.messageReceived`, `reservation.messageSent` | Guest conversation activity on a reservation |
| Payments | `payments.received`, `payments.failed`, `payments.refunded`, `payments.disputes`, `payments.overdue` and six more | Payment lifecycle, authentication and payment method events, under `payment` |
| Listings | `listing.new`, `listing.updated`, `listing.removed` | Listing created, changed or deleted |
| Calendar | `listing.calendar.updated`, `calendar.updated.v2` | Availability and pricing changes |
| Tasks | `task.created`, `task.updated`, `task.deleted` | Tasks created, updated or deleted |
| Guests and folios | `guest.created`, `guest.updated`, `guest.deleted`, `guest-folio.created`, `guest-folio.updated` | Guest records and reservation financials |

Guesty recommends the v2 reservation events. They separate stay details from financials and payments, which now have their own events (`guest-folio.*` and `payments.*`), and they add sequencing information. Here is Guesty's `reservation.created.v2` example, trimmed:

```json
{
  "data": {
    "accountId": "66053c21dd2adbdfb3b383b7",
    "reservationId": "6a96ae8ea07eac3dcadf0aab",
    "confirmationCode": "GY-pCCSraYC",
    "status": "confirmed",
    "checkIn": "2026-09-16T15:00:00.000Z",
    "checkOut": "2026-09-20T10:00:00.000Z",
    "guestId": "68b455b9e7d0e5d47378ac4b",
    "guestsCount": 2,
    "platform": "direct",
    "source": "manual",
    "unitId": "66054019764cbb000f37c450"
  },
  "event": "reservation.created.v2",
  "meta": {
    "eventId": "b708e831-d9fc-44d5-b94a-3ff72a0ef815",
    "publishedAt": "2026-09-01T10:53:03.025Z"
  }
}

```

A `reservation.updated.v2` body has the same shape, and its `meta` adds what changed, for example `"changedFields": ["notes"]` and `"subType": "NOTES_ALTERED"`.

The calendar docs are inconsistent on one point. The field table for `calendar.updated.v2` lists an `event` field, but its only example has no `event` key. Until you have seen one of these deliveries, don't assume every Guesty body carries `event`.

## Setting up Guesty webhooks

Guesty has no dashboard form for creating subscriptions. You create them with the Open API, authenticated with a bearer token.

1. Create the subscription with your HTTPS endpoint and the events you want:

```bash
curl -X POST "https://open-api.guesty.com/v1/webhooks" \
  -H "Authorization: Bearer $GUESTY_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/guesty",
    "events": ["reservation.created.v2", "reservation.updated.v2", "payments.received"]
  }'

```

1. Fetch the signing secret for that endpoint and store it server-side, for example as `GUESTY_WEBHOOK_SECRET`:

```bash
curl "https://open-api.guesty.com/v1/webhooks-v2/secret?url=https://example.com/webhooks/guesty" \
  -H "Authorization: Bearer $GUESTY_ACCESS_TOKEN"

```

You can also read it from the webhooks developer dashboard in your Guesty account.

1. To change the events later, call the update endpoint, which replaces the subscription's event list. The URL is fixed at creation and an update with a different `url` is rejected with a `400`, so moving to a new URL means creating a new subscription and deleting the old one.

A few rules apply. The URL must be HTTPS on port 443 with a public hostname, so a local server needs a tunnel. Don't create two subscriptions for the same URL: Guesty warns that this "can lead to missed notifications", and it is also the usual cause of duplicate payloads. Deleting and recreating a subscription can generate a new secret, so fetch it again whenever you do.

## Securing Guesty webhooks

Verify every delivery before you parse it. Build the signed content as `{svix-id}.{svix-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of your secret after `whsec_`, base64-encode the result, and compare it in constant time with each `v1,` entry in `svix-signature`. The header can hold several space-separated entries, and a match on any one of them is valid.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyGuestySignature(rawBody, headers, secret) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // The header can hold several space-separated "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/guesty', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.GUESTY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyGuestySignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: if your own policy needs it, reject svix-timestamp values that are too old.
  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event. Deduplicate on svix-id and event.meta?.eventId, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_guesty_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_guesty_signature(raw_body, request.headers, os.environ["GUESTY_WEBHOOK_SECRET"])

```

The official `svix` libraries for Node and Python do the same check in one call (`new Webhook(secret).verify(rawBody, headers)`). They also reject any timestamp more than five minutes from the current time, which matters if you ever re-run verification on a stored request.

To check your verifier, use the secret `whsec_aG9va2RlY2stZ3VpZGUtdGVzdC1rZXktMzJieXRlcyE=`, the headers `svix-id: msg_2kGuestyTest0001` and `svix-timestamp: 1761040800`, and this exact body:

```
{"event":"reservation.created.v2","meta":{"eventId":"b708e831-d9fc-44d5-b94a-3ff72a0ef815"}}

```

The `svix-signature` header should be:

```
v1,kTR5aMGocpNnSeCzwBzIQNRFT6BzmQesYWn7zCz83wk=

```

> **Make Guesty webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every Guesty delivery, accepts it well inside the 15-second window, and durably queues reservation, payment and listing events before your service sees them.

## Guesty webhook limitations and pain points

### Events arrive out of order, and sometimes twice

**The Problem:** A `reservation.updated` can arrive before the event it follows, and a reservation you processed yesterday can show up again.

**Why It Happens:** Guesty says it "processes events asynchronously, so you may receive events out of order on occasion", and that recovery processes "may also emit historical events you've already received". Svix retries add more duplicates when your endpoint is slow or failing.

**Workarounds:**

* Deduplicate on `svix-id` and, where present, `meta.eventId`, which is what Guesty recommends.
* Order by `__v` or `lastUpdatedAt` in the data, or `meta.publishedAt` on v2 events, never by arrival time.
* Treat each webhook as a prompt to fetch the current object from the Open API, and let that drive your state.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose, such as `meta.eventId`, before they reach your service.

### No common payload shape

**The Problem:** Code that reads `body.data` works for `reservation.created.v2` and returns `undefined` for a payment or guest event.

**Why It Happens:** Every Guesty body has `event`, but each domain puts its object under its own key: `data` for v2 reservations, `reservation` for legacy ones, `payment`, `guest`, `listing`, and so on. Several domains publish only placeholder examples, so the exact fields are something you learn from real deliveries.

**Workarounds:**

* Map each event name to the key that holds its object, in one place, and read through that map.
* Log the first delivery of each event type you subscribe to and build your parser from what arrives.
* Fetch the full object from the API rather than relying on the webhook body for details.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) route events to different destinations by the `event` field, and [transformations](/docs/transformations) can reshape each domain's body into a consistent structure before delivery.

### Two generations of reservation events

**The Problem:** Integrations built on `reservation.new` and `reservation.updated` stop receiving events when the legacy events are retired.

**Why It Happens:** Guesty runs legacy and v2 reservation events side by side and says the legacy versions "will be phased out by the end of February 2027". The v2 events are shaped differently: they are leaner, and payments and folio changes moved out to their own events.

**Workarounds:**

* Subscribe to `reservation.created.v2` and `reservation.updated.v2` in new integrations. The create-subscription reference already says the legacy events "may require temporary allowlist approval".
* When migrating, add `payments.*` and `guest-folio.*` subscriptions to replace the financial changes that legacy `reservation.updated` used to carry.
* Run both generations in parallel during migration and compare results before you drop the legacy subscription.

**How Hookdeck Can Help:** With both generations sent to one Hookdeck source, you can route legacy and v2 events to different destinations by `event` and replay stored events into the new handler while you test it.

### A five-day outage turns the endpoint off

**The Problem:** After a long enough failure, Guesty stops delivering to your endpoint and you need Guesty support to turn it back on.

**Why It Happens:** Svix retries each message for about 27 hours. If every attempt to an endpoint fails for 5 days, the endpoint is automatically disabled, and Guesty says to contact Guesty API Support to reactivate it.

**Workarounds:**

* Return a `2xx` as soon as the signature checks out and do the work afterwards.
* Never return a redirect: Guesty counts `3xx` as a failure.
* Use the dashboard's **Replay** to resend failed messages once your endpoint is healthy again.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so Guesty sees a healthy endpoint even while your own service is down. You then [retry](/docs/retries) and replay on your own schedule.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Guesty sent. In Express, mount `express.raw()` on the webhook route and parse only after verification. In FastAPI, read `await request.body()`. Re-serialising parsed JSON changes the bytes and breaks the signature.

### Keep one subscription per URL

Guesty warns that duplicate subscriptions for the same URL lead to missed notifications, and they are the first thing to check when you see duplicate payloads. Put every event you need in one subscription's `events` list, and only create separate subscriptions for separate systems.

### Re-fetch after secret changes

Each endpoint has its own secret, and recreating a subscription can issue a new one. Read the secret with `GET /webhooks-v2/secret?url=...` after any change to a subscription, and update your receiver before the old deliveries stop verifying.

### Treat webhooks as change notifications

Guesty's own handling steps are acknowledge, parse, validate, fetch and compare. Use the webhook to learn which object changed, fetch it from the Open API, and compare it with your stored copy. This handles lean v2 payloads, out-of-order delivery and re-emitted events with the same code.

### Acknowledge fast and process asynchronously

Return a `2xx` within the 15-second window and hand the event to a queue. Slow handlers produce retries, and retries produce duplicates. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Store `svix-id` and `meta.eventId` as you process them and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Guesty sends its webhooks through Svix, so verification is the Svix scheme: an HMAC-SHA256 over `svix-id`, `svix-timestamp` and the raw body, keyed with your endpoint's decoded `whsec_` secret. Subscribe through the Open API with one subscription per URL, move to the v2 reservation events before the legacy ones are retired, and expect events out of order or more than once.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `svix-signature`, deduplicates repeated events, and holds each one until your service has processed it, so an outage on your side never runs into Guesty's five-day disable rule.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Guesty webhooks reliably in minutes.