OTA channels
Last updated: October 27, 2026
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).
$signature = hash_hmac(
'sha256',
"POST\n/api/ota/viator/bookings\n".$rawJsonBody,
$webhookSecret,
);
# 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
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}
]
}
availablefalse means closed, sold-out, or outside the sellable window.remainingis the remaining capacity;nullmeans 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-levelremainingis the largest party that fits inside ONE segment, not the day's total. - An event's
remainingis 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_dateextends the range up to 31 days.
Create a booking
POST /api/ota/generic/bookings
{
"product_id": "P-1",
"external_reference": "partner-ref-1001",
"customer": {"name": "Mario Bianchi", "email": "[email protected]", "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:
{"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
- Owner/admin creates a connection on the OTA channels page
(
/ota,otapackage feature) and copies thepublic_id. - Rotate/copy the signing secret once — it is write-only afterwards.
- Map each partner
external_product_idto an event or area (+ optionalprice_key). - Partner signs
METHOD\nREQUEST-URI\nRAW-BODYper call, sends both headers. - Smoke-test
availability→bookings→cancelwith a throwaway reference, then go live.