# OTA channels

# OTA channels

Inbound booking surface for Online Travel Agencies (Viator-class partners).
The contract is provider-agnostic and stable — the same paths serve every
provider, `{provider}` selects the payload mapping. Viator ships as a
documented mapping skeleton, `generic` speaks this contract as-is for bespoke
integrations, GetYourGuide answers `501` until certification. Inbound only:
the app never pushes availability to the partner.

## Endpoints

```
GET  /api/ota/{provider}/availability?product_id={id}&date=YYYY-MM-DD[&to_date=YYYY-MM-DD]
POST /api/ota/{provider}/bookings
POST /api/ota/{provider}/bookings/{external_reference}/cancel
```

`{provider}` is one of `viator`, `getyourguide`, `generic`. Unknown or
disabled providers return `404`.

Rate limit: 60 requests per minute per connection.

## Authentication

Every request carries two headers:

- `X-Ota-Connection: {public_id}` — the connection's public id, shown on the
  OTA channels admin page. Never the internal id.
- `X-Ota-Signature: {hex}` — hex HMAC-SHA256 keyed with the connection's
  signing secret (shown once when the connection is created or rotated) over:

```
{METHOD}\n{REQUEST-URI}\n{RAW-BODY}
```

`REQUEST-URI` is the exact request target including the query string —
`/api/ota/viator/availability?product_id=P-1&date=2027-01-15`. `RAW-BODY` is
the unparsed request body (empty string on GET).

```php
$signature = hash_hmac(
    'sha256',
    "POST\n/api/ota/viator/bookings\n".$rawJsonBody,
    $webhookSecret,
);
```

```bash
# worked example (generic provider)
curl -X POST https://{host}/api/ota/generic/bookings \
  -H "X-Ota-Connection: 9f4c…" \
  -H "X-Ota-Signature: $(php -r 'echo hash_hmac("sha256", "POST\n/api/ota/generic/bookings\n".$argv[1], "SECRET");' "$body")" \
  -H "Content-Type: application/json" \
  --data-raw "$body"
```

Errors: unknown connection `404 {"error":"unknown_connection"}`; missing or
mismatched signature `401 {"error":"bad_signature"}`. The company is always
resolved from the connection — payloads cannot select a tenant.

## Product mapping

Partners sell `external_product_id` values. Each id maps in the admin UI to
exactly one bookable — an event or an area — plus an optional `price_key`
(null uses the bookable's default price resolution). Unmapped ids return
`404 {"error":"unknown_product"}`.

## Availability

```json
GET /api/ota/generic/availability?product_id=P-1&date=2027-01-15

{
  "product_id": "P-1",
  "days": [
    {"date": "2027-01-15", "available": true, "remaining": 7}
  ]
}
```

- `available` false means closed, sold-out, or outside the sellable window.
- `remaining` is the remaining capacity; `null` means uncapped.
- Area products additionally return `windows: [{from, to, remaining}]` —
  the day's sellable segments. Each segment carries its own remaining
  (per-segment capacity minus the simultaneous peak, bounded by every
  daily pool the segment touches); the day-level `remaining` is the
  largest party that fits inside ONE segment, not the day's total.
- An event's `remaining` is bounded by EVERY date of its run — a booking
  consumes the pool of each day the event occupies.
- An event without an end runs on its start day only.
- `to_date` extends the range up to 31 days.

## Create a booking

```json
POST /api/ota/generic/bookings

{
  "product_id": "P-1",
  "external_reference": "partner-ref-1001",
  "customer": {"name": "Mario Bianchi", "email": "mario@example.com", "phone": "+39002"},
  "number": 2,
  "from": "2027-01-15T10:00:00+00:00",
  "to": "2027-01-15T11:00:00+00:00",
  "date": "2027-01-15"
}
```

`from`/`to` apply to area products (sellable windows); `date` is the
calendar day for event products and must fall inside the event's run.

Event products book the leaf the channel can address: a bare event books
directly, an event with Times books the Time whose slot starts at `from`
(`from` is then required — missing or unmatched returns `422`), and an
event with Seats reports `available: false` instead of advertising a slot
the partner could never book (seat choice stays on the widget).

Response:

```json
{"booking": {"reference": "partner-ref-1001", "status": "confirmed",
             "booking_code": "BK-AB12", "number": 2,
             "created_at": "2027-01-10T09:30:00+00:00"}}
```

Bookings arrive free — if the mapped bookable would require payment the
booking is refused with `422 {"error":"payment_required"}` and the held
capacity is released. Partner-collected money reconciles outside the app.

Validation failures return `422` with `error` (`booking_failed`) and a
`fields` map. Replays of a failed reference return the recorded failure.

### Idempotency

`external_reference` is the idempotency key, unique per connection. A retry
re-POSTing the same reference returns the recorded outcome — `200` with the
same booking payload — without touching capacity again. Never reuse a
reference across different orders.

## Cancellation

```
POST /api/ota/{provider}/bookings/{external_reference}/cancel
```

Cancels the underlying booking through the normal cancel spine and flips
the ledger row to `cancelled`. Unknown reference → `404
{"error":"unknown_reference"}`. Cancelling an already-cancelled reference
is a `200` no-op. Settlement of partner-collected money is the partner's
business — the endpoint never triggers a refund.

## Partner onboarding checklist

1. Owner/admin creates a connection on the OTA channels page
   (`/ota`, `ota` package feature) and copies the `public_id`.
2. Rotate/copy the signing secret once — it is write-only afterwards.
3. Map each partner `external_product_id` to an event or area (+ optional
   `price_key`).
4. Partner signs `METHOD\nREQUEST-URI\nRAW-BODY` per call, sends both
   headers.
5. Smoke-test `availability` → `bookings` → `cancel` with a throwaway
   reference, then go live.


---
> Source: https://placeful-app.com/en/docs/developer/ota
