openapi: 3.1.0
info:
  title: Tixxly AI API
  description: |
    Search and book tours, experiences, and activities worldwide via Tixxly's
    public HTTP API. This document is the canonical machine-readable reference
    for ChatGPT Actions, Gemini function calling, Perplexity tools, and the
    Tixxly MCP server.

    ## Authentication

    Send your vendor API key in the `x-api-key` header. Keys are created in the
    Tixxly backoffice under **AI Connections** (feed / MCP key). Without a key,
    many read endpoints still respond, but results are not vendor-scoped.
    Write/booking endpoints do not require a key for the lean reserve flow.

    Obtain a key: sign in at https://vendors.tixxly.ai → AI Connections → create
    or copy your feed/MCP API key. Contact help@tixxly.ai if you need platform access.

    ## Rate limits

    Fair-use limits apply. Sustained bursts may receive HTTP 429. Prefer
    pagination (`page` / `limit`) on the feed and cache availability for short
    windows. Contact help@tixxly.ai for higher limits on Infrastructure plans.

    ## Booking flow

    1. `searchExperiences` — browse activities (`GET /openai/feed`)
    2. `getExperienceDetail` — full product info (optional; availability also returns key data)
    3. `checkAvailability` — time slots, pricingCategories, and places
    4. `getBookingOptions` — form fields; include pickupPlaceId/dropoffPlaceId only when places[] was non-empty
    5. `createReservation` — submit answers; returns `payment_url`
    6. `getReservation` — optional status lookup after payment

    ### Places rule

    - When `places[]` is a **non-empty** array from detail or availability: **required**
      positive `pickupPlaceId` and `dropoffPlaceId` from `places[].id`.
    - When `places[]` is empty, null, or absent: **omit** both fields — never send `0` or `null`.

    ### Answers rule

    - `answers` / `pickupAnswers` are arrays of `{ questionId: string, values: string[] }`.
    - Include `answers` only when booking_options returned activity/option questions.
    - Include `pickupAnswers` only when `pickupQuestions` was non-empty.
    - `questionId` is the numeric ID string from booking_options — never the human label.

    Human-readable docs: https://tixxly.ai/api
  version: 2.3.0
  contact:
    name: Tixxly Engineering
    email: help@tixxly.ai
    url: https://tixxly.ai/api
  termsOfService: https://tixxly.ai/en/terms
  license:
    name: Proprietary
    url: https://tixxly.ai/en/terms

externalDocs:
  description: Tixxly API docs (Redoc) and agent surface
  url: https://tixxly.ai/api

servers:
  - url: https://api.tixxly.ai
    description: Production

tags:
  - name: Destinations
    description: Public destination index
  - name: Suppliers
    description: Tour and activity supplier directory
  - name: Experiences
    description: Search and product detail
  - name: Availability
    description: Real-time availability and booking form fields
  - name: Bookings
    description: Reservations and status
  - name: Vendors
    description: Public vendor storefront profiles
  - name: Analytics
    description: Optional event tracking

security:
  - ApiKeyAuth: []

