# Webhook events — payload reference

This page documents what HotelSync **sends** to webhook URLs you've registered via [`POST /api/webhooks/insert/webhook`](./insert-webhook.md).

When an event fires, the server makes an HTTP POST to your `url` with `Content-Type: application/json` and this envelope:

```json
{
  "data_type": "<reservation|avail|prices|restrictions|invoice|inquiry|messages|message>",
  "action":    "<insert|edit|delete>",
  "data":      { … }
}
```

The shape of `data` depends on `data_type` (described below).

> **Source:** `api/functions/functions.php` → `function sendWebhook($id_properties, $data_type, $action, $data)`. The dispatcher fans out to every non-deleted webhook row for the property, with `CURLOPT_TIMEOUT = 1` (one second) and no retry. Be ready to reconcile missed events from the API — webhooks are best-effort, not guaranteed.

---

## Event taxonomy

| `data_type` | `action` | When it fires |
|---|---|---|
| `reservation` | `insert` | Reservation created — direct booking (`/api/engine/insert/reservation`), Channex import, Booking Engine submission, Channel Manager push. |
| `reservation` | `edit` | Any reservation update — guest-app self-service edits, OTA-side modifications, manual edits via the calendar or reservation form, payment/invoice mutations that touch the reservation row. |
| `reservation` | `delete` | Reservation cancelled or removed (soft-delete on the `reservations` row). |
| `avail` | `edit` | Room-type availability counts changed via `/api/avail/edit/avail` or via calendar-grid edits that modify the `avail` cell. |
| `prices` | `edit` | Per-day, per-room-type price changed via `/api/prices/edit/prices` or via calendar-grid price edits. |
| `restrictions` | `edit` | Stay restrictions (min/max stay, CTA/CTD, closed-out) changed via `/api/restrictions/edit/restrictions` or via calendar-grid restriction edits. |
| `invoice` | `insert` | Invoice issued (`/api/invoice/insert/invoice` and related insertion paths). |
| `invoice` | `edit` | Invoice updated. |
| `invoice` | `delete` | Invoice voided / soft-deleted. |
| `inquiry` | `insert` | A guest opens a **new** message thread from an OTA (e.g. an Airbnb inquiry) before a reservation exists. Carries the enquiry's `booking_details`. |
| `messages` | `insert` | A **follow-up** message on an existing inquiry thread (matched by `message_thread_id`). Same shape as `inquiry` minus `booking_details`. |
| `message` | `insert` | A message on an existing **reservation** thread. `data.message` is the full thread (array of messages), not a single string. |

---

## `reservation` — `insert` / `edit` / `delete`

`data` is the full reservation object as returned by `getOneReservation()` — every column from the `reservations` table (99 columns) plus several nested arrays loaded from related tables. This is the same shape that `POST /api/reservations/data/reservation` returns on read.

The example below shows the most common columns you'll want to consume; the actual payload contains every column the row has set.

