# Guide to Ventrata Webhooks: Features and Best Practices

Ventrata is a ticketing and booking platform for tours, attractions and experiences. Partners connect to it through the OCTO API, an open standard for the tours and activities industry, and webhooks are the part of that API that pushes changes to you: an order is updated, a booking is confirmed or cancelled, availability shifts, or a product is edited.

This guide covers how Ventrata webhooks are created and authenticated, what a delivery contains, the gaps in Ventrata's documentation, and the practices that keep a Ventrata receiver reliable.

## What are Ventrata webhooks?

Ventrata webhooks belong to the `octo/webhooks` capability of the OCTO API. Ventrata documents them as API resources: you create, list, update and delete webhooks through the API, with `octo/webhooks` in the `Octo-Capabilities` header of each request. Each webhook has one event, a URL, a map of HTTP headers to send with every delivery, and two switches, `retryOnError` and `useContactLanguage`.

When the event happens, Ventrata sends a `POST` to the URL with a JSON body. The body carries the object that changed, serialised exactly as the matching `GET` endpoint would return it, plus a `diff` of what changed for orders, bookings and products.

Ventrata does not sign webhooks. You authenticate them with a header of your own: put a secret in the webhook's `headers` map, such as `X-Webhook-Secret`, and compare it on your side.

## Ventrata webhook features

| Feature | Details |
| --- | --- |
| Configuration | OCTO API: `POST /webhooks`, `GET /webhooks`, `PATCH /webhooks/{webhookId}`, `DELETE /webhooks/{webhookId}` or `DELETE /webhooks` by `url` |
| Capability | `octo/webhooks` in the `Octo-Capabilities` header |
| Events | `order_update`, `booking_update`, `availability_update`, `product_update` |
| Method and format | Always `POST`, JSON body |
| Body | `event`, `webhook`, `supplier`, and `order`, `booking`, `product` or `availability`, plus `diff` |
| Authentication | Headers you set in the webhook's `headers` map |
| Signature and timestamp | None |
| Retries | Per webhook with `retryOnError`; schedule not documented |
| Timeout and source IPs | Not documented |
| Payload shape | The same serialisation as the `GET` endpoints, with the webhook's stored capabilities applied |

## Events and payloads

Ventrata's webhooks page lists four events, one per resource: `order_update`, `booking_update`, `availability_update` and `product_update`. The schema examples on the same page write the event as `booking.updated`, though, so check which spelling the API accepts when you create a webhook and which one arrives in `event`, and dispatch on it accordingly.

A delivery carries the event, the webhook it belongs to, the supplier and the objects the event concerns. This is the example from Ventrata's schema, cut down to a few fields per object:

```json
{
  "event": "booking.updated",
  "webhook": {
    "id": "webhook_booking_update",
    "event": "booking.updated",
    "url": "https://www.city-sightseeing.com"
  },
  "supplier": {
    "id": "3d6f0a3a-59d4-4b16-a0c5-11d2d8a4e6b7",
    "name": "City Sightseeing Group",
    "reference": "CITY_SIGHTSEEING_GROUP"
  },
  "booking": {
    "uuid": "89fe0192-ddcd-430a-b285-e1396a4725d2",
    "status": "ON_HOLD",
    "optionId": "94cdd032-3d32-416d-b0a4-abf8b7495b8b",
    "id": "89fe0192-ddcd-430a-b285-e1396a4725d2"
  },
  "diff": [
    {
      "op": "add",
      "path": "path-example",
      "was": "was-example",
      "value": "value-example"
    }
  ]
}

```

The objects are full API objects, not summaries:

* `order` has the shape of `GET /orders/:orderId`.
* `booking` has the shape of `GET /bookings/:uuid`, including `status`, `contact`, `utcUpdatedAt` and `testMode`.
* `product` has the shape of `GET /products/:productId`.
* `availability` is one item from `GET /availability`.

Fields added by other capabilities, such as pricing, content, extras or questions, appear in the payload the same way they appear in those responses, according to the capabilities stored on the webhook. One exception: with `octo/cardPayments` active, Ventrata does not generate card payment session data for webhooks, so detailed `cardPayment` payloads are left out.

`diff` is sent for order, booking and product updates. Each entry has an `op` of `add`, `remove` or `replace`, a `path`, the previous value in `was` and the new one in `value`.

> **Inspect Ventrata webhooks as they arrive.** Create a webhook pointing at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the full payload your capabilities produce, with no account or setup required.

## Setting up Ventrata webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`. Then create the webhook through the OCTO API at `https://api.ventrata.com/octo`, authenticated as your API connection, with `octo/webhooks` in the `Octo-Capabilities` header:

```http
POST /octo/webhooks
Octo-Capabilities: octo/webhooks
Content-Type: application/json

{
  "event": "booking_update",
  "url": "https://api.yourcompany.com/webhooks/ventrata",
  "headers": {
    "X-Webhook-Secret": "<long random secret>"
  },
  "retryOnError": true
}

```

The webhook stores the capabilities from that request's `Octo-Capabilities` header, not from the body, and uses them to serialise every payload. Add any other capabilities whose fields you want in the payload, such as `octo/pricing`, to the same header. For `order_update`, `octo/cart` is always included.

The response is the webhook object with its `id`. Use `GET /webhooks` to list the webhooks for your connection, `PATCH /webhooks/{webhookId}` to change one, and `DELETE /webhooks/{webhookId}` or `DELETE /webhooks` with a `url` to remove them.

