openapi: 3.1.0
info:
  title: B2A API
  version: "2026-05-08"
  description: |
    Travel decision engine for AI agents. See https://b2a.bluepillow.com/docs.
servers:
  - url: https://api.b2a.bluepillow.com/v1
    description: Production
components:
  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: pk_live_*
  schemas:
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          additionalProperties: false
          required: [code, message, request_id, docs_url]
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - authentication_failed
                - forbidden
                - not_found
                - conflict
                - rate_limited
                - upstream_unavailable
                - decision_expired
                - no_offers_found
                - internal_error
            message: { type: string, minLength: 1 }
            field: { type: string, nullable: true }
            request_id: { type: string, minLength: 1 }
            docs_url: { type: string, format: uri, minLength: 1 }
    OTA:
      type: string
      enum:
        - Booking
        - Agoda
        - Expedia
        - TripCom
        - Vrbo
        - HotelsDotCom
        - HotelPlanner
        - Priceline
        - Hotwire
        - Orbitz
        - Kayak
        - Trivago
        - Hostelworld
        - MrAndMrsSmith
        - Nuitee
        - Direct
        - Eps
        - HotelBeds
        - NextPax
        - TuiVillas
        - HomeStay
        - BelVilla
        - Other
    LocationObject:
      type: object
      additionalProperties: false
      required: [lat, lon, address, city, country]
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        address: { type: string }
        city: { type: string }
        country: { type: string, minLength: 2, maxLength: 2, pattern: "^[A-Z]{2}$" }
        neighborhood: { type: string, nullable: true }
    ImageObject:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { type: string, format: uri }
        caption: { type: string, nullable: true }
    ReviewsByOta:
      type: object
      additionalProperties: false
      required: [score, count]
      properties:
        score: { type: number, minimum: 0, maximum: 5 }
        count: { type: integer, minimum: 0 }
    ReviewsAggregate:
      type: object
      additionalProperties: false
      required: [score_0_5, count]
      properties:
        score_0_5: { type: number, minimum: 0, maximum: 5 }
        count: { type: integer, minimum: 0 }
        distribution_by_ota:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/ReviewsByOta"
    DedupMeta:
      type: object
      additionalProperties: false
      required: [source_listings, confidence, last_reconciled]
      properties:
        source_listings: { type: integer, minimum: 1 }
        confidence: { type: number, minimum: 0, maximum: 1 }
        last_reconciled: { type: string, format: date-time }
    Offer:
      type: object
      additionalProperties: false
      required: [ota, amount, currency]
      properties:
        ota: { type: string, description: "Connector name (e.g. 'Booking', 'Agoda') as reported by BluePillow." }
        amount: { type: number, minimum: 0 }
        amount_per_night: { type: number, minimum: 0, nullable: true }
        currency: { type: string, minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" }
        breakfast_included: { type: boolean, default: false }
        refundable: { type: boolean, default: false }
        is_discounted: { type: boolean, default: false }
        discount_percentage: { type: number, minimum: 0, maximum: 100, nullable: true }
        deeplink_url:
          type: string
          nullable: true
          description: >
            BluePillow skippy redirect URL (tracked redirect to the OTA booking page).
            Opens the OTA landing page for this property with dates/guests pre-filled,
            routing through BluePillow's tracking layer. Not a direct OTA link.
            None when BP did not return a URL for this offer.
        rooms_left:
          type: integer
          minimum: 0
          nullable: true
          description: >
            Number of rooms remaining at this rate per BluePillow's live-availability
            fan-out. A low value (1-3) is a scarcity signal. Populated from /search
            when BP reports it; None when not available.
    Property:
      type: object
      additionalProperties: false
      required: [id, name, property_type, rating, location, amenities, ota_coverage]
      properties:
        id: { type: string, pattern: "^prop_" }
        name: { type: string }
        brand: { type: string, nullable: true }
        chain: { type: string, nullable: true }
        property_type:
          type: string
          enum: [hotel, aparthotel, apartment, villa, bnb, hostel, resort]
        rating: { type: number, minimum: 0, maximum: 5 }
        stars: { type: integer, minimum: 1, maximum: 5, nullable: true }
        location: { $ref: "#/components/schemas/LocationObject" }
        amenities: { type: array, items: { type: string } }
        images: { type: array, items: { $ref: "#/components/schemas/ImageObject" } }
        reviews_aggregate:
          $ref: "#/components/schemas/ReviewsAggregate"
        ota_coverage:
          type: array
          items: { $ref: "#/components/schemas/OTA" }
        dedup_meta:
          $ref: "#/components/schemas/DedupMeta"
        web_url:
          type: string
          nullable: true
          description: >
            BluePillow property-page URL with the caller's search context (check-in/out,
            language, currency, party size) baked into query params. Links directly to
            the BluePillow listing page, not an OTA. None when the search context is
            too sparse to build a meaningful URL (e.g. single-property lookup without dates).
        offers:
          type: array
          items: { $ref: "#/components/schemas/Offer" }
          description: >
            Live per-OTA offer list from BluePillow GET /search, sorted best-first.
            Populated only when the BP bridge is active and dates/guests were provided.
            Empty when no live data is available (Mongo fallback path).
        price:
          $ref: "#/components/schemas/Price"
          nullable: true
          description: Best offer from the offers list; None when offers is empty.
        availability_status:
          type: string
          enum: [available, unavailable, unknown]
          description: >
            Live availability state: available = BP confirmed bookable rooms;
            unavailable = BP reported no rooms for the requested dates;
            unknown = dates not provided or live fan-out was skipped.
    Location:
      type: object
      additionalProperties: false
      required: [type, value]
      properties:
        type:
          type: string
          enum: [destination_id, poi_id, coordinates, bbox, property_id]
        # Server-side validated: shape depends on `type` (string for destination_id/poi_id/property_id;
        # {lat, lon, radius_km?} for coordinates; {nw: [lat, lon], se: [lat, lon]} for bbox).
        # See b2abp_shared.schemas.intent.Location for the authoritative validator.
        value: {}
    Dates:
      type: object
      additionalProperties: false
      required: [check_in, check_out]
      properties:
        check_in: { type: string, format: date }
        check_out: { type: string, format: date }
    Guests:
      type: object
      additionalProperties: false
      required: [adults]
      properties:
        adults: { type: integer, minimum: 1, maximum: 16 }
        children_ages: { type: array, items: { type: integer } }
        rooms: { type: integer, minimum: 1, maximum: 9, default: 1 }
    SearchRequest:
      type: object
      additionalProperties: false
      required: [location, dates, guests]
      properties:
        location: { $ref: "#/components/schemas/Location" }
        dates: { $ref: "#/components/schemas/Dates" }
        guests: { $ref: "#/components/schemas/Guests" }
        # R1: subset of Constraints (price_min_eur, required_amenities, cancellation, preferred_otas, excluded_otas are decision-time and live in /decide, post-MVP).
        filters:
          type: object
          additionalProperties: false
          properties:
            price_max_eur: { type: number }
            min_rating: { type: number, minimum: 0, maximum: 5 }
            property_types:
              type: array
              items: { type: string, enum: [hotel, aparthotel, apartment, villa, bnb, hostel, resort] }
        sort:
          type: string
          enum: [score_desc, price_asc, rating_desc, conversion_desc]
          default: score_desc
        page:
          type: object
          additionalProperties: false
          properties:
            limit: { type: integer, minimum: 1, maximum: 100, default: 20 }
            cursor: { type: string, nullable: true }
    SearchResponse:
      type: object
      additionalProperties: false
      required: [results, page, metadata]
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/Property" }
        page:
          type: object
          additionalProperties: false
          required: [next_cursor, total_estimate]
          properties:
            next_cursor: { type: string, nullable: true }
            total_estimate: { type: integer, minimum: 0 }
        metadata:
          type: object
          additionalProperties: false
          required: [price_as_of, otas_queried, request_id]
          properties:
            price_as_of: { type: string, format: date-time }
            otas_queried: { type: integer }
            request_id: { type: string }
    Price:
      type: object
      additionalProperties: false
      required: [amount, currency]
      properties:
        amount: { type: number, minimum: 0 }
        amount_per_night: { type: number, minimum: 0, nullable: true }
        currency: { type: string, minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" }
        breakfast_included: { type: boolean, default: false }
        refundable: { type: boolean, default: false }
        is_discounted: { type: boolean, default: false }
        discount_percentage: { type: number, minimum: 0, maximum: 100, nullable: true }
        ota: { type: string, nullable: true }
    Constraints:
      type: object
      additionalProperties: false
      properties:
        price_max_eur: { type: number, nullable: true }
        price_min_eur: { type: number, nullable: true }
        min_rating: { type: number, minimum: 0, maximum: 5, nullable: true }
        property_types:
          type: array
          nullable: true
          items: { type: string, enum: [hotel, apartment, house, villa, bb, hostel, farmstay, holiday-home] }
        required_amenities:
          type: array
          nullable: true
          items: { type: string }
        cancellation:
          type: string
          enum: [any, free, non_refundable]
          default: any
        preferred_otas:
          type: array
          nullable: true
          items: { $ref: "#/components/schemas/OTA" }
        excluded_otas:
          type: array
          nullable: true
          items: { $ref: "#/components/schemas/OTA" }
    Context:
      type: object
      additionalProperties: false
      required: [end_user_country, currency]
      properties:
        end_user_country: { type: string, minLength: 2, maxLength: 2, pattern: "^[A-Z]{2}$" }
        currency: { type: string, minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" }
        language: { type: string, minLength: 2, nullable: true }
        user_hash: { type: string, nullable: true }
        agent_profile: { type: string, nullable: true }
    Intent:
      type: object
      additionalProperties: false
      required: [location, dates, guests]
      properties:
        location: { $ref: "#/components/schemas/Location" }
        dates: { $ref: "#/components/schemas/Dates" }
        guests: { $ref: "#/components/schemas/Guests" }
        constraints: { $ref: "#/components/schemas/Constraints" }
        context: { $ref: "#/components/schemas/Context" }
    Option:
      type: object
      additionalProperties: false
      required: [property]
      properties:
        property: { $ref: "#/components/schemas/Property" }
    DecisionMetadata:
      type: object
      additionalProperties: false
      required: [total_options_considered, request_id]
      properties:
        price_as_of: { type: string, format: date-time, nullable: true }
        total_options_considered: { type: integer }
        request_id: { type: string }
    Decision:
      type: object
      additionalProperties: false
      required: [recommended, metadata]
      properties:
        recommended: { $ref: "#/components/schemas/Option" }
        alternatives:
          type: array
          items: { $ref: "#/components/schemas/Option" }
        metadata: { $ref: "#/components/schemas/DecisionMetadata" }
    StayDates:
      type: object
      additionalProperties: false
      required: [check_in, check_out]
      properties:
        check_in: { type: string, format: date }
        check_out: { type: string, format: date }
    OfferToCompare:
      type: object
      additionalProperties: false
      required: [total_eur]
      properties:
        ota: { type: string, nullable: true }
        total_eur: { type: number, minimum: 0 }
    ByHotelRequest:
      type: object
      additionalProperties: false
      required: [property_id, dates, guests]
      properties:
        property_id: { type: string }
        dates: { $ref: "#/components/schemas/StayDates" }
        guests: { $ref: "#/components/schemas/Guests" }
    ValidateRequest:
      type: object
      additionalProperties: false
      required: [property_id, dates, guests, offer_to_compare]
      properties:
        property_id: { type: string }
        dates: { $ref: "#/components/schemas/StayDates" }
        guests: { $ref: "#/components/schemas/Guests" }
        offer_to_compare: { $ref: "#/components/schemas/OfferToCompare" }
    ValidateResult:
      type: object
      additionalProperties: false
      required: [still_valid]
      properties:
        still_valid: { type: boolean }
        price_delta_eur: { type: number, nullable: true }
        delta_percent: { type: number, nullable: true }
        refreshed_offer:
          oneOf:
            - $ref: "#/components/schemas/Price"
          nullable: true
        reason:
          type: string
          nullable: true
          description: One of price_changed | sold_out | dates_unavailable
    InitiateRequest:
      type: object
      additionalProperties: false
      required: [property_id, dates, guests]
      properties:
        property_id: { type: string }
        dates: { $ref: "#/components/schemas/StayDates" }
        guests: { $ref: "#/components/schemas/Guests" }
        ota: { type: string, nullable: true }
    InitiateResult:
      type: object
      additionalProperties: false
      required: [tracking_id, deep_link]
      properties:
        tracking_id: { type: string }
        deep_link: { type: string }
        ota: { type: string, nullable: true }
    DestinationLocation:
      type: object
      additionalProperties: false
      required: [lat, lon, default_radius_km]
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        default_radius_km: { type: number, minimum: 0 }
    Destination:
      type: object
      additionalProperties: false
      required: [id, name, display_name, type, location, accomodations_count, path_breadcrumb]
      properties:
        id: { type: string, pattern: "^dest_[0-9a-f]{24}$" }
        name: { type: string }
        display_name: { type: string }
        type:
          type: string
          enum: [city, neighborhood, airport, poi]
        country_code: { type: string, pattern: "^[A-Z]{2}$", nullable: true }
        country_name: { type: string, nullable: true }
        location: { $ref: "#/components/schemas/DestinationLocation" }
        accomodations_count: { type: integer, minimum: 0 }
        path_breadcrumb: { type: array, items: { type: string } }
        confidence: { type: number, minimum: 0, maximum: 1, nullable: true }
        distance_km: { type: number, minimum: 0, nullable: true }
    ResolveRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 2, maxLength: 100 }
        country: { type: string, nullable: true }
        type:
          type: string
          enum: [city, neighborhood, airport, poi]
          nullable: true
        language:
          type: string
          enum: [en, it, fr, de, es, pt, nl, ko, pl, ja]
          default: en
    ResolveResult:
      type: object
      additionalProperties: false
      required: [candidates, total_matches, disambiguation_recommended]
      properties:
        candidates:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/Destination" }
        total_matches: { type: integer, minimum: 1 }
        total_matches_capped_at: { type: integer, nullable: true }
        disambiguation_recommended: { type: boolean }
    DiscoverNearRequest:
      type: object
      additionalProperties: false
      required: [lat, lon]
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        radius_km: { type: number, exclusiveMinimum: 0, default: 5.0 }
        type:
          type: string
          enum: [city, neighborhood, airport, poi]
          nullable: true
        language:
          type: string
          enum: [en, it, fr, de, es, pt, nl, ko, pl, ja]
          default: en
    DiscoverResult:
      type: object
      additionalProperties: false
      required: [candidates, total_matches]
      properties:
        candidates:
          type: array
          items: { $ref: "#/components/schemas/Destination" }
        total_matches: { type: integer, minimum: 0 }
        total_matches_capped_at: { type: integer, nullable: true }
security:
  - ApiKeyBearer: []
paths:
  /health:
    get:
      summary: Liveness + per-OTA status
      security: []
      responses:
        "200":
          description: OK or degraded
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [status, version]
                properties:
                  status:
                    type: string
                    enum: [ok, degraded, down]
                  otas:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        status: { type: string, enum: [ok, slow, down] }
                        p95_latency_ms: { type: number, nullable: true }
                  version: { type: string }
  /properties/{property_id}:
    get:
      summary: Get canonical property record
      parameters:
        - name: property_id
          in: path
          required: true
          schema: { type: string }
        - name: include
          in: query
          required: false
          schema:
            type: string
            description: Comma-separated; subset of [signals, reviews_aggregate, ota_coverage]
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      responses:
        "200":
          description: Property found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Property" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Property not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /search/stays:
    post:
      summary: Ranked discovery search
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /destinations/resolve:
    post:
      summary: Resolve a place name to one or more destination ids
      tags: [destinations]
      parameters:
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResolveRequest" }
      responses:
        "200":
          description: One or more candidate destinations
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResolveResult" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: No destination matched
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /destinations/discover-near:
    post:
      summary: Discover destinations within a radius of a coordinate
      tags: [destinations]
      parameters:
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DiscoverNearRequest" }
      responses:
        "200":
          description: Candidate destinations near the point (possibly empty)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DiscoverResult" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /decide:
    post:
      summary: Stateless top-1 recommendation for a travel intent
      tags: [decision]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Intent" }
      responses:
        "200":
          description: Recommended option plus alternatives
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Decision" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: No offers found for the intent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /recommendations/by-hotel:
    post:
      summary: Best OTA for a property the user has already chosen
      tags: [decision]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ByHotelRequest" }
      responses:
        "200":
          description: Decision wrapping the chosen property with per-OTA offers
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Decision" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Property not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /validate:
    post:
      summary: Re-price a previously-seen offer (±2% tolerance)
      tags: [decision]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ValidateRequest" }
      responses:
        "200":
          description: Validation verdict for the offer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidateResult" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /bookings/initiate:
    post:
      summary: Generate a BluePillow booking deep link for a chosen property
      tags: [handoff]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: B2A-Version
          in: header
          required: false
          schema: { type: string, format: date }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InitiateRequest" }
      responses:
        "200":
          description: Tracking id plus booking deep link
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InitiateResult" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Auth missing or invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Property not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
