{
  "openapi": "3.1.0",
  "info": {
    "title": "Alps2Alps Public Transfer API",
    "version": "1.4.0",
    "description": "Public B2C API for AI agents and external tools. Allows discovering Alpine transfer locations, fetching real-time prices, and generating a pre-filled checkout link that lands the user directly on booking step 3 (Your Details).\n\n**No authentication required.** Rate limited by IP address (see `x-rate-limit` notes per endpoint).\n\nAll prices are calculated in real-time using the same engine as the booking website. Prices in non-EUR currencies include an additional disclaimer, as live exchange rates are applied at the time of booking.\n\n## Deep links (GET-only flow)\n\nFor lightweight chat agents and link-sharing contexts that cannot issue POST requests, two browser-facing deep links are available alongside this versioned JSON API. Both are stateless (no DB row, no expiry) and use the same friendly-alias query string as `GET /transfer-options`:\n\n- `https://booking.alps2alps.com/book/quick-search?from=…&to=…&date=…&time=…` — lands the user on step 2 (vehicle picker) with prices already calculated.\n- `https://booking.alps2alps.com/book/quick-checkout?from=…&to=…&date=…&time=…&vehicle=…` — lands the user on step 3 (Your Details) with the chosen vehicle pre-picked.\n\nThese endpoints redirect to the booking funnel — they do not return JSON, which is why they are not modelled as OpenAPI paths. The full alias table is documented in the `friendly_aliases` capability of `/.well-known/agent-skills/index.json`. Use `POST /checkout-link` when you need accommodation pre-fill, flight numbers, free-text driver notes, or a 24h-expiring link record."
  },
  "servers": [
    {
      "url": "https://booking.alps2alps.com/api/public/v1",
      "description": "Production"
    },
    {
      "url": "https://testbooking.alps2alps.com/api/public/v1",
      "description": "Staging / sandbox"
    }
  ],
  "paths": {
    "/locations/search": {
      "get": {
        "operationId": "searchLocations",
        "summary": "Search pickup and drop-off locations",
        "description": "Returns a list of Alps2Alps supported locations (resorts and transport stations) matching the query string. Use the returned `code` values as `route_from_code` / `route_to_code` in subsequent calls.\n\nReturns an empty array when `q` is shorter than 2 characters.",
        "x-rate-limit": "30 requests/minute per IP",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search term. Minimum 2 characters to receive results.",
            "schema": { "type": "string", "minLength": 1, "example": "chamonix" }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of matching locations (may be empty).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/LocationResult" }
                },
                "example": [
                  { "code": "resort-11",  "name": "Chamonix",       "type": "resort",  "country": "France" },
                  { "code": "airport-1",  "name": "Geneva Airport",  "type": "airport", "country": "Switzerland" },
                  { "code": "city-48",    "name": "Geneva City",     "type": "city",    "country": "Switzerland" }
                ]
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/accommodations": {
      "get": {
        "operationId": "listAccommodations",
        "summary": "List verified accommodations near a resort",
        "description": "Typed lookup of entries from the Alps2Alps accommodation catalogue within 10 km of the specified resort. Use `resort_id` from a `resort-{id}` location code and a required `q` term (min 2 chars) naming the hotel. Pass the chosen `id` into `/checkout-link` as `accommodation_id` to pre-fill the hotel field.\n\nThe bare resort-only listing is intentionally not exposed (the catalogue is not enumerable). Addresses come from the internal verified `transfer_accommodation` catalogue that powers the booking funnel's step-3 autocomplete. Free-typed hotel names/addresses are not accepted — only verified catalogue ids are allowed for pre-fill.",
        "x-rate-limit": "30 requests/minute per IP",
        "parameters": [
          {
            "name": "resort_id",
            "in": "query",
            "required": true,
            "description": "Numeric resort id from a `resort-{id}` location code.",
            "schema": { "type": "integer", "example": 11 }
          },
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Required typed-lookup term: substring of the accommodation name (accent-insensitive). Minimum 2 characters, max 100. The bare resort-only listing is not exposed, so name the hotel to resolve it.",
            "schema": { "type": "string", "minLength": 2, "maxLength": 100, "example": "clos" }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum results to return. Default 20, max 50.",
            "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 50 }
          }
        ],
        "responses": {
          "200": {
            "description": "`data` array of accommodations plus `total` count before limit was applied.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AccommodationsResponse" },
                "example": {
                  "data": [
                    {
                      "id": 38959,
                      "name": "Clos 66",
                      "address": "66 Clos des Charmilles, Chamonix",
                      "latitude": 45.9123,
                      "longitude": 6.8694
                    }
                  ],
                  "total": 1
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "404": { "$ref": "#/components/responses/ResortNotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/transfer-options": {
      "get": {
        "operationId": "getTransferOptionsByQuery",
        "summary": "Get real-time transfer prices (GET, friendly aliases)",
        "description": "Same response envelope as the POST flavour, but consumes a friendly-alias query string instead of a JSON body. Designed for AI agents that can only construct URLs and for in-link quote previews.\n\nMinimum viable URL: `?from=…&to=…&date=…&time=…`. Sensible defaults are applied for the rest (`adults=2`, `luggage=2`, `is_flight=1`, `currency=EUR`, all child/infant/ski counts `0`).\n\nAn unknown or inactive `currency` returns `400 CURRENCY_NOT_SUPPORTED` (this strict behaviour applies to both GET and POST `/transfer-options`; only the browser deep links `/book/quick-search` and `/book/quick-checkout` silently fall back to EUR).\n\n**Return trips:** adding any `return_*` parameter (or `return=1`) flips the trip to a return booking. Omitted return_* fields automatically mirror the matching outbound field (e.g. `adults=2` without `return_adults` → 2 return adults). Explicit return_* values always win.\n\nUnknown query keys (e.g. `utm_*`) are silently ignored.",
        "x-rate-limit": "20 requests/minute per IP",
        "parameters": [
          { "name": "from", "in": "query", "required": true,  "schema": { "type": "string", "example": "airport-1" }, "description": "Pickup location code from `/locations/search`." },
          { "name": "to",   "in": "query", "required": true,  "schema": { "type": "string", "example": "resort-11" }, "description": "Drop-off location code." },
          { "name": "date", "in": "query", "required": true,  "schema": { "type": "string", "format": "date", "example": "2026-07-22" }, "description": "Outbound date `YYYY-MM-DD`." },
          { "name": "time", "in": "query", "required": true,  "schema": { "type": "string", "example": "14:30" }, "description": "Outbound pickup or flight arrival time. Accepts `HH:MM` (also `HHMM` or `HH`)." },
          { "name": "is_flight",   "in": "query", "required": false, "schema": { "type": "integer", "enum": [0, 1], "default": 1 }, "description": "`1` (default) treats `time` as a flight arrival time so the funnel adjusts pickup." },
          { "name": "adults",      "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "default": 2 } },
          { "name": "children",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "child_ages",  "in": "query", "required": false, "schema": { "type": "string", "example": "8,12" }, "description": "Comma-separated ages of children. Informational only — not used for pricing or vehicle selection. Echoed in `passengers.child_ages` and preserved in generated URLs." },
          { "name": "infants",     "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "child_seats", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 }, "description": "Number of children needing a full child seat." },
          { "name": "boosters",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 }, "description": "Number of children needing a booster seat." },
          { "name": "luggage",     "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 2 } },
          { "name": "ski_bags",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "ski",         "in": "query", "required": false, "schema": { "type": "integer", "enum": [0, 1], "default": 0 }, "description": "Carrying ski equipment." },
          { "name": "currency",    "in": "query", "required": false, "schema": { "type": "string", "default": "EUR", "example": "GBP" }, "description": "ISO 4217 code. Must be active in the system." },
          { "name": "promo",       "in": "query", "required": false, "schema": { "type": "string", "example": "AMI10" }, "description": "Promo code. Silently ignored if invalid / promo service unreachable." },
          { "name": "return",          "in": "query", "required": false, "schema": { "type": "integer", "enum": [0, 1] }, "description": "Set `1` to force return-trip mode. Also implicitly enabled by passing any `return_*` field." },
          { "name": "return_to",       "in": "query", "required": false, "schema": { "type": "string" }, "description": "Return drop-off location code. Defaults to outbound `from` (typical airport return)." },
          { "name": "return_date",     "in": "query", "required": false, "schema": { "type": "string", "format": "date" }, "description": "Return date `YYYY-MM-DD`. Presence of this field alone enables return-trip mode." },
          { "name": "return_time",     "in": "query", "required": false, "schema": { "type": "string", "example": "10:00" }, "description": "Return time `HH:MM`. When `return_is_flight=1` (default), treated as the flight **departure** time; the system calculates the resort pickup time automatically. `return.pick_up_date_time` in the response shows the calculated pickup time." },
          { "name": "return_is_flight","in": "query", "required": false, "schema": { "type": "integer", "enum": [0, 1] }, "description": "`1` (default, mirrors outbound) = `return_time` is a flight departure time; pickup is auto-calculated. `0` = `return_time` is the literal resort pickup time." },
          { "name": "return_adults",      "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Defaults to outbound `adults`." },
          { "name": "return_children",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `children`." },
          { "name": "return_child_ages",  "in": "query", "required": false, "schema": { "type": "string", "example": "8,12" }, "description": "Comma-separated child ages for the return leg. Informational pass-through only. Preserved in generated URLs." },
          { "name": "return_infants",     "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `infants`." },
          { "name": "return_child_seats", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `child_seats`." },
          { "name": "return_boosters",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `boosters`." },
          { "name": "return_luggage",     "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `luggage`." },
          { "name": "return_ski_bags",    "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0 }, "description": "Defaults to outbound `ski_bags`." },
          { "name": "return_ski",         "in": "query", "required": false, "schema": { "type": "integer", "enum": [0, 1] }, "description": "Defaults to outbound `ski`." }
        ],
        "responses": {
          "200": {
            "description": "Real-time quotes for all bookable vehicles. Same envelope as POST, with two GET-only additions: `quote_url` (top-level, for step-2 landing) and `booking_url` per vehicle (for step-3 landing with vehicle pre-selected).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TransferOptionsResponse" },
                "example": {
                  "disclaimer": "Prices shown are real-time and calculated using the same engine as the booking website.",
                  "quote_url": "https://booking.alps2alps.com/book/quick-search?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2",
                  "route": {
                    "from": "Geneva Airport",
                    "from_code": "airport-1",
                    "to": "Chamonix",
                    "to_code": "resort-11",
                    "distance_km": 99,
                    "duration_minutes": 100,
                    "travel_time": "01:40:00"
                  },
                  "currency": "EUR",
                  "passengers": { "adults": 2, "children": 0, "child_ages": null, "infants": 0, "child_seats": 0, "boosters": 0 },
                  "outbound": {
                    "pick_up_date_time": "2026-07-22 14:30:00",
                    "luggage": { "normal_suitcases": 2, "ski_equipment": 0 },
                    "vehicles": [
                      { "vehicle_type_id": 2,  "name": "Standard/economy", "price": 175,    "max_passengers": 3, "max_luggage": 3, "offer_code": "requested", "total_price": null, "booking_url": "https://booking.alps2alps.com/book/quick-checkout?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2&vehicle=2" },
                      { "vehicle_type_id": 10, "name": "Standard minivan",  "price": 247,    "max_passengers": 8, "max_luggage": 8, "offer_code": "requested", "total_price": null, "booking_url": "https://booking.alps2alps.com/book/quick-checkout?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2&vehicle=10" },
                      { "vehicle_type_id": 4,  "name": "VIP",               "price": 418.50, "max_passengers": 7, "max_luggage": 7, "offer_code": "requested", "total_price": null, "booking_url": "https://booking.alps2alps.com/book/quick-checkout?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2&vehicle=4" }
                    ]
                  },
                  "return": null,
                  "promo_code_applied": false
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "404": { "$ref": "#/components/responses/RouteNotFound" },
          "422": { "$ref": "#/components/responses/UnprocessableRoute" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "post": {
        "operationId": "getTransferOptions",
        "summary": "Get real-time transfer prices",
        "description": "Returns real-time prices for every bookable vehicle type on the requested route, for the requested date/time, passenger configuration, and currency. Optionally applies a promo code.\n\nThis endpoint never persists anything — use it to show the user options before calling `/checkout-link`.",
        "x-rate-limit": "20 requests/minute per IP",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TransferRequest" },
              "example": {
                "route_from_code": "airport-1",
                "route_to_code": "resort-11",
                "outbound_date": "2026-07-22",
                "outbound_time_hours": 14,
                "outbound_time_minutes": 30,
                "outbound_selected_time_is_flight": 1,
                "is_return": 1,
                "return_date": "2026-07-26",
                "return_time_hours": 15,
                "return_time_minutes": 0,
                "adult_count": 2,
                "children_count": 1,
                "children_age_seat_count": 1,
                "luggage_count": 2,
                "currency_code": "EUR"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Real-time quotes for all bookable vehicles.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TransferOptionsResponse" },
                "example": {
                  "disclaimer": "Prices shown are real-time and calculated using the same engine as the booking website.",
                  "quote_url": null,
                  "route": {
                    "from": "Geneva Airport",
                    "from_code": "airport-1",
                    "to": "Chamonix",
                    "to_code": "resort-11",
                    "distance_km": 99,
                    "duration_minutes": 100,
                    "travel_time": "01:40:00"
                  },
                  "currency": "EUR",
                  "passengers": { "adults": 2, "children": 1, "child_ages": null, "infants": 0, "child_seats": 1, "boosters": 0 },
                  "outbound": {
                    "pick_up_date_time": "2026-07-22 14:30:00",
                    "luggage": { "normal_suitcases": 2, "ski_equipment": 0 },
                    "vehicles": [
                      { "vehicle_type_id": 2,  "name": "Standard/economy", "price": 175,    "max_passengers": 3, "max_luggage": 3, "offer_code": "requested", "total_price": 350.00,   "booking_url": null },
                      { "vehicle_type_id": 10, "name": "Standard minivan",  "price": 247,    "max_passengers": 8, "max_luggage": 8, "offer_code": "requested", "total_price": null,     "booking_url": null },
                      { "vehicle_type_id": 4,  "name": "VIP",               "price": 418.50, "max_passengers": 7, "max_luggage": 7, "offer_code": "requested", "total_price": 837.00,   "booking_url": null }
                    ]
                  },
                  "return": {
                    "pick_up_date_time": "2026-07-26 15:00:00",
                    "luggage": { "normal_suitcases": 2, "ski_equipment": 0 },
                    "vehicles": [
                      { "vehicle_type_id": 2,  "name": "Standard/economy", "price": 175,    "max_passengers": 3, "max_luggage": 3, "offer_code": "requested", "total_price": 350.00,   "booking_url": null },
                      { "vehicle_type_id": 4,  "name": "VIP",               "price": 418.50, "max_passengers": 7, "max_luggage": 7, "offer_code": "requested", "total_price": 837.00,   "booking_url": null }
                    ]
                  },
                  "promo_code_applied": false
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "404": { "$ref": "#/components/responses/RouteNotFound" },
          "422": { "$ref": "#/components/responses/UnprocessableRoute" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/checkout-link": {
      "post": {
        "operationId": "createCheckoutLink",
        "summary": "Generate a pre-filled checkout link",
        "description": "Accepts the same body as `/transfer-options` plus optional pre-fill fields (vehicle type, flight number, accommodation, free-text driver notes). Validates the trip end-to-end, persists a link record, and returns a tokenised URL.\n\nOpening the returned `checkout_url` clears any existing booking session and lands the user directly on **step 3 (Your Details)** of the booking funnel with all supplied fields pre-filled. The link is valid for 24 hours.\n\n**Flight times** are NOT a separate input. When `outbound_selected_time_is_flight = 1`, the funnel derives the flight time automatically from `outbound_time_hours` / `outbound_time_minutes`. Only the flight *number* is a separate field.",
        "x-rate-limit": "20 requests/minute per IP",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CheckoutLinkRequest" },
              "example": {
                "route_from_code": "airport-1",
                "route_to_code": "resort-11",
                "outbound_date": "2026-07-22",
                "outbound_time_hours": 14,
                "outbound_time_minutes": 30,
                "outbound_selected_time_is_flight": 1,
                "is_return": 1,
                "return_date": "2026-07-26",
                "return_time_hours": 15,
                "return_time_minutes": 0,
                "adult_count": 2,
                "children_count": 1,
                "children_age_seat_count": 1,
                "luggage_count": 2,
                "currency_code": "EUR",
                "outbound_vehicle_type_id": 4,
                "return_vehicle_type_id": 4,
                "outbound_flight_number": "BA123",
                "return_flight_number": "BA456",
                "accommodation_id": 38959,
                "additional_info": "Please call on arrival.",
                "return_additional_info": "Early pickup preferred."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout link generated successfully.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CheckoutLinkResponse" },
                "example": {
                  "checkout_url": "https://booking.alps2alps.com/book/public-checkout/8f1c2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
                  "expires_at": "2026-07-23 14:30:00",
                  "currency": "EUR",
                  "promo_code_applied": false
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "404": { "$ref": "#/components/responses/RouteNotFound" },
          "422": { "$ref": "#/components/responses/UnprocessableRoute" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "LocationResult": {
        "type": "object",
        "required": ["code", "name", "type"],
        "properties": {
          "code": {
            "type": "string",
            "description": "Unique location code. Format: `resort-{id}`, `airport-{id}`, `train_station-{id}`, or `city-{id}`.",
            "example": "resort-11"
          },
          "name": {
            "type": "string",
            "description": "Human-readable location name.",
            "example": "Chamonix"
          },
          "type": {
            "type": "string",
            "enum": ["resort", "airport", "train_station", "city"],
            "description": "Location type.",
            "example": "resort"
          },
          "country": {
            "type": ["string", "null"],
            "description": "Country name.",
            "example": "France"
          }
        }
      },
      "AccommodationItem": {
        "type": "object",
        "required": ["id", "name", "address"],
        "properties": {
          "id": {
            "type": "integer",
            "description": "Unique accommodation id. Pass this as `accommodation_id` in `/checkout-link`.",
            "example": 38959
          },
          "name": { "type": "string", "example": "Clos 66" },
          "address": { "type": "string", "example": "66 Clos des Charmilles, Chamonix" },
          "latitude":  { "type": ["number", "null"], "format": "float", "example": 45.9123 },
          "longitude": { "type": ["number", "null"], "format": "float", "example": 6.8694 }
        }
      },
      "AccommodationsResponse": {
        "type": "object",
        "required": ["data", "total"],
        "properties": {
          "data": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/AccommodationItem" }
          },
          "total": {
            "type": "integer",
            "description": "Total matches before `limit` was applied. Use to detect truncation.",
            "example": 4
          }
        }
      },
      "VehicleOption": {
        "type": "object",
        "required": ["vehicle_type_id", "name", "price", "offer_code"],
        "properties": {
          "vehicle_type_id": {
            "type": "integer",
            "description": "Use this value as `outbound_vehicle_type_id` / `return_vehicle_type_id` in `/checkout-link`, or as `&vehicle=` in the GET deep links.",
            "example": 4
          },
          "name":  { "type": "string", "example": "VIP" },
          "price": { "type": "number", "format": "float", "description": "Final price in the requested currency.", "example": 418.50 },
          "max_passengers": {
            "type": ["integer", "null"],
            "description": "Maximum passengers this vehicle can carry (engine capacity used when matching vehicles to a group size). `null` if not configured for this vehicle type.",
            "example": 7
          },
          "max_luggage": {
            "type": ["integer", "null"],
            "description": "Maximum suitcases this vehicle can carry. `null` if not configured.",
            "example": 7
          },
          "offer_code": { "type": "string", "description": "Always `requested` for this API.", "example": "requested" },
          "total_price": {
            "type": ["number", "null"],
            "format": "float",
            "description": "Return trips only. Combined price for this vehicle type across both legs (outbound + return), rounded to 2 dp. `null` on one-way trips or if this vehicle type is not available on the other leg.",
            "example": 473.81
          },
          "booking_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "Direct checkout URL pre-filled with the current search params and this vehicle pre-selected. Lands the user on step 3 (Your Details). Present only on GET requests — `null` on POST. Pass this URL directly to the user after they choose a vehicle.",
            "example": "https://booking.alps2alps.com/book/quick-checkout?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2&vehicle=4"
          }
        }
      },
      "LuggageInfo": {
        "type": "object",
        "description": "Luggage counts applied when pricing this direction.",
        "properties": {
          "normal_suitcases": { "type": "integer", "description": "Number of standard suitcases.", "example": 2 },
          "ski_equipment":    { "type": "integer", "enum": [0, 1], "description": "`1` if ski equipment was requested for this leg.", "example": 0 }
        }
      },
      "PassengersInfo": {
        "type": "object",
        "description": "Passenger configuration echoed from the request, after defaults have been applied.",
        "properties": {
          "adults":      { "type": "integer", "example": 2 },
          "children":    { "type": "integer", "example": 0 },
          "child_ages":  { "type": ["array", "null"], "items": { "type": "integer" }, "description": "Ages of children as provided in the `child_ages` GET parameter. Informational only — not used for pricing. `null` when not supplied or on POST requests.", "example": [8, 12] },
          "infants":     { "type": "integer", "example": 0 },
          "child_seats": { "type": "integer", "description": "Children requiring a full child seat.", "example": 0 },
          "boosters":    { "type": "integer", "description": "Children requiring a booster seat.", "example": 0 }
        }
      },
      "DirectionBlock": {
        "type": "object",
        "required": ["pick_up_date_time", "vehicles"],
        "properties": {
          "pick_up_date_time": {
            "type": ["string", "null"],
            "description": "Confirmed pickup date/time in `YYYY-MM-DD HH:MM:SS` format.",
            "example": "2026-07-22 14:30:00"
          },
          "luggage": { "$ref": "#/components/schemas/LuggageInfo" },
          "vehicles": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/VehicleOption" }
          }
        }
      },
      "RouteInfo": {
        "type": "object",
        "properties": {
          "from":      { "type": ["string", "null"], "description": "Human-readable pickup location name.", "example": "Geneva Airport" },
          "from_code": { "type": ["string", "null"], "description": "Location code used as pickup (mirrors the `from` request parameter).", "example": "airport-1" },
          "to":        { "type": ["string", "null"], "description": "Human-readable drop-off location name.", "example": "Chamonix" },
          "to_code":   { "type": ["string", "null"], "description": "Location code used as drop-off (mirrors the `to` request parameter).", "example": "resort-11" },
          "distance_km":      { "type": ["integer", "null"], "example": 99 },
          "duration_minutes": { "type": ["integer", "null"], "example": 100 },
          "travel_time": {
            "type": ["string", "null"],
            "description": "Formatted travel time `HH:MM:SS`. Constant per route — not affected by vehicle type.",
            "example": "01:40:00"
          }
        }
      },
      "PromoDiscount": {
        "type": "object",
        "properties": {
          "type":  { "type": ["string", "null"], "example": "percent" },
          "value": { "type": ["number", "null"], "example": 5 }
        }
      },
      "TransferRequest": {
        "type": "object",
        "required": ["route_from_code", "route_to_code", "outbound_date", "outbound_time_hours", "outbound_time_minutes", "adult_count"],
        "properties": {
          "route_from_code":                   { "type": "string",  "description": "Pickup location code from `/locations/search`.", "example": "airport-1" },
          "route_to_code":                     { "type": "string",  "description": "Drop-off location code.", "example": "resort-11" },
          "outbound_date":                     { "type": "string",  "format": "date", "description": "Outbound date `YYYY-MM-DD`.", "example": "2026-07-22" },
          "outbound_time_hours":               { "type": "integer", "minimum": 0, "maximum": 23, "example": 14 },
          "outbound_time_minutes":             { "type": "integer", "minimum": 0, "maximum": 59, "example": 30 },
          "outbound_selected_time_is_flight":  { "type": "integer", "enum": [0, 1], "description": "Set to `1` when the time provided is a flight arrival time (the funnel adjusts pickup time accordingly).", "example": 1 },
          "is_return":                         { "type": "integer", "enum": [0, 1], "default": 0, "description": "Set to `1` for a return transfer. All `return_*` fields then apply." },
          "return_route_to_code":              { "type": "string",  "description": "Return drop-off location. Defaults to `route_from_code` if omitted.", "example": "airport-1" },
          "return_date":                       { "type": "string",  "format": "date", "example": "2026-07-26" },
          "return_time_hours":                 { "type": "integer", "minimum": 0, "maximum": 23, "example": 15 },
          "return_time_minutes":               { "type": "integer", "minimum": 0, "maximum": 59, "example": 0 },
          "return_selected_time_is_flight":    { "type": "integer", "enum": [0, 1], "description": "Set to `1` when the return time is a flight departure time." },
          "adult_count":                       { "type": "integer", "minimum": 1, "example": 2 },
          "children_count":                    { "type": "integer", "minimum": 0, "default": 0, "description": "Total number of children (not infants).", "example": 1 },
          "infant_count":                      { "type": "integer", "minimum": 0, "default": 0, "example": 0 },
          "children_age_seat_count":           { "type": "integer", "minimum": 0, "default": 0, "description": "Number of children requiring a full child seat.", "example": 1 },
          "children_age_booster_count":        { "type": "integer", "minimum": 0, "default": 0, "description": "Number of children requiring a booster seat only.", "example": 0 },
          "return_adult_count":                { "type": "integer", "minimum": 1, "description": "Defaults to `adult_count` when omitted." },
          "return_children_count":             { "type": "integer", "minimum": 0 },
          "return_infant_count":               { "type": "integer", "minimum": 0 },
          "return_children_age_seat_count":    { "type": "integer", "minimum": 0 },
          "return_children_age_booster_count": { "type": "integer", "minimum": 0 },
          "luggage_count":                     { "type": "integer", "minimum": 0, "default": 0, "example": 2 },
          "skibag_count":                      { "type": "integer", "minimum": 0, "default": 0 },
          "has_ski_equipment":                 { "type": "integer", "enum": [0, 1], "default": 0 },
          "return_luggage_count":              { "type": "integer", "minimum": 0 },
          "return_skibag_count":               { "type": "integer", "minimum": 0 },
          "return_has_ski_equipment":          { "type": "integer", "enum": [0, 1] },
          "currency_code":                     { "type": "string",  "default": "EUR", "description": "ISO 4217 currency code. Must be active in the system.", "example": "EUR" },
          "promo_code":                        { "type": "string",  "description": "Promo / discount code. Silently ignored if invalid or if the promo service is temporarily unreachable.", "example": "AMI10" }
        }
      },
      "CheckoutLinkRequest": {
        "allOf": [
          { "$ref": "#/components/schemas/TransferRequest" },
          {
            "type": "object",
            "properties": {
              "outbound_vehicle_type_id": {
                "type": "integer",
                "description": "`vehicle_type_id` from the `/transfer-options` response. If omitted, the cheapest available vehicle is selected automatically.",
                "example": 4
              },
              "return_vehicle_type_id": {
                "type": "integer",
                "description": "Vehicle type for the return leg. Defaults to the cheapest return option, or the outbound pick if no cheaper option exists.",
                "example": 4
              },
              "outbound_flight_number": {
                "type": "string",
                "description": "Pre-fills the flight number field on step 3. Alphanumeric + dashes only, max 16 characters.",
                "example": "BA123"
              },
              "return_flight_number": {
                "type": "string",
                "description": "Return leg flight number. Only applied when `is_return = 1`.",
                "example": "BA456"
              },
              "accommodation_id": {
                "type": "integer",
                "description": "Id from `/accommodations` response. Must be within 10 km of the route's resort. Pre-fills the hotel name and address on step 3.",
                "example": 38959
              },
              "additional_info": {
                "type": "string",
                "description": "Free-text driver note for the outbound leg, pre-filled in the 'Additional Information' field on step 3. Control characters stripped. Max 1000 characters.",
                "example": "Please call on arrival."
              },
              "return_additional_info": {
                "type": "string",
                "description": "Free-text driver note for the return leg. Only applied when `is_return = 1`. Max 1000 characters.",
                "example": "Early pickup preferred."
              }
            }
          }
        ]
      },
      "TransferOptionsResponse": {
        "type": "object",
        "required": ["disclaimer", "route", "currency", "passengers", "outbound", "promo_code_applied"],
        "properties": {
          "disclaimer": { "type": "string", "description": "Always present. Extended text for non-EUR currencies to flag potential FX variation at checkout." },
          "quote_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "Pre-filled `/book/quick-search` URL landing the user on step 2 (vehicle picker) with all current search params already set. Present only on GET requests — `null` on POST. Use this when the user has not yet chosen a vehicle.",
            "example": "https://booking.alps2alps.com/book/quick-search?from=airport-1&to=resort-11&date=2026-07-22&time=14%3A30&adults=2"
          },
          "route": { "$ref": "#/components/schemas/RouteInfo" },
          "currency": { "type": "string", "example": "EUR" },
          "passengers": { "$ref": "#/components/schemas/PassengersInfo" },
          "outbound": { "$ref": "#/components/schemas/DirectionBlock" },
          "return": {
            "oneOf": [
              { "$ref": "#/components/schemas/DirectionBlock" },
              { "type": "null" }
            ],
            "description": "Present only when `is_return = 1`."
          },
          "promo_code_applied": {
            "type": "boolean",
            "description": "`true` when a valid promo code was applied and prices reflect the discount."
          },
          "promo_code": {
            "type": "string",
            "description": "Echo of the submitted promo code. Only present when a code was included in the request.",
            "example": "AMI10"
          },
          "promo_code_discount": {
            "$ref": "#/components/schemas/PromoDiscount",
            "description": "Only present when `promo_code_applied` is `true`."
          },
          "promo_code_message": {
            "type": "string",
            "description": "Human-readable explanation when a promo code was submitted but not applied. Only present when `promo_code_applied` is `false` and a code was in the request.",
            "example": "Promo code is not valid for this route, date, or group."
          }
        }
      },
      "CheckoutLinkResponse": {
        "type": "object",
        "required": ["checkout_url", "expires_at", "currency", "promo_code_applied"],
        "properties": {
          "checkout_url": {
            "type": "string",
            "format": "uri",
            "description": "One-shot URL valid for 24 hours. Opens the booking funnel step 3 with all fields pre-filled.",
            "example": "https://booking.alps2alps.com/book/public-checkout/8f1c2c3d4e5f6a7b8c9d0e1f2a3b4c5d"
          },
          "expires_at": {
            "type": "string",
            "description": "Link expiry timestamp `YYYY-MM-DD HH:MM:SS` (server time, UTC+1/+2 depending on DST).",
            "example": "2026-07-23 14:30:00"
          },
          "currency": { "type": "string", "example": "EUR" },
          "promo_code_applied": {
            "type": "boolean",
            "description": "`true` when the supplied promo code was validated and is encoded into the link."
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code":    { "type": "string", "example": "INVALID_PARAMS" },
              "message": { "type": "string", "example": "outbound_date: cannot be in the past" }
            }
          }
        }
      }
    },
    "responses": {
      "InvalidParams": {
        "description": "**400 INVALID_PARAMS** — Missing/invalid parameters, malformed date, or failed validation. Also covers **400 CURRENCY_NOT_SUPPORTED** and **400 VALIDATION_ERROR** (bad `accommodation_id`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "RouteNotFound": {
        "description": "**404 ROUTE_NOT_FOUND** — Location code combination does not map to an active route.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "ResortNotFound": {
        "description": "**404 RESORT_NOT_FOUND** — Resort id unknown, inactive, or has no geo-coordinates configured.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "UnprocessableRoute": {
        "description": "**422** — One of: **UNSUPPORTED_ROUTE** (e.g. station-to-station), **MANUAL_BOOKING_REQUIRED** (not bookable online), or **NO_BOOKABLE_VEHICLE** (no vehicle price available).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "RateLimited": {
        "description": "**429 RATE_LIMITED** — Too many requests from this IP. Back off and retry.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorEnvelope" },
            "example": { "error": { "code": "RATE_LIMITED", "message": "Too many requests. Please slow down and retry." } }
          }
        }
      },
      "InternalError": {
        "description": "**500 INTERNAL_ERROR** — Unexpected server-side error. Retrying after a short delay is safe.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      }
    }
  }
}
