# Panion Partner API A REST API for tour operators on Panion: your bookings, your daily manifest (every channel), your sales and your analytics, plus narrow writes such as checking guests in and sending a balance payment link. The API never creates bookings. No endpoint reserves, books, rebooks, checks out or mints share links. Bookings are made through Panion checkout or your own booking system. Base URL: https://www.panion.travel/api/v1. JSON over HTTPS. Money is integer minor units (`amount_minor`) with an ISO 4217 `currency`. Times are ISO 8601; departure times are local `HH:MM` with an IANA `timezone`. Lists page with `next_cursor` passed back as `cursor`. Endpoints: - GET /api/v1/me: The key and the operators it acts for. Section: authentication. - GET /api/v1/bookings: List bookings. Section: bookings. - GET /api/v1/bookings/{id}: Get one booking. Section: bookings. - GET /api/v1/manifest: Departures and passengers for one day. Section: manifest. - GET /api/v1/sales: Sales per day and channel. Section: sales. - GET /api/v1/analytics/tours: Tour funnel and sales per surface and day. Section: analytics. - GET /api/v1/analytics/sites: Traffic, sources, funnel and sales per site. Section: analytics. - GET /api/v1/bookings/{id}/reschedule-options: Departures a booking can move to on one day. Section: bookings. - POST /api/v1/manifest/{id}/checkin: Check a manifest booking in, mark a no-show, or clear it. Section: writes. - POST /api/v1/bookings/{id}/payment-link: Email the guest a link to pay the balance still owed. Section: writes. - POST /api/v1/bookings/{id}/cancel: Cancel a booking with a full refund. Section: writes. - POST /api/v1/bookings/{id}/reschedule: Move a booking to another date or departure. Section: writes. Machine-readable: https://www.panion.travel/api/v1/openapi.json (OpenAPI 3.1). Sections as Markdown: https://www.panion.travel/api/v1/docs/overview.md, https://www.panion.travel/api/v1/docs/authentication.md, https://www.panion.travel/api/v1/docs/bookings.md, https://www.panion.travel/api/v1/docs/manifest.md, https://www.panion.travel/api/v1/docs/sales.md, https://www.panion.travel/api/v1/docs/analytics.md, https://www.panion.travel/api/v1/docs/writes.md, https://www.panion.travel/api/v1/docs/errors.md, https://www.panion.travel/api/v1/docs/limits.md, https://www.panion.travel/api/v1/docs/changelog.md. # Authentication Send the key on every request as `Authorization: Bearer pnn_live_...`. Never put a key in a URL; a key in the query string is refused. Team owners and admins create keys in Oracle under Settings, API keys. A key is shown once, has the scopes ticked when it was made and an expiry. Write scopes are off on a new key unless ticked. ## Operators on a key One key can cover several operators: the ones its creator owns or administers and picked for it. Requests act for those where the creator is still owner or admin (none left: 401). Reads return every operator's data, and every booking, manifest passenger and sales row has a `partner` (`id`, `name`). Add `partner_id` to a read for one operator; an id that is not one of the key's operators answers 404. Operators sharing one booking system account are shown once. `GET /api/v1/me` lists the key's operators. Reads that take `partner_id`: - GET /api/v1/bookings - GET /api/v1/bookings/{id} - GET /api/v1/manifest - GET /api/v1/sales - GET /api/v1/analytics/tours - GET /api/v1/analytics/sites Scopes: - `bookings:read` (Bookings): List and read your Panion bookings. No guest email or phone. - `manifest:read` (Manifest): Departures for a day with passengers and check-in state, from every channel. - `guests.contact:read` (Guest contact details): Adds guest email and phone to bookings and the manifest. Off unless you need it. - `finance:read` (Sales): Daily sales totals per channel. - `analytics:read` (Analytics): Your tours' funnel and sales on every site and channel they are sold on, plus traffic for the sites you are on. Owners of a site also see every tour sold there. - `checkins:write` (Check guests in (write)): Mark a manifest booking checked in or a no-show, or clear it. Never changes the booking. - `payment_links:write` (Send balance payment links (write)): Email the guest a link to pay the balance still owed. Panion decides the amount. Never a rebook link. - `bookings.cancel:write` (Cancel bookings with policy refund (write)): Cancel a live booking: the seats are released in your booking system and the guest gets a full refund, exactly as Cancel and refund in Oracle. Cannot be undone. - `bookings.reschedule:write` (Reschedule bookings (write)): Move a live booking to another available date or departure at the same price. Never charges or refunds; a different price is refused. Guest email and phone appear only with `guests.contact:read`. Without it those keys are absent. ### GET /api/v1/me The key and the operators it acts for. Returns the key's name, scopes and expiry, and every operator it acts for right now (`partners`). Scope: any valid key. Response fields (200): - `key` (object) - `id` (string): Public key id (the part after pnn_live_). - `name` (string) - `scopes` (array) - `created_at` (string, ISO 8601 date-time) - `expires_at` (string, ISO 8601 date-time) - `partner` (object): The first of `partners`, by name (kept for one-operator clients). - `id` (string) - `name` (string) - `partners` (array): Every operator this key acts for right now: the operators it was created for where its creator is still owner or admin. - each item: - `id` (string) - `name` (string) Example: A key with read scopes ```sh curl "https://www.panion.travel/api/v1/me" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "key": { "id": "k7h2m4p9", "name": "Front desk sync", "scopes": [ "bookings:read", "manifest:read" ], "created_at": "2026-10-01T08:00:00Z", "expires_at": "2027-10-01T08:00:00Z" }, "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "partners": [ { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" } ] } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 429: Rate limited (300 requests per minute per key) # Bookings ### GET /api/v1/bookings List bookings. Bookings made through Panion for the tours of every operator on the key (or the one in `partner_id`), newest first, 50 per page by default. Each carries its `partner`. Holds and abandoned checkouts never appear. Scope: `bookings:read`. Query parameters: - `date_from` (string, YYYY-MM-DD, optional): Experience date on or after (YYYY-MM-DD). - `date_to` (string, YYYY-MM-DD, optional): Experience date on or before (YYYY-MM-DD). - `updated_since` (string, ISO 8601 date-time, optional): Only bookings created or changed at or after this instant. - `status` (one of `CONFIRMED`, `CANCELLED`, `REFUNDED`, optional) - `cursor` (string, optional): `next_cursor` of the last page. - `limit` (integer, optional, default 50) - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. Omit for every operator on the key. Response fields (200): - `data` (array) - each item: - `id` (string) - `confirmation_code` (string) - `status` (one of `CONFIRMED`, `CANCELLED`, `REFUNDED`) - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `id` (string) - `name` (string) - `tour` (object) - `id` (string, nullable) - `name` (string, nullable) - `experience_date` (string, YYYY-MM-DD, nullable) - `start_time` (string, nullable): Local wall-clock time (HH:MM) in `timezone`. - `timezone` (string): IANA timezone, e.g. Europe/Oslo. - `participants` (integer, nullable) - `amount_minor` (integer): Integer minor units (øre, cents). - `currency` (string): ISO 4217 code. - `channel` (string, nullable) - `guest` (object) - `name` (string, nullable) - `email` (string, nullable, optional): Only with the guests.contact:read scope. - `phone` (string, nullable, optional): Only with the guests.contact:read scope. - `booked_at` (string, ISO 8601 date-time, nullable) - `created_at` (string, ISO 8601 date-time) - `updated_at` (string, ISO 8601 date-time, nullable) - `next_cursor` (string, nullable): Pass as `cursor` for the next page. Example: Bookings for one week ```sh curl "https://www.panion.travel/api/v1/bookings?date_from=2026-11-10&date_to=2026-11-16&limit=50" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "data": [ { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "confirmation_code": "PNN-7K2Q9X", "status": "CONFIRMED", "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "tour": { "id": "3a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "name": "Northern Lights Chase" }, "experience_date": "2026-11-14", "start_time": "19:00", "timezone": "Europe/Oslo", "participants": 2, "amount_minor": 359800, "currency": "NOK", "channel": "panion", "guest": { "name": "Ingrid Hansen" }, "booked_at": "2026-10-01T12:30:00Z", "created_at": "2026-10-01T12:30:00Z", "updated_at": null } ], "next_cursor": null } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 429: Rate limited (300 requests per minute per key) ### GET /api/v1/bookings/{id} Get one booking. One booking by its id. A booking whose tour belongs to none of the key's operators (or not to `partner_id`) answers 404. Scope: `bookings:read`. Path parameters: - `id` (string): Booking id (UUID). Query parameters: - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. Omit for every operator on the key. Response fields (200): - `id` (string) - `confirmation_code` (string) - `status` (one of `CONFIRMED`, `CANCELLED`, `REFUNDED`) - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `id` (string) - `name` (string) - `tour` (object) - `id` (string, nullable) - `name` (string, nullable) - `experience_date` (string, YYYY-MM-DD, nullable) - `start_time` (string, nullable): Local wall-clock time (HH:MM) in `timezone`. - `timezone` (string): IANA timezone, e.g. Europe/Oslo. - `participants` (integer, nullable) - `amount_minor` (integer): Integer minor units (øre, cents). - `currency` (string): ISO 4217 code. - `channel` (string, nullable) - `guest` (object) - `name` (string, nullable) - `email` (string, nullable, optional): Only with the guests.contact:read scope. - `phone` (string, nullable, optional): Only with the guests.contact:read scope. - `booked_at` (string, ISO 8601 date-time, nullable) - `created_at` (string, ISO 8601 date-time) - `updated_at` (string, ISO 8601 date-time, nullable) - `provider_reference` (string, nullable): Your booking system's own reference (e.g. Bokun code). - `payment` (object) - `strategy` (one of `pay_now`, `pay_later`) - `pay_later` (object, nullable) - `status` (string) - `amount_minor` (integer): Integer minor units (øre, cents). - `currency` (string, nullable): ISO 4217 code. - `charge_at` (string, ISO 8601 date-time, nullable) - `paid_at` (string, ISO 8601 date-time, nullable) - `commission` (object, nullable) - `amount_minor` (integer): Integer minor units (øre, cents). - `rate_percent` (number, nullable) - `currency` (string, nullable): ISO 4217 code. - `status` (string, nullable) Example: A paid booking ```sh curl "https://www.panion.travel/api/v1/bookings/8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "confirmation_code": "PNN-7K2Q9X", "status": "CONFIRMED", "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "tour": { "id": "3a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "name": "Northern Lights Chase" }, "experience_date": "2026-11-14", "start_time": "19:00", "timezone": "Europe/Oslo", "participants": 2, "amount_minor": 359800, "currency": "NOK", "channel": "panion", "guest": { "name": "Ingrid Hansen" }, "booked_at": "2026-10-01T12:30:00Z", "created_at": "2026-10-01T12:30:00Z", "updated_at": null, "provider_reference": "ARC-123456", "payment": { "strategy": "pay_now", "pay_later": null }, "commission": { "amount_minor": 53970, "rate_percent": 15, "currency": "NOK", "status": "pending" } } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 429: Rate limited (300 requests per minute per key) ### GET /api/v1/bookings/{id}/reschedule-options Departures a booking can move to on one day. Read only. Whether the booking can move to `date` and the departures it can take there, from the booking's own booking system (one availability read for that day). A day with room only at a different price shows `price_differs` and no options: a move never changes the price. Needs the bookings.reschedule:write scope. Scope: `bookings.reschedule:write`. Path parameters: - `id` (string): Booking id (UUID) from GET /api/v1/bookings. Query parameters: - `date` (string, YYYY-MM-DD): The day to check (YYYY-MM-DD). Response fields (200): - `id` (string): The booking id. - `date` (string) - `current_date` (string, nullable): The booking's departure day now. - `reschedulable` (boolean): False when the booking cannot be moved at all; `detail` says why. - `available` (boolean): True when the booking can move to `date`. - `price_differs` (boolean): True when `date` has room only at a different price. A move never changes the price, so it is refused. - `detail` (string, nullable) - `options` (array) - each item: - `start_time_id` (string) - `rate_id` (string, nullable) - `start_time` (string, nullable) - `label` (string, nullable) - `remaining` (integer) Example: Departures on the new day ```sh curl "https://www.panion.travel/api/v1/bookings/8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f/reschedule-options?date=2026-12-14" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "date": "2026-12-14", "current_date": "2026-12-12", "reschedulable": true, "available": true, "price_differs": false, "detail": null, "options": [ { "start_time_id": "4821", "rate_id": "1093", "start_time": "18:00", "label": null, "remaining": 12 } ] } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 429: Rate limited (300 requests per minute per key) # Manifest ### GET /api/v1/manifest Departures and passengers for one day. Every departure the key's operators (or the one in `partner_id`) run on a day, with passengers from every channel, their check-in state and their `partner`. Operators sharing one booking system account show each booking once. Read from your booking system's mirror, which refreshes every two hours for near-term departures. Scope: `manifest:read`. Query parameters: - `date` (string, YYYY-MM-DD): Departure day (YYYY-MM-DD), local time. - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. Omit for every operator on the key. Response fields (200): - `date` (string, YYYY-MM-DD) - `timezone` (string): IANA timezone, e.g. Europe/Oslo. - `synced_at` (string, ISO 8601 date-time, nullable): When the booking-system mirror last refreshed this day. - `totals` (object) - `departures` (integer) - `bookings` (integer) - `passengers` (integer) - `checked_in` (integer) - `no_show` (integer) - `departures` (array) - each item: - `start_time` (string, nullable): Local wall-clock time (HH:MM) in `timezone`. - `timezone` (string): IANA timezone, e.g. Europe/Oslo. - `product_title` (string, nullable) - `rate_title` (string, nullable) - `passengers` (integer) - `checked_in` (integer) - `no_show` (integer) - `bookings` (array) - each item: - `id` (string): Manifest row id (stable for this booking line). - `reference` (string, nullable) - `confirmation_code` (string, nullable) - `status` (string) - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `channel` (object) - `participants` (integer) - `pax_breakdown` (array, nullable) - `guest` (object) - `pickup` (object, nullable) - `payment_status` (string, nullable) - `check_in` (object) - `cancelled` (array) - each item: - `id` (string): Manifest row id (stable for this booking line). - `reference` (string, nullable) - `confirmation_code` (string, nullable) - `status` (string) - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `id` (string) - `name` (string) - `channel` (object) - `class` (string, nullable) - `source` (string, nullable) - `title` (string, nullable) - `participants` (integer) - `pax_breakdown` (array, nullable) - each item: - `title` (string) - `quantity` (integer) - `guest` (object) - `name` (string, nullable) - `email` (string, nullable, optional): Only with the guests.contact:read scope. - `phone` (string, nullable, optional): Only with the guests.contact:read scope. - `pickup` (object, nullable) - `place` (string, nullable) - `time` (string, nullable) - `payment_status` (string, nullable) - `check_in` (object) - `status` (one of `checked_in`, `no_show`, nullable) - `present` (integer, nullable) Example: One departure ```sh curl "https://www.panion.travel/api/v1/manifest?date=2026-11-14" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "date": "2026-11-14", "timezone": "Europe/Oslo", "synced_at": "2026-11-14T10:00:00Z", "totals": { "departures": 1, "bookings": 1, "passengers": 2, "checked_in": 0, "no_show": 0 }, "departures": [ { "start_time": "19:00", "timezone": "Europe/Oslo", "product_title": "Northern Lights Chase", "rate_title": "Standard", "passengers": 2, "checked_in": 0, "no_show": 0, "bookings": [ { "id": "5c4b3a29-1807-4f6e-9d5c-4b3a29180716", "reference": "ARC-123456", "confirmation_code": "PNN-7K2Q9X", "status": "CONFIRMED", "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "channel": { "class": "panion", "source": "Panion", "title": "Panion" }, "participants": 2, "pax_breakdown": [ { "title": "Adult", "quantity": 2 } ], "guest": { "name": "Ingrid Hansen" }, "pickup": { "place": "Radisson Blu Hotel", "time": "18:45" }, "payment_status": "PAID", "check_in": { "status": null, "present": null } } ] } ], "cancelled": [] } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 429: Rate limited (300 requests per minute per key) # Sales ### GET /api/v1/sales Sales per day and channel. Daily totals for a date range: Panion sales by booking date per operator, and every other channel from your booking system, for every operator on the key or the one in `partner_id`. Scope: `finance:read`. Query parameters: - `from` (string, YYYY-MM-DD): First day (YYYY-MM-DD). - `to` (string, YYYY-MM-DD): Last day, inclusive (YYYY-MM-DD). At most 366 days. - `currency` (one of `EUR`, `NOK`, optional, default "NOK"): Currency for other-channel figures. - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. Omit for every operator on the key. Response fields (200): - `from` (string, YYYY-MM-DD) - `to` (string, YYYY-MM-DD) - `panion` (object): Sales made through Panion (by booking date), in booking currency, one row per day, currency and operator. - `days` (array) - each item: - `date` (string, YYYY-MM-DD) - `revenue_minor` (integer): Integer minor units (øre, cents). - `bookings` (integer) - `currency` (string): ISO 4217 code. - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `id` (string) - `name` (string) - `totals` (array) - each item: - `currency` (string): ISO 4217 code. - `partner` (object): The operator a row belongs to: one of the key's operators (the tour's operator for a booking). - `id` (string) - `name` (string) - `revenue_minor` (integer): Integer minor units (øre, cents). - `bookings` (integer) - `other_channels` (object): Every other channel from your booking system (OTAs, desk, ...), converted. - `currency` (string): ISO 4217 code. - `partners` (array): The operators these figures cover. Operators sharing one booking system account are counted once, not once per operator. - each item: - `id` (string) - `name` (string) - `available` (boolean): False when the figures could not be read. - `days` (array) - each item: - `date` (string, YYYY-MM-DD) - `revenue_minor` (integer): Integer minor units (øre, cents). - `bookings` (integer) - `channels` (array) - each item: - `label` (string) - `revenue_minor` (integer): Integer minor units (øre, cents). - `bookings` (integer) - `participants` (integer) - `totals` (object) - `revenue_minor` (integer): Integer minor units (øre, cents). - `bookings` (integer) - `participants` (integer) Example: One day ```sh curl "https://www.panion.travel/api/v1/sales?from=2026-11-01&to=2026-11-01¤cy=NOK" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "from": "2026-11-01", "to": "2026-11-01", "panion": { "days": [ { "date": "2026-11-01", "revenue_minor": 359800, "bookings": 1, "currency": "NOK", "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" } } ], "totals": [ { "currency": "NOK", "revenue_minor": 359800, "bookings": 1, "partner": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" } } ] }, "other_channels": { "currency": "NOK", "partners": [ { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" } ], "available": true, "days": [ { "date": "2026-11-01", "revenue_minor": 539700, "bookings": 2 } ], "channels": [ { "label": "Desk", "revenue_minor": 539700, "bookings": 2, "participants": 3 } ], "totals": { "revenue_minor": 539700, "bookings": 2, "participants": 3 } } } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 429: Rate limited (300 requests per minute per key) # Analytics Needs the `analytics:read` scope. Money is EUR in minor units; days are UTC. Analytics follow one visibility rule, the same one Oracle uses. (1) Your own tours on every surface they are sold on: each Panion-powered site (funnel and sales) and each other sales channel in your booking system, such as GetYourGuide or Viator (sales and revenue only). (2) Whole-site traffic for the sites your operators are on. (3) Sales of other operators' tours only on a site your operator owns; on a site you are only added to, sales cover your own tours. Analytics are read from pre-computed daily rollups that refresh every five minutes, never from raw events. A surface is either a `site` (a Panion-powered website: sessions per funnel step, bookings, revenue and conversion rates) or a `channel` (another sales channel from your booking system: bookings, participants and revenue only, because a channel sale has no session to divide by). Every tour row says why you see it in `basis`: `own_tour` or `site_owner`. Limits: at most 400 days per request and 2 analytics requests at a time per key (more answer 429 with `Retry-After`). A request that takes longer than 20 seconds answers 503; retry or ask for a shorter range. ### GET /api/v1/analytics/tours Tour funnel and sales per surface and day. Your tours on every surface they are sold on: each Panion-powered site (sessions that viewed the tour, checked availability, opened checkout and tried to pay, then bookings, revenue in EUR and conversion rates) and each other sales channel in your booking system, such as GetYourGuide or Viator (bookings, participants and revenue only: a channel sale has no session, so no funnel and no rate). If your operator owns a site, every operator's tours sold on it are included too, marked `basis: site_owner`. Days are UTC. At most 400 days per request. Scope: `analytics:read`. Query parameters: - `from` (string, YYYY-MM-DD): First UTC day (YYYY-MM-DD). - `to` (string, YYYY-MM-DD): Last UTC day, inclusive (YYYY-MM-DD). At most 400 days from `from`. - `granularity` (one of `day`, `total`, optional, default "day"): `day`: one row per tour, surface and UTC day. `total`: one row per tour and surface for the whole range. - `tour_id` (string, optional): Only this tour. - `surface` (string, optional): Only this surface: a site domain or a channel id from `surface.id`. - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. What you see follows that operator's sites and tours. Response fields (200): - `from` (string, YYYY-MM-DD) - `to` (string, YYYY-MM-DD) - `granularity` (one of `day`, `total`) - `currency` (`EUR`) - `partners` (array): The operators this answer is for (the key's, or `partner_id`). - each item: - `id` (string) - `name` (string) - `rows` (array) - each item: - `date` (string, YYYY-MM-DD, nullable): UTC day. Null with `granularity=total` (the whole range). - `surface` (object) - `type` (one of `site`, `channel`): `site`: a Panion-powered website (sessions, funnel and sales). `channel`: another sales channel from your booking system, such as GetYourGuide or Viator (sales and revenue only, never a funnel). - `id` (string): The site's domain, or the channel's id (e.g. `getyourguide`). - `name` (string) - `tour` (object) - `id` (string, nullable) - `name` (string, nullable) - `operator` (object, nullable): The tour's operator. Null when the tour is not matched to one. - `id` (string) - `name` (string) - `basis` (one of `own_tour`, `site_owner`): Why you see this row: `own_tour` (a tour of one of your operators, shown on every surface) or `site_owner` (another operator's tour sold on a site you own). - `funnel` (object, nullable): Null on a channel. - `views` (integer): Sessions that viewed the tour. - `availability_checks` (integer): Sessions that checked availability. - `checkout_opens` (integer): Sessions that opened checkout. - `checkout_attempts` (integer): Sessions that tried to pay. - `bookings` (integer) - `participants` (integer, nullable): Channels only; null on a site. - `revenue_minor` (integer): Integer minor units (cents) of `currency` (EUR). - `conversion` (object, nullable): Null on a channel. - `view_to_check` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `check_to_open` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `open_to_attempt` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `attempt_to_booking` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `view_to_booking` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `surfaces` (array): Every row of `rows` summed per surface, largest revenue first. - each item: - `surface` (object) - `type` (one of `site`, `channel`): `site`: a Panion-powered website (sessions, funnel and sales). `channel`: another sales channel from your booking system, such as GetYourGuide or Viator (sales and revenue only, never a funnel). - `id` (string): The site's domain, or the channel's id (e.g. `getyourguide`). - `name` (string) - `funnel` (object, nullable): Distinct sessions per step. Sites only. - `views` (integer): Sessions that viewed the tour. - `availability_checks` (integer): Sessions that checked availability. - `checkout_opens` (integer): Sessions that opened checkout. - `checkout_attempts` (integer): Sessions that tried to pay. - `bookings` (integer) - `participants` (integer, nullable) - `revenue_minor` (integer): Integer minor units (cents) of `currency` (EUR). - `revenue_share` (number): This surface's share of the revenue in `surfaces` (0 to 1). - `conversion` (object, nullable): Step-through rates. Sites only. - `view_to_check` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `check_to_open` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `open_to_attempt` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `attempt_to_booking` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `view_to_booking` (number, nullable): A ratio (0.25 = 25%). Null when the step before it is zero. - `other_channels_available` (boolean): False when other-channel sales could not be read this time; the site rows are still complete. Example: One tour on a site and on GetYourGuide ```sh curl "https://www.panion.travel/api/v1/analytics/tours?from=2026-11-01&to=2026-11-30&granularity=total" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "from": "2026-11-01", "to": "2026-11-30", "granularity": "total", "currency": "EUR", "partners": [ { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" } ], "rows": [ { "date": null, "surface": { "type": "site", "id": "destinationtromso.com", "name": "Destination Tromsø" }, "tour": { "id": "3a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "name": "Northern Lights Chase" }, "operator": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "basis": "own_tour", "funnel": { "views": 120, "availability_checks": 48, "checkout_opens": 20, "checkout_attempts": 9 }, "bookings": 6, "participants": null, "revenue_minor": 74400, "conversion": { "view_to_check": 0.4, "check_to_open": 0.4167, "open_to_attempt": 0.45, "attempt_to_booking": 0.6667, "view_to_booking": 0.05 } }, { "date": null, "surface": { "type": "channel", "id": "getyourguide", "name": "GetYourGuide" }, "tour": { "id": "3a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "name": "Northern Lights Chase" }, "operator": { "id": "2b7e1516-28ae-4d2a-9f15-88094f3c4f3c", "name": "Arctic Tours" }, "basis": "own_tour", "funnel": null, "bookings": 14, "participants": 31, "revenue_minor": 190500, "conversion": null } ], "surfaces": [ { "surface": { "type": "channel", "id": "getyourguide", "name": "GetYourGuide" }, "funnel": null, "bookings": 14, "participants": 31, "revenue_minor": 190500, "revenue_share": 0.7192, "conversion": null }, { "surface": { "type": "site", "id": "destinationtromso.com", "name": "Destination Tromsø" }, "funnel": { "views": 120, "availability_checks": 48, "checkout_opens": 20, "checkout_attempts": 9 }, "bookings": 6, "participants": null, "revenue_minor": 74400, "revenue_share": 0.2808, "conversion": { "view_to_check": 0.4, "check_to_open": 0.4167, "open_to_attempt": 0.45, "attempt_to_booking": 0.6667, "view_to_booking": 0.05 } } ], "other_channels_available": true } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 429: Rate limited (300 requests per minute per key), or 2 analytics requests from this key are already running - 503: The data did not load in time; retry, or ask for a shorter range ### GET /api/v1/analytics/sites Traffic, sources, funnel and sales per site. Whole-site traffic for every site your operators are on: visitors, sessions, pageviews, bounce rate, a daily series and the top sources. On a site your operator owns you also get the whole-site funnel, conversions per source and every tour's sales; on a site you are only added to, sales cover your own tours. Days are UTC; money is EUR. At most 400 days per request. Scope: `analytics:read`. Query parameters: - `from` (string, YYYY-MM-DD): First UTC day (YYYY-MM-DD). - `to` (string, YYYY-MM-DD): Last UTC day, inclusive (YYYY-MM-DD). At most 400 days from `from`. - `site` (string, optional): Only this site (its domain). A site the key cannot see traffic for answers 404. - `partner_id` (string, optional): Only this operator: one of the key's operators (see GET /api/v1/me). Any other id answers 404. What you see follows that operator's sites and tours. Response fields (200): - `from` (string, YYYY-MM-DD) - `to` (string, YYYY-MM-DD) - `currency` (`EUR`) - `sites` (array) - each item: - `domain` (string) - `name` (string) - `role` (one of `owner`, `added`): `owner`: your operator owns the site, so sales cover every tour sold on it. `added`: your operator is on the site but does not own it, so sales cover your own tours only. - `sales_scope` (one of `all_tours`, `own_tours`) - `traffic` (object): The whole site, every tour and page. - `visitors` (integer) - `new_visitors` (integer) - `sessions` (integer) - `pageviews` (integer) - `bounce_rate` (number, nullable) - `avg_session_seconds` (number) - `days` (array) - each item: - `date` (string, YYYY-MM-DD) - `visitors` (integer) - `new_visitors` (integer) - `pageviews` (integer) - `bookings` (integer): Within `sales_scope`. - `revenue_minor` (integer): Integer minor units (cents) of `currency` (EUR). - `sources` (array): Top 25 per dimension, by visitors. - each item: - `dimension` (one of `channel`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`, `country`, `device`, `browser`, `os`, `path`) - `label` (string) - `visitors` (integer) - `conversions` (integer, nullable): Owners only; null otherwise. - `revenue_minor` (integer, nullable): Owners only; null otherwise. - `funnel` (array, nullable): The whole site's funnel. Owners only; null otherwise. - each item: - `step` (string) - `unit` (one of `visitors`, `bookings`) - `count` (integer) - `sales` (object): Panion sales on this site within `sales_scope`. - `bookings` (integer) - `revenue_minor` (integer): Integer minor units (cents) of `currency` (EUR). Example: One site the operator owns ```sh curl "https://www.panion.travel/api/v1/analytics/sites?from=2026-11-01&to=2026-11-01&site=destinationtromso.com" \ -H "Authorization: Bearer $PANION_API_KEY" ``` ```json { "from": "2026-11-01", "to": "2026-11-01", "currency": "EUR", "sites": [ { "domain": "destinationtromso.com", "name": "Destination Tromsø", "role": "owner", "sales_scope": "all_tours", "traffic": { "visitors": 410, "new_visitors": 352, "sessions": 468, "pageviews": 1290, "bounce_rate": 0.41, "avg_session_seconds": 96 }, "days": [ { "date": "2026-11-01", "visitors": 410, "new_visitors": 352, "pageviews": 1290, "bookings": 7, "revenue_minor": 86800 } ], "sources": [ { "dimension": "channel", "label": "Organic Search", "visitors": 220, "conversions": 4, "revenue_minor": 49600 } ], "funnel": [ { "step": "view", "unit": "visitors", "count": 300 }, { "step": "conversion", "unit": "bookings", "count": 7 } ], "sales": { "bookings": 7, "revenue_minor": 86800 } } ] } ``` Responses: - 200: OK - 400: Invalid request - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 429: Rate limited (300 requests per minute per key), or 2 analytics requests from this key are already running - 503: The data did not load in time; retry, or ask for a shorter range # Writes Writes act on real bookings. Each has its own scope, off on a new key unless ticked. There are no bulk endpoints: one booking per request. No write can create a booking or take an amount: a cancel always refunds in full through the same path as Oracle, and a reschedule never changes the price. Every write needs an `Idempotency-Key` header (missing: 400). Use a new UUID per action and reuse it only to retry that action. For 24 hours the same key with the same request replays the first response with `Idempotent-Replayed: true`; the same key with a different request answers 422; while the first request is still running a duplicate answers 409. ### POST /api/v1/manifest/{id}/checkin Check a manifest booking in, mark a no-show, or clear it. Sets the check-in state of one manifest row. `checked_in` with `present` records a partial party (3 of 4). `clear` returns the row to not seen. Only the check-in is stored: the booking, its guests and its money are never changed and your booking system is not told. Scope: `checkins:write`. Needs an `Idempotency-Key` header. Counts against the key's daily checkins limit. Path parameters: - `id` (string): Manifest row id: the `id` of a passenger in GET /api/v1/manifest. Request body (JSON): - `status` (one of `checked_in`, `no_show`, `clear`): `checked_in`, `no_show`, or `clear` (back to not seen). - `present` (integer, optional): With `checked_in` only: how many of the party turned up. Omit for everyone. Response fields (200): - `id` (string) - `check_in` (object) - `status` (one of `checked_in`, `no_show`, nullable) - `present` (integer, nullable) Example: Three of four turned up ```sh curl -X POST "https://www.panion.travel/api/v1/manifest/5c4b3a29-1807-4f6e-9d5c-4b3a29180716/checkin" \ -H "Authorization: Bearer $PANION_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"status":"checked_in","present":3}' ``` ```json { "id": "5c4b3a29-1807-4f6e-9d5c-4b3a29180716", "check_in": { "status": "checked_in", "present": 3 } } ``` Responses: - 200: Done - 400: Invalid body, or the Idempotency-Key header is missing - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 409: A request with this Idempotency-Key is still running - 422: Not possible for this booking, or the Idempotency-Key was used for a different request - 429: Rate limited, or the key's daily limit for this action is reached (checkins). Retry-After says when to try again. - 503: A system this action depends on did not answer; retry with the same key ### POST /api/v1/bookings/{id}/payment-link Email the guest a link to pay the balance still owed. Sends a balance payment link for an existing booking: the pay-later balance of a Panion booking, or the balance your booking system says is due on a direct booking. Panion decides the amount; the request takes none. A cancelled booking (which would need a rebook link, and so a new booking) and anything with nothing owed are refused with 422. Calling it again resends the same open link. Scope: `payment_links:write`. Needs an `Idempotency-Key` header. Counts against the key's daily payment_links limit. Path parameters: - `id` (string): A booking id from GET /api/v1/bookings, or a manifest row id from GET /api/v1/manifest for a booking that exists only in your booking system. Both are UUIDs and never collide. Request body (JSON): - (no fields) Response fields (200): - `id` (string): The id you sent. - `kind` (one of `deferred_balance`, `provider_balance`): `deferred_balance`: the balance of a Panion pay-later booking. `provider_balance`: the balance your booking system says is due. - `url` (string): The payment page. Safe to share with the guest only. - `amount_minor` (integer) - `currency` (string) - `emailed` (boolean): False when the link exists but the email did not go out; share the url another way. Example: The pay-later balance ```sh curl -X POST "https://www.panion.travel/api/v1/bookings/8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f/payment-link" \ -H "Authorization: Bearer $PANION_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` ```json { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "kind": "deferred_balance", "url": "https://www.panion.travel/pay/abc123", "amount_minor": 179900, "currency": "NOK", "emailed": true } ``` Responses: - 200: Done - 400: Invalid body, or the Idempotency-Key header is missing - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 409: A request with this Idempotency-Key is still running - 422: Not possible for this booking, or the Idempotency-Key was used for a different request - 429: Rate limited, or the key's daily limit for this action is reached (payment_links). Retry-After says when to try again. - 503: A system this action depends on did not answer; retry with the same key ### POST /api/v1/bookings/{id}/cancel Cancel a booking with a full refund. Cancels a live (CONFIRMED or RESERVED) Panion booking exactly as Cancel and refund in Oracle: the seats are released in your booking system first, then the guest gets a full refund (the reservation fee on an unpaid pay-later booking), the booking is marked cancelled, the commission is voided, and the guest and your team are emailed. No free-cancellation window or departure check applies: your call is the refund decision. The request takes no amount. If releasing the seats fails nothing changes (503, retry with the same key). A booking that is already cancelled answers 200 with `already_cancelled` and does not count against the daily limit. Cannot be undone. Scope: `bookings.cancel:write`. Needs an `Idempotency-Key` header. Counts against the key's daily cancels limit. Path parameters: - `id` (string): Booking id (UUID) from GET /api/v1/bookings. Request body (JSON): - `reason` (string): Why the booking is cancelled. Kept on the booking for your team. Response fields (200): - `id` (string): The booking id. - `status` (one of `CONFIRMED`, `CANCELLED`, `REFUNDED`): The booking's status now. `REFUNDED` once the money is back with the guest, `CANCELLED` when nothing needed returning or a void is still settling, `CONFIRMED` only with `refund.outcome` `pending`. - `already_cancelled` (boolean): True when the booking was already cancelled before this call. Nothing changed. - `refund` (object) - `outcome` (one of `refunded`, `none`, `pending`): `refunded`: the payment provider confirmed the money is returned. `none`: nothing was returned by this call (nothing was paid, or it was already cancelled). `pending`: the seats were released but the refund did not complete; Panion has been alerted and completes it by hand. - `amount_minor` (integer): Refunded amount in minor units. - `currency` (string) Example: Cancel with a full refund ```sh curl -X POST "https://www.panion.travel/api/v1/bookings/8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f/cancel" \ -H "Authorization: Bearer $PANION_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"reason":"Storm warning, the boat cannot sail"}' ``` ```json { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "status": "REFUNDED", "already_cancelled": false, "refund": { "outcome": "refunded", "amount_minor": 179900, "currency": "NOK" } } ``` Responses: - 200: Done - 400: Invalid body, or the Idempotency-Key header is missing - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 409: A request with this Idempotency-Key is still running - 422: Not possible for this booking, or the Idempotency-Key was used for a different request - 429: Rate limited, or the key's daily limit for this action is reached (cancels). Retry-After says when to try again. - 503: A system this action depends on did not answer; retry with the same key ### POST /api/v1/bookings/{id}/reschedule Move a booking to another date or departure. Moves a live Panion booking in your booking system and on Panion, at the same price: the payment is never touched, so a departure with a different price is refused (422). The guest's free-change window does not apply to you, but a tour that has started cannot be moved, the new date must be available, and a pay-later booking still owing its balance cannot move too close to the charge deadline. Pick a departure with GET /api/v1/bookings/{id}/reschedule-options. The guest is emailed that you moved the booking and your team gets the usual reschedule email. A booking already on that date answers 200 with `already_on_date` and does not count against the daily limit. Scope: `bookings.reschedule:write`. Needs an `Idempotency-Key` header. Counts against the key's daily reschedules limit. Path parameters: - `id` (string): Booking id (UUID) from GET /api/v1/bookings. Request body (JSON): - `date` (string, YYYY-MM-DD): The new departure day (YYYY-MM-DD). - `start_time_id` (string, optional): `start_time_id` of an option from GET /api/v1/bookings/{id}/reschedule-options. Omit to keep the booking's time of day. - `rate_id` (string, optional): `rate_id` of the same option, when it has one. Response fields (200): - `id` (string): The booking id. - `status` (string) - `date` (string): The booking's departure day now. - `start_time` (string, nullable): Its start time now, when known. - `previous_date` (string, nullable) - `previous_start_time` (string, nullable) - `already_on_date` (boolean): True when the booking was already on that date. Nothing changed. Example: Move to another evening ```sh curl -X POST "https://www.panion.travel/api/v1/bookings/8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f/reschedule" \ -H "Authorization: Bearer $PANION_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"date":"2026-12-14","start_time_id":"4821","rate_id":"1093"}' ``` ```json { "id": "8f6d2c1e-4b7a-4e3f-9c2d-1a5b6c7d8e9f", "status": "CONFIRMED", "date": "2026-12-14", "start_time": "18:00", "previous_date": "2026-12-12", "previous_start_time": "18:00", "already_on_date": false } ``` Responses: - 200: Done - 400: Invalid body, or the Idempotency-Key header is missing - 401: Missing, invalid, expired or revoked key - 403: The key lacks the scope - 404: Not found, or not yours - 409: A request with this Idempotency-Key is still running - 422: Not possible for this booking, or the Idempotency-Key was used for a different request - 429: Rate limited, or the key's daily limit for this action is reached (reschedules). Retry-After says when to try again. - 503: A system this action depends on did not answer; retry with the same key # Errors Errors are RFC 9457 problem details (`application/problem+json`) with `type`, `title`, `status`, an optional `detail` and a `request_id`. A booking that is not yours answers 404, never 403. - 401 `https://www.panion.travel/api/v1/docs#problem-unauthorized`: Missing or invalid API key. - 403 `https://www.panion.travel/api/v1/docs#problem-forbidden`: This key does not have the scope this endpoint needs. - 404 `https://www.panion.travel/api/v1/docs#problem-not_found`: Not found. - 400 `https://www.panion.travel/api/v1/docs#problem-invalid_request`: Invalid request. - 429 `https://www.panion.travel/api/v1/docs#problem-rate_limited`: Too many requests. - 400 `https://www.panion.travel/api/v1/docs#problem-idempotency_key_required`: This request needs an Idempotency-Key header. - 422 `https://www.panion.travel/api/v1/docs#problem-idempotency_key_reused`: This Idempotency-Key was used for a different request. - 409 `https://www.panion.travel/api/v1/docs#problem-idempotency_in_progress`: A request with this Idempotency-Key is still running. - 429 `https://www.panion.travel/api/v1/docs#problem-daily_limit`: Daily limit reached for this key. - 422 `https://www.panion.travel/api/v1/docs#problem-not_allowed`: This action is not possible for this booking. - 503 `https://www.panion.travel/api/v1/docs#problem-unavailable`: A system this action depends on did not answer. - 500 `https://www.panion.travel/api/v1/docs#problem-internal`: Something went wrong on our side. # Limits - Rate limit: 300 requests per minute per key. Past it: 429 with `Retry-After` and `RateLimit-*` headers. - Daily write limits per key and UTC day (defaults: 2000 check-ins, 50 payment links, 20 cancels, 20 reschedules). Past a limit: 429 with `Retry-After`. Refused actions do not count. - Idempotency keys are kept 24 hours. - Freshness: the manifest reads a mirror of your booking system, refreshed nightly and every two hours for near-term departures. - Analytics: at most 400 days per request and 2 analytics requests at a time per key. The rollups behind them refresh every five minutes. - Deprecations: a deprecated endpoint answers with `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers and keeps working until the sunset date. The changelog announces it first. # Changelog Newest first. Also at https://www.panion.travel/api/v1/changelog. - 2026-10-10 [added] MCP server at https://mcp.panion.travel for AI assistants (Claude, ChatGPT, Cursor, VS Code). It signs in with your Panion account (OAuth), you pick the operators and permissions on Panion's consent page, and its read tools are generated from these same contracts: me, bookings, one booking, manifest, sales and analytics. Calls count against the same rate limits and show in the same request log. - 2026-10-10 [added] Analytics: GET /api/v1/analytics/tours (your tours' funnel, bookings, revenue and conversion per site, plus bookings and revenue per other sales channel such as GetYourGuide and Viator, per UTC day) and GET /api/v1/analytics/sites (traffic, sources, funnel and sales per site), with the new analytics:read scope. Site owners also see every tour sold on their site. At most 400 days per request and 2 analytics requests at a time per key. - 2026-10-10 [added] Booking changes: POST /api/v1/bookings/{id}/cancel (bookings.cancel:write, full refund through the same path as Cancel and refund in Oracle), POST /api/v1/bookings/{id}/reschedule and GET /api/v1/bookings/{id}/reschedule-options (bookings.reschedule:write, same price only). Both writes need an Idempotency-Key and have daily limits (20 cancels and 20 reschedules per key per UTC day by default). There is no refund endpoint. - 2026-10-09 [added] Agent-readable docs generated from the contracts: /api/v1/llms-full.txt, Markdown sections at /api/v1/docs/
.md, Markdown from /api/v1/docs with Accept: text/markdown, this changelog at /api/v1/changelog, worked examples in the OpenAPI document, and Deprecation and Sunset headers for deprecated endpoints. - 2026-10-09 [changed] One key can cover several operators. GET /api/v1/me lists them in `partners`; every booking, manifest passenger and sales row carries its `partner`; reads take an optional `partner_id` (404 outside the key's operators). Oracle shows each key's usage. - 2026-10-09 [added] Writes: POST /api/v1/manifest/{id}/checkin and POST /api/v1/bookings/{id}/payment-link (balance links only), each with its own write scope, a required Idempotency-Key header and per-key daily limits. - 2026-10-09 [added] Read API: GET /api/v1/me, /bookings, /bookings/{id}, /manifest and /sales with scoped keys, rate limits, the OpenAPI document, the reference page and llms.txt.