paths:
  /v1/directory/destinations:
    get:
      operationId: listDestinations
      tags: [Destinations]
      summary: List destinations
      description: |
        Returns cities currently published in the directory, sorted by product
        count descending. Includes country codes and optional geo coordinates.
      security: []
      responses:
        "200":
          description: Destination list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DestinationsResponse"
              examples:
                immmersivHub:
                  summary: Sample destinations
                  value:
                    destinations:
                      - slug: marrakech
                        name: Marrakech
                        country:
                          code: ma
                          name: Morocco
                        geo:
                          latitude: 31.6295
                          longitude: -7.9811
                        productCount: 12
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/directory/suppliers:
    get:
      operationId: listSuppliers
      tags: [Suppliers]
      summary: List suppliers
      description: |
        Lists verified tour and activity suppliers with Trust Score, product
        count, and country coverage. Sorted by Trust Score desc, then product count.
      security: []
      responses:
        "200":
          description: Supplier roster
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuppliersResponse"
              examples:
                sample:
                  summary: Includes Immmersiv
                  value:
                    suppliers:
                      - username: immmersiv
                        name: Immmersiv
                        verified: true
                        productCount: 21
                        countries:
                          - code: ma
                            name: Morocco
                        cities:
                          - slug: marrakech
                            name: Marrakech
                            country:
                              code: ma
                              name: Morocco
                        trustScore:
                          score: 86
                          band: high
                        storefrontUrl: https://ai.immmersiv.eu
                        liveSince: "2025-06-01"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/directory/suppliers/{username}:
    get:
      operationId: getSupplier
      tags: [Suppliers]
      summary: Get supplier detail
      description: |
        Supplier profile including Trust Score breakdown and published products.
      security: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
          example: immmersiv
      responses:
        "200":
          description: Supplier profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SupplierDetailResponse"
              examples:
                immmersiv:
                  summary: Immmersiv profile
                  value:
                    supplier:
                      username: immmersiv
                      name: Immmersiv
                      verified: true
                      productCount: 21
                      countries:
                        - code: ma
                          name: Morocco
                      trustScore:
                        score: 86
                        band: high
                      storefrontUrl: https://ai.immmersiv.eu
                      products: []
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /openai/feed:
    get:
      operationId: searchExperiences
      tags: [Experiences]
      summary: Search travel experiences
      description: |
        Returns paginated bookable experiences. Use each product `id` as
        `experienceId` in subsequent calls. With an `x-api-key` (MCP group),
        results are scoped to that vendor.
      parameters:
        - name: query
          in: query
          description: Free-text search (e.g. "desert tour")
          schema:
            type: string
          example: desert tour
        - name: location
          in: query
          description: Country, region, or city
          schema:
            type: string
          example: Morocco
        - name: category
          in: query
          description: Category filter (adventure, sightseeing, food, culture, water, nature)
          schema:
            type: string
        - name: vendor_id
          in: query
          description: Restrict to a numeric vendor id
          schema:
            type: integer
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 200
      responses:
        "200":
          description: Product feed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedResponse"
              examples:
                morocco:
                  summary: Morocco search
                  value:
                    total: 1
                    products:
                      - id: "10042"
                        content:
                          title: Sahara Desert Day Trip from Marrakech
                          subtitle: Camel ride and berber lunch
                        taxonomy:
                          brand: Immmersiv
                        offers:
                          price: 89
                          currency: EUR
                    merchant:
                      name: Tixxly
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/experiences/{experienceId}:
    get:
      operationId: getExperienceDetail
      tags: [Experiences]
      summary: Get full experience details
      description: |
        Returns content, offers, pricingCategories, places, and vendor.
        If `places` is a non-empty array, ask the user to choose a location and
        send `places[].id` as pickupPlaceId and dropoffPlaceId in booking-options
        and reserve. If places is empty or absent, omit those fields.
      parameters:
        - name: experienceId
          in: path
          required: true
          schema:
            type: string
          example: "10042"
      responses:
        "200":
          description: Experience details with pricing and places
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExperienceDetail"
              examples:
                sample:
                  summary: Experience with places
                  value:
                    success: true
                    data:
                      id: "10042"
                      content:
                        title: Sahara Desert Day Trip from Marrakech
                      image: https://cdn.example.com/sahara.jpg
                      vendor:
                        id: 12
                        name: Immmersiv
                      pricingCategories:
                        - id: 501
                          fullTitle: Adult
                      places:
                        - id: 9001
                          name: Jemaa el-Fnaa Meeting Point
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/experiences/{experienceId}/availability:
    get:
      operationId: checkAvailability
      tags: [Availability]
      summary: Check availability for dates
      description: |
        Returns availability slots (max 10), pricingCategories, and places.
        Apply the places rule before calling booking-options or reserve.
      security: []
      parameters:
        - name: experienceId
          in: path
          required: true
          schema:
            type: string
        - name: startDate
          in: query
          required: true
          schema:
            type: string
            format: date
          example: "2026-09-15"
        - name: endDate
          in: query
          required: true
          schema:
            type: string
            format: date
          example: "2026-09-20"
      responses:
        "200":
          description: Availability slots
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvailabilityResponse"
              examples:
                sample:
                  summary: Open slots
                  value:
                    success: true
                    data:
                      availabilities:
                        - startTimeId: 7001
                          date: "2026-09-15"
                          startTime: "08:00"
                          availabilityCount: 8
                          soldOut: false
                          rates:
                            - rateId: 3001
                              prices:
                                - categoryId: 501
                                  title: Adult
                                  amount: 89
                                  currency: EUR
                      pricingCategories:
                        - id: 501
                          fullTitle: Adult
                      places:
                        - id: 9001
                          name: Jemaa el-Fnaa Meeting Point
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/experiences/{experienceId}/booking-options:
    post:
      operationId: getBookingOptions
      tags: [Availability]
      summary: Get required booking form fields
      description: |
        Returns questions for contact, passengers, and pickup. Collect all answers
        using `questionId` values (not labels).

        When places[] from detail/availability was non-empty, you MUST include
        positive pickupPlaceId and dropoffPlaceId. When places was empty or
        absent, omit both fields entirely.
      security: []
      parameters:
        - name: experienceId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingOptionsRequest"
            examples:
              withPlace:
                summary: With pickup place
                value:
                  activityBookings:
                    - activityId: 10042
                      startTimeId: 7001
                      date: "2026-09-15"
                      rateId: 3001
                      pickupPlaceId: 9001
                      dropoffPlaceId: 9001
                      passengers:
                        - pricingCategoryId: 501
      responses:
        "200":
          description: Form fields grouped by contact, passenger, and pickup
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingOptionsRes"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/experiences/{experienceId}/reserve:
    post:
      operationId: createReservation
      tags: [Bookings]
      summary: Create booking and get payment link
      description: |
        Submit collected contact and passenger answers. Returns `payment_url`.
        Always present payment_url as a markdown link: [Complete Payment](url).

        Include pickupPlaceId/dropoffPlaceId only when places[] applied.
        Include answers / pickupAnswers only when booking_options returned those
        question groups.
      security: []
      parameters:
        - name: experienceId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateReservationRequest"
            examples:
              sample:
                summary: Two adults
                value:
                  user_email: traveller@example.com
                  directBooking:
                    mainContactDetails:
                      - questionId: "1"
                        values: ["Ada"]
                      - questionId: "2"
                        values: ["Lovelace"]
                      - questionId: "3"
                        values: ["traveller@example.com"]
                      - questionId: "4"
                        values: ["+447821015382"]
                    activityBookings:
                      - activityId: 10042
                        startTimeId: 7001
                        date: "2026-09-15"
                        rateId: 3001
                        pickupPlaceId: 9001
                        dropoffPlaceId: 9001
                        passengers:
                          - pricingCategoryId: 501
                            passengerDetails: []
      responses:
        "201":
          description: Reservation created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReservationResponse"
              examples:
                created:
                  summary: Payment handoff
                  value:
                    success: true
                    data:
                      reservation_id: "res_abc123"
                      payment_url: https://stores.tixxly.ai/en/v/immmersiv/book/order/res_abc123
                      amount: 178
                      currency: EUR
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /reservations/{reservationId}:
    get:
      operationId: getReservation
      tags: [Bookings]
      summary: Get booking status
      description: |
        Lookup reservation status, payment state, and pricing after createReservation.
      security: []
      parameters:
        - name: reservationId
          in: path
          required: true
          schema:
            type: string
          example: res_abc123
      responses:
        "200":
          description: Reservation status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReservationStatusResponse"
              examples:
                pending:
                  summary: Awaiting payment
                  value:
                    success: true
                    data:
                      reservation_id: res_abc123
                      status: pending
                      payment_status: requires_payment_method
                      amount: 178
                      currency: EUR
                      pricing:
                        subtotal: 178
                        service_fee: 0
                        total: 178
                        currency: EUR
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/vendors/{username}:
    get:
      operationId: getVendor
      tags: [Vendors]
      summary: Get vendor information
      security: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
          example: immmersiv
      responses:
        "200":
          description: Vendor profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Vendor"
              examples:
                immmersiv:
                  value:
                    vendor_id: 12
                    company_name: Immmersiv
                    business_type: tours
                    username: immmersiv
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/vendors/{username}/products:
    get:
      operationId: getVendorProducts
      tags: [Vendors]
      summary: List vendor products
      security: []
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
          example: immmersiv
      responses:
        "200":
          description: Vendor products (empty if Stripe onboarding incomplete)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

  /analytics/track:
    post:
      operationId: trackEvent
      tags: [Analytics]
      summary: Track an analytics event
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TrackEventRequest"
      responses:
        "200":
          description: Event accepted
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Vendor feed/MCP API key from the Tixxly backoffice (AI Connections).
        Optional on many public read endpoints; scopes feed results when present.

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            success: false
            message: Validation failed
            code: VALIDATION_ERROR
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            success: false
            message: Unauthorized
            code: UNAUTHORIZED
    Forbidden:
      description: Authenticated but not allowed (e.g. tier gate)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            success: false
            message: Not found
            code: NOT_FOUND
    TooManyRequests:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            success: false
            message: Too many requests
            code: RATE_LIMITED
    InternalError:
      description: Unexpected server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"

  schemas:
    ErrorBody:
      type: object
      properties:
        success:
          type: boolean
          example: false
        message:
          type: string
        code:
          type: string
        details:
          type: object
          additionalProperties: true
        requestId:
          type: string

    Country:
      type: object
      properties:
        code:
          type: string
          description: ISO 3166-1 alpha-2 (lowercase)
        name:
          type: string

    Geo:
      type: object
      properties:
        latitude:
          type: number
        longitude:
          type: number

    Destination:
      type: object
      properties:
        slug:
          type: string
        name:
          type: string
        country:
          $ref: "#/components/schemas/Country"
        geo:
          $ref: "#/components/schemas/Geo"
        productCount:
          type: integer

    DestinationsResponse:
      type: object
      properties:
        destinations:
          type: array
          items:
            $ref: "#/components/schemas/Destination"

    TrustScore:
      type: object
      properties:
        score:
          type: number
        band:
          type: string
          description: Qualitative band (e.g. high, medium, low)

    SupplierSummary:
      type: object
      properties:
        username:
          type: string
        name:
          type: string
        verified:
          type: boolean
        productCount:
          type: integer
        countries:
          type: array
          items:
            $ref: "#/components/schemas/Country"
        cities:
          type: array
          items:
            type: object
            properties:
              slug:
                type: string
              name:
                type: string
              country:
                $ref: "#/components/schemas/Country"
        trustScore:
          $ref: "#/components/schemas/TrustScore"
        storefrontUrl:
          type: string
          format: uri
        liveSince:
          type: string
          format: date
        verifiedAt:
          type: string
          format: date

    SuppliersResponse:
      type: object
      properties:
        suppliers:
          type: array
          items:
            $ref: "#/components/schemas/SupplierSummary"

    ProductSummary:
      type: object
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
        fromPrice:
          type: object
          properties:
            amount:
              type: number
            currency:
              type: string

    SupplierDetailResponse:
      type: object
      properties:
        supplier:
          allOf:
            - $ref: "#/components/schemas/SupplierSummary"
            - type: object
              properties:
                products:
                  type: array
                  items:
                    $ref: "#/components/schemas/ProductSummary"

    FeedResponse:
      type: object
      properties:
        total:
          type: integer
        products:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Tixxly product ID — use everywhere
              content:
                type: object
                properties:
                  title:
                    type: string
                  subtitle:
                    type: string
              taxonomy:
                type: object
                properties:
                  brand:
                    type: string
                  attributes:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        value:
                          type: string
              offers:
                type: object
                properties:
                  price:
                    type: number
                  currency:
                    type: string
        merchant:
          type: object
          additionalProperties: true
        pagination:
          type: object
          properties:
            page:
              type: integer
            total:
              type: integer
            has_next:
              type: boolean

    ExperienceDetail:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            id:
              type: string
            external_id:
              type: string
            status:
              type: string
            content:
              type: object
              properties:
                title:
                  type: string
                subtitle:
                  type: string
                description:
                  type: string
                tags:
                  type: array
                  items:
                    type: string
            taxonomy:
              type: object
              properties:
                category_path:
                  type: string
                brand:
                  type: string
                attributes:
                  type: array
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                      value:
                        type: string
            compliance:
              type: object
              properties:
                warnings:
                  type: array
                  items:
                    type: string
                age_restriction:
                  type: string
            offers:
              type: array
              items:
                type: object
                properties:
                  offer_id:
                    type: string
                  price:
                    type: number
                  currency:
                    type: string
                  availability:
                    type: string
            image:
              type: string
              format: uri
              description: "Display as markdown: ![title](image_url)"
            vendor:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
            pricingCategories:
              type: array
              description: Ticket types. Use id as pricingCategoryId in booking.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  fullTitle:
                    type: string
                  minAge:
                    type: integer
                  maxAge:
                    type: integer
                  dependent:
                    type: boolean
            places:
              type: array
              description: |
                Pickup/dropoff locations. When non-empty, use id as
                pickupPlaceId/dropoffPlaceId. When empty or absent, omit those fields.
              items:
                type: object
                properties:
                  id:
                    type: integer
                    minimum: 1
                  name:
                    type: string

    AvailabilityResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            image:
              type: string
              format: uri
              description: "Display as markdown: ![title](image_url)"
            availabilities:
              type: array
              description: Time slots (max 10)
              items:
                type: object
                properties:
                  startTimeId:
                    type: integer
                    description: Use in booking-options and reserve
                  date:
                    type: string
                  startTime:
                    type: string
                  availabilityCount:
                    type: integer
                  soldOut:
                    type: boolean
                  rates:
                    type: array
                    items:
                      type: object
                      properties:
                        rateId:
                          type: integer
                          description: Use in booking-options and reserve
                        prices:
                          type: array
                          items:
                            type: object
                            properties:
                              categoryId:
                                type: integer
                              title:
                                type: string
                              amount:
                                type: number
                              currency:
                                type: string
                              minParticipantsRequired:
                                type: integer
                              maxParticipantsRequired:
                                type: integer
            pricingCategories:
              type: array
              description: Ticket types. Use id as pricingCategoryId.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  fullTitle:
                    type: string
                  minAge:
                    type: integer
                  maxAge:
                    type: integer
            places:
              type: array
              description: |
                Pickup/dropoff locations. If non-empty, ask user to choose one
                and send as pickupPlaceId AND dropoffPlaceId. If empty or absent, omit both.
              items:
                type: object
                properties:
                  id:
                    type: integer
                    minimum: 1
                  name:
                    type: string

    BookingOptionsRequest:
      type: object
      required:
        - activityBookings
      properties:
        activityBookings:
          type: array
          items:
            type: object
            required:
              - activityId
              - startTimeId
              - date
              - passengers
            properties:
              activityId:
                type: integer
                description: Tixxly product ID
              startTimeId:
                type: integer
                description: From checkAvailability slot
              date:
                type: string
                description: YYYY-MM-DD
              rateId:
                type: integer
                description: From checkAvailability slot rates[].rateId
              pickupPlaceId:
                type: integer
                minimum: 1
                description: |
                  Required when places[] was non-empty. Use places[].id.
                  Omit entirely when places[] was empty or absent — never 0 or null.
              dropoffPlaceId:
                type: integer
                minimum: 1
                description: |
                  Required when places[] was non-empty. Use places[].id.
                  Omit entirely when places[] was empty or absent — never 0 or null.
              passengers:
                type: array
                description: One entry per passenger
                items:
                  type: object
                  required:
                    - pricingCategoryId
                  properties:
                    pricingCategoryId:
                      type: integer
                      description: From getExperienceDetail pricingCategories[].id

    BookingOptionsRes:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            options:
              type: array
              items:
                type: object
                properties:
                  questions:
                    type: array
                    items:
                      $ref: "#/components/schemas/Question"
            questions:
              type: object
              properties:
                mainContactDetails:
                  type: array
                  description: Collect answers for reserve mainContactDetails
                  items:
                    $ref: "#/components/schemas/Question"
                activityBookings:
                  type: array
                  items:
                    type: object
                    properties:
                      questions:
                        type: array
                        items:
                          $ref: "#/components/schemas/Question"
                      passengers:
                        type: array
                        description: Collect answers for reserve passengers[].passengerDetails
                        items:
                          type: object
                          properties:
                            passengerDetails:
                              type: array
                              items:
                                $ref: "#/components/schemas/Question"
                      pickupQuestions:
                        type: array
                        items:
                          $ref: "#/components/schemas/Question"

    Question:
      type: object
      description: |
        When dataFormat is PHONE_NUMBER, values must use international format with
        country code (e.g. +44123456789). Always include the + prefix and country code.
      properties:
        questionId:
          type: string
          description: Numeric ID string — use in answers, never the label
        label:
          type: string
        dataType:
          type: string
        dataFormat:
          type: string
          description: "PHONE_NUMBER = must be +countrycode format"
        required:
          type: boolean
        selectFromOptions:
          type: boolean
        answerOptions:
          type: array
          items:
            type: object
            properties:
              value:
                type: string
              label:
                type: string

    Answer:
      type: object
      required:
        - questionId
        - values
      properties:
        questionId:
          type: string
          description: Numeric question ID string from booking_options
        values:
          type: array
          minItems: 1
          items:
            type: string

    CreateReservationRequest:
      type: object
      required:
        - user_email
        - directBooking
      properties:
        user_email:
          type: string
          format: email
        directBooking:
          type: object
          required:
            - activityBookings
            - mainContactDetails
          properties:
            activityBookings:
              type: array
              items:
                type: object
                required:
                  - activityId
                  - startTimeId
                  - date
                  - passengers
                properties:
                  activityId:
                    type: integer
                    description: Tixxly product ID
                  startTimeId:
                    type: integer
                    description: From checkAvailability slot
                  date:
                    type: string
                    description: YYYY-MM-DD
                  rateId:
                    type: integer
                    description: From checkAvailability slot rates[].rateId
                  pickupPlaceId:
                    type: integer
                    minimum: 1
                    description: |
                      Required when places[] was non-empty. Omit when places empty/absent.
                  dropoffPlaceId:
                    type: integer
                    minimum: 1
                    description: |
                      Required when places[] was non-empty. Omit when places empty/absent.
                  answers:
                    type: array
                    description: |
                      Activity/option answers from booking_options. Omit when that
                      question group was empty.
                    items:
                      $ref: "#/components/schemas/Answer"
                  pickupAnswers:
                    type: array
                    description: |
                      Pickup answers when pickupQuestions was non-empty. Omit otherwise.
                    items:
                      $ref: "#/components/schemas/Answer"
                  passengers:
                    type: array
                    description: One per passenger with filled passengerDetails
                    items:
                      type: object
                      required:
                        - pricingCategoryId
                        - passengerDetails
                      properties:
                        pricingCategoryId:
                          type: integer
                        passengerDetails:
                          type: array
                          items:
                            $ref: "#/components/schemas/Answer"
            mainContactDetails:
              type: array
              items:
                $ref: "#/components/schemas/Answer"

    ReservationResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            reservation_id:
              type: string
            payment_url:
              type: string
              format: uri
              description: Always display as markdown link [Complete Payment](url)
            amount:
              type: number
            currency:
              type: string

    ReservationStatusResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            reservation_id:
              type: string
            booking_id:
              type: [string, "null"]
            status:
              type: string
            payment_status:
              type: [string, "null"]
            amount:
              type: number
            currency:
              type: string
            pricing:
              type: object
              properties:
                subtotal:
                  type: number
                service_fee:
                  type: number
                total:
                  type: number
                currency:
                  type: string

    Vendor:
      type: object
      properties:
        vendor_id:
          type: integer
        company_name:
          type: string
        business_type:
          type: string
        username:
          type: string

    TrackEventRequest:
      type: object
      required:
        - event_type
        - vendor_id
      properties:
        event_type:
          type: string
          enum:
            - storefront_view
            - product_view
            - booking_click
            - checkout_start
            - payment_attempt
            - booking_confirmed
        vendor_id:
          type: integer
        product_id:
          type: integer
        source:
          type: string
          default: openai