To route through Hookdeck, create a Hookdeck source with the **Ventrata** type and use its URL as the webhook's `url`. Ventrata webhooks are not signed, so add a secret header when you create the webhook through the OCTO API and enter it in the source:

* **API Key Header**: the header name from the `headers` map, such as `X-Webhook-Secret`. Hookdeck looks it up case-insensitively.
* **API Key**: the header's whole value, exactly as you set it. If you put a `Bearer ` prefix in the value, include it here too.

The source accepts `POST` only, which is the method Ventrata always uses.

## Securing Ventrata webhooks

Read the header you chose, compare its whole value with the one you stored in constant time, and reject the request if either is missing. Do the check before you parse the body.

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

const HEADER_NAME = process.env.VENTRATA_WEBHOOK_HEADER || 'X-Webhook-Secret';
const HEADER_VALUE = process.env.VENTRATA_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/ventrata', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME);
  if (!HEADER_VALUE || typeof received !== 'string' || !safeEqual(received, HEADER_VALUE)) {
    return res.sendStatus(401);
  }

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Store payload.event and the changed object, respond, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("VENTRATA_WEBHOOK_HEADER", "X-Webhook-Secret")
HEADER_VALUE = os.environ.get("VENTRATA_WEBHOOK_SECRET", "")

def verify_ventrata_request(headers) -> bool:
    if not HEADER_VALUE:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), HEADER_VALUE.encode("utf-8"))

```

The header value is static. It authenticates the sender, not the body, and with no timestamp a captured request can be replayed. Serve the endpoint over HTTPS only, and if the secret might have leaked, replace it with `PATCH /webhooks/{webhookId}`.

Ventrata says the `webhook` field in a delivery is the same object its webhook endpoints return, and that object's schema includes the `headers` map. Its example payload shows only `id`, `event` and `url`, so check a real delivery: if the headers are there, your secret is in the body as well, and you should redact it before logging or forwarding payloads.

> **Make Ventrata webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the header you configure, stores every booking change durably, and retries delivery to your service on a schedule you control.

## Ventrata webhook limitations and pain points

### Retry behaviour is a yes or no

**The Problem:** You cannot tell how long Ventrata keeps trying after your endpoint fails, or whether a booking change was dropped.

**Why It Happens:** Each webhook has a `retryOnError` switch, but Ventrata does not document the schedule, the number of attempts, the timeout or what counts as an error. There is no documented way to list past deliveries or resend one.

**Workarounds:**

* Set `retryOnError` to `true` on every webhook.
* Respond with a `2xx` quickly and process afterwards.
* Reconcile bookings and orders against the OCTO API after an outage, using `utcUpdatedAt`.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure. You can replay any event after a fix, and [issues](/docs/issues) alert you when deliveries start failing.

### Updates can silently drop capabilities

**The Problem:** After a change to a webhook, payloads stop including pricing, content or other fields your code relies on.

**Why It Happens:** A webhook's capabilities come from the `Octo-Capabilities` header, not the body, and Ventrata recalculates them on every `PATCH`. An update sent with only `octo/webhooks` in the header leaves the webhook with no extra capabilities, even if you only meant to change the URL.

**Workarounds:**

* Send the full `Octo-Capabilities` header on every create and update.
* After each change, call `GET /webhooks` and check the `capabilities` array.

**How Hookdeck Can Help:** Hookdeck stores the payloads it receives, so you can compare deliveries from before and after a change and see exactly which fields went missing.

### The event name is ambiguous

**The Problem:** A handler that switches on `booking_update` never matches, or one that expects `booking.updated` does not.

**Why It Happens:** Ventrata's webhooks page lists the events as `order_update`, `booking_update`, `availability_update` and `product_update`, while its schema examples, including the example delivery, use `booking.updated`.

**Workarounds:**

* Log `event` from your first real delivery and code against that.
* Until you have, accept both spellings.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `event` and [transformations](/docs/transformations) can normalise it to one spelling before it reaches your service.

### Large payloads with personal data

**The Problem:** Each delivery carries a whole order or booking, including customer contact details.

**Why It Happens:** Ventrata serialises the full object the same way its `GET` endpoints do, with every active capability applied, rather than sending an ID for you to fetch.

**Workarounds:**

* Subscribe each webhook with only the capabilities its consumer needs.
* Treat stored payloads as personal data and apply your retention rules to them.
* Use `testMode` on bookings and orders to keep test traffic out of production data.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/transformations) can strip fields such as `contact` before a payload reaches a downstream service that does not need them.

## Best practices

### Always set a secret header

A webhook without one sends nothing your endpoint can check. Use a long random value and give each environment its own webhook and secret.

### Compare the whole value

Store the exact value from the `headers` map and compare against it in full, without trimming or parsing a prefix.

### Make handlers idempotent

With retries switched on, the same change can arrive more than once. Use the booking `uuid` or order `id` together with `utcUpdatedAt` to skip changes you have already applied. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Check the header, store the payload, return `200`, and run your booking logic afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Check your webhooks after every change

Call `GET /webhooks` after any create or update and confirm the event, URL and capabilities are what you expect.

## Conclusion

Ventrata sends OCTO webhooks for orders, bookings, availability and products as `POST` requests carrying the full API object and a `diff`, with no signature and an undocumented retry schedule. Set a long random secret in each webhook's `headers` map, compare it in constant time over HTTPS, send the full `Octo-Capabilities` header whenever you create or update a webhook, and process every delivery idempotently.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, keeps every delivery, and retries and replays it to your service, so a short outage does not cost you a booking change.

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