> ## Documentation Index
> Fetch the complete documentation index at: https://partner.headout.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Event subscription notification

Webhook callback sent by Headout when a subscribed event occurs. Sent once per event, per matching [event subscription](https://partner.headout.com/docs/api-partner/v2/event-subscriptions/create.md).

> [!NOTE]
>
> `product.listing_status_changed`, `product.variant_listing_status_changed`, `product.data_changed`, and `product.variant_pricing_changed` are the supported event types.

### Headers

Every delivery includes:

| Header | Description |
| - | - |
| `X-Headout-Signature` | HMAC-SHA256 signature, see below. |
| `X-Headout-Event-Id` | Stable identifier for this event. Unchanged across retries of the same delivery — use it to deduplicate. |
| `X-Headout-Event-Type` | The event type, e.g. `product.listing_status_changed`. |
| `X-Headout-Delivery-Id` | Unique identifier for this delivery (one event sent to one subscription). Stable across retries of the same delivery. |
| `X-Headout-Delivery-Attempt` | 1-based attempt counter for this delivery — `1` on the first attempt, incrementing on each retry. |
| `Authorization` | Present only if you configured `authentication.type: AUTHORIZATION_HEADER` on the subscription — sent exactly as you set it. |
| `Content-Type` | Always `application/json`. |
| `Content-Length` | Byte length of the JSON body. Deliveries always carry an explicit length and are never sent with chunked transfer encoding. |
| `User-Agent` | `headout_chime/<version>`, e.g. `headout_chime/1.0.2`. The version changes with each release of the delivery service, so match on the `headout_chime/` prefix rather than the full value. |

Apart from the headers above, a delivery carries only standard HTTP transport headers (`Host`, `Connection`, `Accept-Encoding`). No other headers are sent — in particular no `Accept`, `Accept-Language`, `Referer`, `Cookie`, or tracing headers.

Deliveries are server-to-server requests from Headout's infrastructure, not browser traffic: they carry no cookies, no `Referer`, and no browser identity. If a bot-protection or WAF layer sits in front of your endpoint, exempt the webhook path from browser heuristics (optionally scoping the exemption to the `User-Agent` prefix, which is easy to spoof and is not a security control), and verify every delivery's `X-Headout-Signature` in your application as described below. A blocked request that is held open rather than rejected counts as a timeout on our side and is retried on the schedule below.

### Signature verification vs. delivery authentication

These headers carry two independent security mechanisms — one is how Headout proves itself to you, the other is how Headout authenticates against your server:

* **Signature verification** (`X-Headout-Signature`) proves the payload came from Headout and was not modified in transit. It is always present, computed with the subscription's `signingSecret`.
* **Delivery authentication** (`Authorization`) is a value you configure on the subscription and Headout sends back verbatim, so your server (or a gateway in front of it) can apply its own access control.

Always verify the signature, even when an `Authorization` header is configured — the `Authorization` value only controls access to your endpoint; it does not prove the payload is authentic or unmodified.

### Verifying the signature

`X-Headout-Signature` has the form:

```
t=<unix timestamp>,v1=<hex-encoded HMAC-SHA256>
```

To verify a delivery:

1. Extract `t` and `v1` from the header.
2. Compute `HMAC-SHA256(signingSecret, "<t>.<raw request body>")` using the subscription's `signingSecret` (returned once from [Create event subscription](https://partner.headout.com/docs/api-partner/v2/event-subscriptions/create.md) or [Rotate signing secret](https://partner.headout.com/docs/api-partner/v2/event-subscriptions/rotate-secret.md)), over the exact raw bytes of the request body — not a re-serialized version of it.
3. Hex-encode the result (lowercase) and compare it to `v1` using a constant-time comparison.

```go Go theme={"theme":{"light":"github-light","dark":"github-dark"}}
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strings"
)

// rawBody is the request body exactly as received (raw bytes).
func IsValidSignature(rawBody []byte, signatureHeader, secret string) bool {
	// Header: "t=<unix timestamp>,v1=<hmac-hex>"
	var t, v1 string
	for _, part := range strings.Split(signatureHeader, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch strings.TrimSpace(kv[0]) {
		case "t":
			t = strings.TrimSpace(kv[1])
		case "v1":
			v1 = strings.TrimSpace(kv[1])
		}
	}
	if t == "" || v1 == "" {
		return false
	}

	// Headout signs "<t>.<raw body>".
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	computed := hex.EncodeToString(mac.Sum(nil))

	// Constant-time comparison.
	return hmac.Equal([]byte(computed), []byte(v1))
}
```

```java Java theme={"theme":{"light":"github-light","dark":"github-dark"}}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

// rawBody is the request body exactly as received (raw bytes).
boolean isValidSignature(byte[] rawBody, String signatureHeader, String secret) throws Exception {
    // Header: "t=<unix timestamp>,v1=<hmac-hex>"
    String t = null, v1 = null;
    for (String part : signatureHeader.split(",")) {
        String[] kv = part.split("=", 2);
        if (kv.length != 2) continue;
        if (kv[0].trim().equals("t")) t = kv[1].trim();
        else if (kv[0].trim().equals("v1")) v1 = kv[1].trim();
    }
    if (t == null || v1 == null) return false;

    // Headout signs "<t>.<raw body>".
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update(t.getBytes(StandardCharsets.US_ASCII));
    mac.update((byte) '.');
    byte[] computed = mac.doFinal(rawBody);
    String computedHex = HexFormat.of().formatHex(computed);

    // Constant-time comparison. Compares hex strings directly so a malformed
    // (non-hex or wrong-length) v1 fails the comparison instead of throwing.
    return MessageDigest.isEqual(computedHex.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}
```

```kotlin Kotlin theme={"theme":{"light":"github-light","dark":"github-dark"}}
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.StandardCharsets
import java.security.MessageDigest

// rawBody is the request body exactly as received (raw bytes).
fun isValidSignature(rawBody: ByteArray, signatureHeader: String, secret: String): Boolean {
    // Header: "t=<unix timestamp>,v1=<hmac-hex>"
    val parts = signatureHeader.split(",").mapNotNull {
        val kv = it.split("=", limit = 2)
        if (kv.size == 2) kv[0].trim() to kv[1].trim() else null
    }.toMap()
    val t = parts["t"] ?: return false
    val v1 = parts["v1"] ?: return false

    // Headout signs "<t>.<raw body>".
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
    mac.update(t.toByteArray(StandardCharsets.US_ASCII))
    mac.update('.'.code.toByte())
    val computed = mac.doFinal(rawBody).joinToString("") { "%02x".format(it) }

    // Constant-time comparison.
    return MessageDigest.isEqual(computed.toByteArray(), v1.toByteArray())
}
```

```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
const crypto = require("crypto");

function isValidSignature(rawBody, signatureHeader, secret) {
  // Header: "t=<unix timestamp>,v1=<hmac-hex>"
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=").map((s) => s.trim()))
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  // rawBody must be the raw string/Buffer as received — NOT JSON.stringify(parsedObject).
  const computed = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

  // Constant-time comparison. timingSafeEqual throws on a length mismatch, so
  // check that first — a malformed v1 should fail the comparison, not throw.
  const computedBuf = Buffer.from(computed);
  const providedBuf = Buffer.from(v1);
  if (computedBuf.length !== providedBuf.length) return false;
  return crypto.timingSafeEqual(computedBuf, providedBuf);
}
```

```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import hashlib
import hmac


def is_valid_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    # Header: "t=<unix timestamp>,v1=<hmac-hex>"
    parts = {}
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        parts[key.strip()] = value.strip()
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False

    # Headout signs "<t>.<raw body>". raw_body must be the raw bytes as received.
    signed = t.encode() + b"." + raw_body
    computed = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # Constant-time comparison.
    return hmac.compare_digest(computed, v1)
```

> [!WARNING]
>
> Verify against the **raw request body bytes exactly as received**. Parsing the JSON and re-serializing it (for example `JSON.stringify`) produces different bytes, and the signature will not match.

> [!NOTE]
>
> **Secret rotation** — after you rotate the signing secret, deliveries (including retries of earlier failed deliveries) are signed with the new secret. Accept a delivery that verifies against **either** your current or previous secret for at least the next 24 hours after rotating, then discard the previous one.
>
> The signature also carries a timestamp `t`, so you can optionally add replay protection by rejecting deliveries whose `t` is too old.

### Response & retry behavior

Your endpoint should return a `2xx` status as soon as the event is durably received — do heavier processing asynchronously if needed.

> [!NOTE]
>
> * **`2xx`** — Delivery successful, no retry.
> * **Any other status, or a timeout** — Treated as a failed delivery and retried. Non-2xx responses are not treated differently by status code today (a `4xx` retries the same as a `5xx`).
> * Retries run on a backoff schedule of **5m → 30m → 3h → 18h → 24h**, up to **6 attempts total**. After the 6th failed attempt, the delivery is not retried again.
> * Each delivery attempt allows roughly **15 seconds** end-to-end for your endpoint to respond.

### Ordering and deduplication

Deliveries are **not ordered**. Events are dispatched independently of each other, and a failed delivery is retried on the backoff above — so an event retried hours later can arrive after a newer event for the same product.

Reconcile on the payload rather than on arrival order:

| Purpose | Field |
| - | - |
| Ordering | `createdAt` — when Headout observed the change. Keep the latest `createdAt` you have applied per `productId` (and per variant id) and discard any event older than it. |
| Deduplication | `id`, also sent as `X-Headout-Event-Id` — identical across every retry of the same event. |

The payload carries no version or sequence number, and `id` is opaque — don't derive order from it.

Delivery is at-least-once, so the same `id` can arrive more than once: an attempt whose response was lost or timed out is retried even though you processed it. Make your handler idempotent on `id`.

> [!NOTE]
>
> Each event carries the state Headout observed at `createdAt`, not a transition. Two changes in quick succession can collapse into a single event describing the net result, so apply the payload as the current state instead of replaying a sequence of flips.

### Reading a `product.listing_status_changed` event

`listingStatus` is the product's status as of `createdAt`: `LISTED` means it is available to sell, `UNLISTED` means it is not — stop offering it until a later `LISTED` event arrives. A product can flip either way any number of times, so apply each event as the current state rather than as a one-off transition.

If you subscribe to both event types, a product being listed or delisted also produces a `product.variant_listing_status_changed` event for the same change. They are independent deliveries — neither their order nor their arrival together is guaranteed, so apply each on its own.

### Reading a `product.variant_listing_status_changed` event

`data` carries only the variants that changed — `listedVariantIds` for newly listed, `delistedVariantIds` for newly unlisted. Variants that didn't change are omitted, so apply both arrays against the variants you already hold rather than treating them as the full list.

For example, if product `3492` has variants `5397`, `16552`, `20770` and this event lists `52632`, `83740` and unlists `5397`, `16552`, then `20770` is unchanged — leaving `20770`, `52632`, `83740` live.

### Reading a `product.data_changed` event

Sent when the content of a **listed** product changes — the product was listed at both the previous and current sync and at least one tracked field now differs. A product being listed or unlisted is reported by `product.listing_status_changed`, and variants being added or removed by `product.variant_listing_status_changed` — not here.

`data.changedFields` lists the top-level fields that changed; it is never empty. Nested changes roll up to their top-level field, and **any** variant-level change collapses into a single `variants` entry. The event names *what* changed, not the new value — re-fetch the product with [Get a product](https://partner.headout.com/docs/api-partner/v2/products/get.md) to read the updated data.

**Product-level fields**

| `changedFields` entry | Emitted when |
| - | - |
| `name` | The product name changes. |
| `city` | The city code or name changes. |
| `content` | The FAQ (`faqHtml`) or ticket delivery instructions (`ticketDeliveryInfoHtml`) change. |
| `startLocation` / `endLocation` / `address` | The address, city, postal code, country, or coordinates change. |
| `reviewsSummary` | The average rating moves by more than `0.1`. |
| `hasInstantConfirmation` | The instant-confirmation flag flips. |
| `primaryCategory` / `primarySubCategory` / `primaryCollection` | The id or name of the category, subcategory, or collection changes (other fields, e.g. URLs, are ignored). |
| `cancellationPolicy` | The cancellable flag or the cancellable-up-to window changes. |
| `reschedulePolicy` | The reschedulable flag or the reschedulable-up-to window changes. |
| `pois` | A point of interest is added or removed, or a POI's name or operating schedules change. |
| `cutoffTimeInMinutes` | The booking cutoff time changes. |

**Variant fields** — any of the below, on a variant present at both syncs, adds a single `variants` entry (variant changes never appear individually):

| Variant change | Reported as |
| - | - |
| `name`, `duration`, `pax` (min/max), cancellation policy, meeting point (address or coordinates), `properties`, or input fields (a field added/removed, or its id / name / data type / validation / level changes) | `variants` |

> [!NOTE]
>
> Ordering within a list is ignored — reordering points of interest, operating schedules, or input fields without changing their content does **not** emit an event. Adding or removing a whole variant is not reported here either; subscribe to `product.variant_listing_status_changed` for that.

### Reading a `product.variant_pricing_changed` event

Sent when the price of a variant changes for one of the next few dates — currently today, tomorrow, or the day after. It is a **notification, not a price feed** — the payload tells you *which* variant and date moved, never the new amounts. When one arrives, re-fetch the live price from the [List inventory](https://partner.headout.com/docs/api-partner/v2/inventory/list-by-tour.md) endpoint and update your side.

`data` carries three fields:

| Field | Meaning |
| - | - |
| `productId` | The product the variant belongs to. |
| `variantId` | The variant whose price changed. |
| `date` | The single inventory date (`yyyy-MM-dd`, in the timezone of the experience's city) whose price changed. |

**Which dates are watched.** Only the nearest few dates per variant trigger events — currently **today, tomorrow, and the day after**, in the timezone of the experience's city. Price changes further out do not emit; you'll pick those up the next time you fetch availability. Each event covers exactly one date, so if a variant's price changes for two of those days in the same sync, you receive two separate events.

**When it fires — and when it doesn't.** Only changes on the watched dates above (today, tomorrow, or the day after) are considered, and among those a change triggers an event only in these cases:

| Fires | Does **not** fire |
| - | - |
| A pax type (e.g. Adult, Child) is added to or removed from a time slot | A whole time slot appears or disappears — that is availability churn, not a price change |
| A pax's price moves by at least a small threshold (currently \~5%) | A price drifts by less than the threshold |

The threshold is measured against the **last price we notified you about**, not the last price we happened to observe. So a run of tiny sub-threshold moves still produces an event once they add up past the threshold — slow drift can't slip through unnoticed.

> [!NOTE]
>
> Because prices move often, treat this event as a prompt to re-fetch rather than a complete record of every change. Always read the live price from the API before relying on it.

```json Sample payload — product.listing_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_019fdcf4-e289-72f6-b324-488d531377ab",
  "type": "product.listing_status_changed",
  "createdAt": "2026-08-07T16:01:04.136037Z",
  "livemode": true,
  "data": {
    "productId": 123456,
    "listingStatus": "LISTED"
  }
}
```

```json Sample payload — product.variant_listing_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_019fe071-a3b1-73c2-9d45-6f2a11c4e8b0",
  "type": "product.variant_listing_status_changed",
  "createdAt": "2026-08-07T16:01:04.136037Z",
  "livemode": true,
  "data": {
    "productId": 3492,
    "listedVariantIds": [52632, 83740],
    "delistedVariantIds": [5397, 16552]
  }
}
```

```json Sample payload — product.data_changed theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_019fe0a2-1c3d-74e5-8b90-2f6a44d9c1e7",
  "type": "product.data_changed",
  "createdAt": "2026-08-07T16:01:04.136037Z",
  "livemode": true,
  "data": {
    "productId": 123456,
    "changedFields": ["name", "city", "variants"]
  }
}
```

```json Sample payload — product.variant_pricing_changed theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_a6c929d1-31d8-42ef-9c25-be90dedb96d1",
  "type": "product.variant_pricing_changed",
  "createdAt": "2026-10-19T06:10:07.946Z",
  "livemode": true,
  "data": {
    "productId": 58349,
    "variantId": 119874,
    "date": "2026-10-20"
  }
}
```


## OpenAPI

````yaml specs/openapi-v2-common.yaml webhook eventSubscriptionEvent
openapi: 3.1.0
info:
  title: Headout API Docs - Common v2
  version: 0.0.1
servers:
  - url: https://www.headout.com
    description: Production server
  - url: https://www.sandbox-headout.com
    description: Sandbox server
security: []
paths: {}

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.