```json
{
  "data_type": "reservation",
  "action":    "insert",
  "data": {
    "id_reservations":         123456,
    "id_properties":           42,
    "id_pricing_plans":        3,
    "id_boards":               1,
    "id_city_taxes":           7,
    "id_channels":             2,
    "id_primary_guests":       9876,
    "id_companies":            null,
    "id_invoices":             null,
    "id_users":                11,
    "id_promocodes":           null,
    "id_special_offers":       null,
    "id_contigents":           null,
    "id_affiliates":           null,
    "status":                  "confirmed",
    "guest_status":            "waiting_arrival",
    "reservation_type":        "regular",
    "date_received":           "2026-05-04",
    "time_received":           "10:23:11",
    "date_arrival":            "2026-06-01",
    "date_departure":          "2026-06-05",
    "guest_check_in":          null,
    "guest_check_out":         null,
    "date_canceled":           null,
    "pending_until":           null,
    "nights":                  4,
    "adults":                  2,
    "seniors":                 0,
    "children_1":              0,
    "children_2":              0,
    "children_3":              0,
    "total_guests":            2,
    "total_price":             420.00,
    "remaining_amount":        320.00,
    "rooms_price":             400.00,
    "rooms_discounted":        0.00,
    "extras_price":            15.00,
    "extras_discounted":       0.00,
    "board_price":             0.00,
    "board_discounted":        0.00,
    "city_tax_price":          8.00,
    "insurance_price":         0,
    "spas_price":              0,
    "conference_halls_price":  0,
    "discount_type":           null,
    "discount_amount":         null,
    "room_discount":           0,
    "extras_discount":         0,
    "board_discount":          0,
    "custom_tax_rate":         0,
    "custom_tax_name":         null,
    "custom_tax_price":        0,
    "no_show":                 null,
    "invalid_cc":              null,
    "is_overbooking":          0,
    "unassigned_rooms":        0,
    "has_card":                "0",
    "parking_count":           null,
    "parking_note":            null,
    "additional_services_extra_bed":   null,
    "additional_services_baby_bed":    null,
    "additional_services_flight_time_arrival": null,
    "additional_services_flight_number":       null,
    "note":                    "Late arrival",
    "private_note":            null,
    "attachment":              null,
    "color":                   null,
    "reference":               null,
    "external_id":             "BDC-1234567890",
    "exchange_rate":           1,
    "additional_exchange_rate": null,
    "field_1":                 null, "field_2": null, "field_3": null, "field_4": null,
    "raw_message":             null,
    "meta_data":               null,
    "canceled_reason_reservation": null,
    "offer_cancellation_type": null,
    "channex_modification":    0,
    "channex_modification_date": null,
    "ignore_duplicate":        0,
    "is_deleted":              0,
    "date_deleted":            null,
    "is_modified":             0,
    "date_modified":           "2026-05-04 10:23:11",
    "date_created":            "2026-05-04 10:23:11",
    "date_created_timestamp":  1746355391,

    "rooms": [
      {
        "id_reservations_rooms": 555001,
        "id_rooms":              17,
        "id_room_types":         5,
        "date_arrival":          "2026-06-01",
        "date_departure":        "2026-06-05",
        "price":                 400.00,
        "discounted":            0.00,
        "city_tax_price":        8.00
      }
    ],
    "guests": [
      {
        "id_guests":   9876,
        "first_name":  "John",
        "last_name":   "Doe",
        "email":       "guest@example.com",
        "phone":       "+38112345678",
        "country":     "ME",
        "is_primary":  1
      }
    ],
    "extras":   [{ "id_extras": 4, "quantity": 1, "price": 15.00 }],
    "payments": [{ "method": "cash", "amount": 100.00, "payment_date": "2026-05-04" }],
    "invoices": [],
    "damage":   [],
    "attachments": [],
    "discounts": [],
    "orders_montenegro": [],
    "incoming_transfers": [],
    "outgoing_transfers": [],
    "guest_pays": [],

    "cc_data":                "",
    "apply_discount_to":      "rooms",
    "channel_logo":           "https://app.hotelsync.com/img/ota/booking.png",
    "channel_name":           "Booking.com",
    "guest_app_link":         "https://app.hotelsync.com/guestapp/app/en/42/<code>",
    "guest_registered_colombia": 0,
    "engine_credit_card":     null
  }
}
```

**Notes**

- **Reservation row + nested arrays.** The top-level fields are the `reservations` table columns; `rooms`, `guests`, `extras`, `payments`, `invoices`, `damage`, `attachments`, `discounts`, `orders_montenegro`, `incoming_transfers`, `outgoing_transfers`, `guest_pays` are arrays loaded from related tables. Any column with a NULL value may be present as `null` or omitted entirely.
- **Room IDs are nested.** There is **no** top-level `id_rooms` or `id_room_types` on a reservation — those live under `data.rooms[]` (one row per assigned room). A multi-room reservation has multiple entries.
- **Guest data is nested.** There is **no** top-level `first_name`, `last_name`, `email`, `phone`, `country`. Read those from `data.guests[]` (filter for `is_primary == 1` to get the primary guest).
- **Money columns are doubles** in the property's configured currency. Read the currency via `POST /api/property/data/property`.
- **Date columns** use `YYYY-MM-DD` for date-only fields (`date_arrival`, `date_departure`, `date_received`) and `YYYY-MM-DD HH:MM:SS` for datetimes (`date_created`, `date_modified`, `guest_check_in`, etc.).
- For `edit`, `data` reflects the **post-edit** state. There is no diff or before-snapshot. If you need before/after, query `/api/reservations/data/reservation` before applying any local mutation.
- For `delete`, `data` is the row as it stood at delete time, with `"is_deleted": 1` and `"date_deleted"` populated.
- `external_id` is the OTA's own reservation code (e.g. Booking.com confirmation number); use it to

---

## Guest messaging events

Three event types cover guest messaging. They differ by **where the message belongs** and, consequently, by payload shape:

| `data_type` | Thread | Keyed by | `data.message` |
|---|---|---|---|
| `inquiry` | New OTA enquiry (no reservation yet) | `message_thread_id` | Single string (the first message) |
| `messages` | Existing enquiry thread | `message_thread_id` | Single string (the new message) |
| `message` | Reservation thread | `id_reservations` | **Array** — the full thread |

**How to route them:** on `inquiry`, create the thread locally and store `message_thread_id` (plus `booking_details` for context). On `messages`, look up that same `message_thread_id` and append. On `message`, match by `id_reservations` and replace/merge the thread from the array.

> On `inquiry` and `messages`, `id_reservations` is `null` — the enquiry has not become a booking. Once a reservation exists, subsequent traffic arrives as `message` with `id_reservations` set.

### `inquiry` — `insert`

Fires when a guest starts a new message thread from an OTA (Airbnb in this example) before any reservation exists. Includes `booking_details` describing the stay the guest is asking about.

```json
{
  "data_type": "inquiry",
  "action": "insert",
  "data": {
    "message": "Buenas tardes, quería saber si está cerca del hotel Radisson Bogotá Metrotel",
    "id_properties": 10647,
    "id_reservations": null,
    "message_thread_id": "db2baedf-8ea3-4552-9b97-f798dc9ebcc6",
    "meta": {
      "name": "Victor",
      "role": "GUEST"
    },
    "booking_details": {
      "id_properties": 10647,
      "id_room_types": 34368,
      "listing_name": "Magnífico Apt en Chapinero Alto + Lavandería",
      "nights": 8,
      "checkout_date": "2026-09-27",
      "checkin_date": "2026-09-19",
      "guest_name": "Victor",
      "number_of_guests": 1,
      "number_of_adults": 1,
      "number_of_children": 0,
      "number_of_infants": 0,
      "payout_amount": "322.36",
      "currency": "USD"
    }
  }
}
```

| Key | Type | Description |
|-----|------|-------------|
| `data.message` | string | The guest's message text. |
| `data.id_properties` | integer | Property the enquiry is for. |
| `data.id_reservations` | null | Always `null` on an enquiry — no reservation exists yet. |
| `data.message_thread_id` | string (uuid) | Thread identifier. **Store this** — follow-up `messages` events reference it. |
| `data.meta.name` | string | Sender's display name. |
| `data.meta.role` | string | Sender role, e.g. `GUEST`. |
| `data.booking_details` | object | The stay the guest is enquiring about (see below). Present on `inquiry` only. |
| `…booking_details.id_room_types` | integer | Room type the enquiry maps to. |
| `…booking_details.listing_name` | string | OTA listing name. |
| `…booking_details.checkin_date` / `checkout_date` | string (YYYY-MM-DD) | Requested stay dates. |
| `…booking_details.nights` | integer | Length of stay. |
| `…booking_details.guest_name` | string | Guest name from the OTA. |
| `…booking_details.number_of_guests` / `number_of_adults` / `number_of_children` / `number_of_infants` | integer | Occupancy breakdown. |
| `…booking_details.payout_amount` | string | Expected payout (as a string). |
| `…booking_details.currency` | string | Currency of `payout_amount`. |

### `messages` — `insert`

Fires for each **follow-up** message on an existing enquiry thread. Match it to the thread you stored from the `inquiry` event via `message_thread_id`. Same shape as `inquiry`, without `booking_details`.

```json
{
  "data_type": "messages",
  "action": "insert",
  "data": {
    "message": "Buenas tardes, quería saber si está cerca del hotel Radisson Bogotá Metrotel",
    "id_properties": 10647,
    "id_reservations": null,
    "message_thread_id": "db2baedf-8ea3-4552-9b97-f798dc9ebcc6",
    "meta": {
      "name": "Victor",
      "role": "GUEST"
    }
  }
}
```

| Key | Type | Description |
|-----|------|-------------|
| `data.message` | string | The new message text. |
| `data.message_thread_id` | string (uuid) | Thread to append to (matches the original `inquiry`). |
| `data.id_reservations` | null | `null` while the thread is still an enquiry. |
| `data.meta` | object | Sender `name` and `role` (`GUEST`, `property`, …). |

### `message` — `insert`

Fires for messages on a **reservation** thread. Unlike the enquiry events, `data.message` is an **array containing the whole conversation**, so treat it as the current state of the thread rather than a single new message.

```json
{
  "data_type": "message",
  "action": "insert",
  "data": {
    "message": [
      {
        "message": "MESSAGE_TEXT",
        "attachments": [],
        "sender": "property",
        "inserted_at": "2026-01-26T17:38:40.644320",
        "id": "145114d1-07b2-4823-8a1b-034274e9c633"
      },
      {
        "message": "MESSAGE_TEXT",
        "attachments": [],
        "sender": "guest",
        "inserted_at": "2026-01-26T17:40:33.892480",
        "id": "03afb201-b62c-4607-b5e7-6d52542b7ae3"
      },
      {
        "message": null,
        "attachments": [
          "ATTACHMENT_1_URL",
          "ATTACHMENT_2_URL"
        ],
        "sender": "guest",
        "inserted_at": "2026-01-26T17:40:38.376101",
        "id": "946ed15c-7497-4732-8317-6a3d00ddbb5f"
      },
      {
        "message": "MESSAGE_TEXT",
        "attachments": [],
        "sender": "property",
        "inserted_at": "2026-01-26T17:44:52.008130",
        "id": "8a492db6-dc44-4d07-b004-5d0355124b62"
      }
    ],
    "id_reservations": 606308,
    "id_properties": 93
  }
}
```

| Key | Type | Description |
|-----|------|-------------|
| `data.message[]` | array | The full message thread, oldest first. |
| `data.message[].id` | string (uuid) | Message ID — use it to de-duplicate against messages you already stored. |
| `data.message[].message` | string \| null | Message text. `null` when the entry is attachments-only. |
| `data.message[].attachments[]` | array of strings | Attachment URLs. Empty when the message is text-only. |
| `data.message[].sender` | string | `guest` or `property`. |
| `data.message[].inserted_at` | string (ISO 8601) | When the message was recorded. |
| `data.id_reservations` | integer | Reservation the thread belongs to. |
| `data.id_properties` | integer | Property the reservation belongs to. |

**Notes**

- A message entry has **either** text **or** attachments: when `attachments[]` is non-empty, `message` is typically `null`. Handle both.
- Because the full thread is resent, de-duplicate on `message[].id` rather than appending blindly.
- Message text arrives in the guest's own language (any script/UTF-8) — do not assume Latin characters.
- Like all webhooks, these are best-effort (1-second timeout, no retry). Reconcile from the API if a thread looks incomplete.

---

## Free-form body — likely additional fields

_This endpoint unpacks the JSON body wholesale (no `checkPost` calls in the action block), so it accepts more fields than the Verified Parameters table lists. The columns below come from the `webhooks` table — the endpoint's primary write target. Treat this as a **superset hint, not a guarantee**: not every column is honoured, and unknown keys may be silently ignored or stored in the row depending on the SQL the action emits._

Primary table: `webhooks`

| Field | Type | Default |
|---|---|---|
| `id_webhooks` | int | — |
| `url` | varchar | — |

