openapi: 3.1.0
info:
  title: Localoge Channel API
  version: v1
  summary: API de distribution de Localoge pour les PMS et Channel Managers.
  description: |
    Poussez logements, disponibilités, tarifs et réservations vers Localoge ; recevez nos
    réservations, messages et avis par webhook.

    - Tous les montants sont en **centimes d'euro** (entiers).
    - Toutes les dates de séjour sont au format `YYYY-MM-DD` ; l'arrivée est comprise, le départ ne
      l'est pas.
    - Enveloppe de succès : `{ success: true, environment, data }`. Enveloppe d'erreur :
      `{ success: false, error: { code, message, request_id } }`.
    - Clés `lok_test_` uniquement sur le sandbox, clés `lok_live_` uniquement en production.
      Une clé de l'autre environnement reçoit `401 UNAUTHORIZED`.
    - Toute réponse authentifiée porte `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
      `X-RateLimit-Reset` ; tout `429` porte `Retry-After`.

    Documentation : https://developers.localoge.com
  contact:
    name: Localoge
    email: contact@localoge.com
    url: https://developers.localoge.com
servers:
  - url: https://localoge.com/api/v1/channel
    description: Production (clés lok_live_ uniquement)
  - url: https://sandbox.localoge.com/api/v1/channel
    description: Sandbox, copie séparée de Localoge (clés lok_test_ uniquement)
security:
  - bearerAuth: []
tags:
  - name: Compte
  - name: Logements
  - name: Cycle de vie
  - name: Calendrier
  - name: Réservations
  - name: Messagerie
  - name: Avis
  - name: Webhooks
  - name: Événements
  - name: Sandbox
  - name: Conformité

# En-têtes de débit, repris par ancre YAML dans chaque réponse authentifiée.
x-rate-limit-headers: &rateLimitHeaders
  X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
  X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
  X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }

paths:
  /instance:
    servers:
      - url: https://localoge.com
        description: Production
      - url: https://sandbox.localoge.com
        description: Sandbox
    get:
      tags: [Compte]
      operationId: getInstance
      summary: Dire sur quelle instance on se trouve
      description: Route publique, sans authentification, hors du préfixe /api/v1/channel.
      security: []
      responses:
        '200':
          description: Instance courante
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'

  /ping:
    get:
      tags: [Compte]
      operationId: ping
      summary: Vérifier sa clé sans rien créer
      responses:
        '200':
          $ref: '#/components/responses/Ping'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /oauth/token:
    post:
      tags: [Compte]
      operationId: exchangeAuthorizationCode
      summary: Échanger le code d'autorisation d'un hôte contre un host_ref
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: Le code reçu sur votre redirect_uri.
      responses:
        '200':
          description: Référence du compte hôte
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [host_ref, host_name]
                        properties:
                          host_ref: { type: string, description: "À joindre à chaque envoi de logement de cet hôte." }
                          host_name: { type: string }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties:
    post:
      tags: [Logements]
      operationId: upsertProperty
      summary: Créer ou mettre à jour un logement (idempotent)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PropertyInput'
      responses:
        '201':
          $ref: '#/components/responses/PropertyUpsert'
        '200':
          $ref: '#/components/responses/PropertyUpsert'
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Logements]
      operationId: listProperties
      summary: Lister ses logements (paginé)
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/UpdatedSince'
      responses:
        '200':
          description: Page de logements
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [properties, truncated]
                        properties:
                          properties:
                            type: array
                            items: { $ref: '#/components/schemas/PropertySummary' }
                          truncated: { type: boolean }
                          next_cursor: { $ref: '#/components/schemas/NextCursor' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/bulk:
    post:
      tags: [Logements]
      operationId: bulkImportProperties
      summary: Importer jusqu'à 500 logements d'un coup (asynchrone)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [properties]
              properties:
                properties:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: '#/components/schemas/PropertyInput' }
      responses:
        '202':
          description: Accepté pour traitement
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [import_id, accepted, status_url]
                        properties:
                          import_id: { type: string }
                          accepted: { type: integer }
                          status_url: { type: string, examples: ['/api/v1/channel/imports/8ae3d395-…'] }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /imports/{import_id}:
    parameters:
      - name: import_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Logements]
      operationId: getImport
      summary: Avancement d'un import massif
      responses:
        '200':
          description: Avancement
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ImportProgress' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    put:
      tags: [Logements]
      operationId: updateProperty
      summary: Mettre à jour un logement (ou le désarchiver)
      description: Un champ absent laisse la valeur en place. L'identifiant vient du chemin.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PropertyInputPartialId'
      responses:
        '200':
          $ref: '#/components/responses/PropertyUpsert'
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Logements]
      operationId: getProperty
      summary: Relire ce que Localoge a compris
      responses:
        '200':
          description: Le logement
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Property' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Cycle de vie]
      operationId: archiveProperty
      summary: Archiver un logement
      description: |
        Retire le logement de la vente et garde son historique (`link_status: "archived"`).
        Refusé en `409 CONFLICT` si un séjour est à venir. Un nouveau `PUT` du même identifiant le
        désarchive.
      responses:
        '200':
          $ref: '#/components/responses/PropertyStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/pause:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    post:
      tags: [Cycle de vie]
      operationId: pauseProperty
      summary: Mettre un logement en pause
      responses:
        '200':
          $ref: '#/components/responses/PropertyStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/activate:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    post:
      tags: [Cycle de vie]
      operationId: activateProperty
      summary: Réactiver un logement
      description: Republie s'il est complet, ou le passe en `pending_validation`.
      responses:
        '200':
          $ref: '#/components/responses/PropertyStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/photos:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    get:
      tags: [Logements]
      operationId: getPropertyPhotos
      summary: Relire les photos et vidéos d'un logement
      responses:
        '200':
          description: Médias
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [photos, videos]
                        properties:
                          photos:
                            type: array
                            items: { $ref: '#/components/schemas/PropertyPhoto' }
                          videos:
                            type: array
                            items: { $ref: '#/components/schemas/PropertyVideo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /availability:
    put:
      tags: [Calendrier]
      operationId: putAvailability
      summary: Ouvrir ou fermer des nuits (jusqu'à 1000 dates)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [property_id]
                  properties:
                    property_id: { type: string, maxLength: 64, description: "Votre external_property_id." }
                - $ref: '#/components/schemas/AvailabilityInput'
      responses:
        '200': { $ref: '#/components/responses/CalendarResult' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/availability:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    put:
      tags: [Calendrier]
      operationId: putPropertyAvailability
      summary: Ouvrir ou fermer des nuits d'un logement
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AvailabilityInput' }
      responses:
        '200': { $ref: '#/components/responses/CalendarResult' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /rates:
    put:
      tags: [Calendrier]
      operationId: putRates
      summary: Poser tarifs et restrictions (jusqu'à 1000 dates)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [property_id]
                  properties:
                    property_id: { type: string, maxLength: 64, description: "Votre external_property_id." }
                - $ref: '#/components/schemas/RatesInput'
      responses:
        '200': { $ref: '#/components/responses/CalendarResult' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/rates:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    put:
      tags: [Calendrier]
      operationId: putPropertyRates
      summary: Poser tarifs et restrictions d'un logement
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RatesInput' }
      responses:
        '200': { $ref: '#/components/responses/CalendarResult' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/calendar:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    get:
      tags: [Calendrier]
      operationId: getPropertyCalendar
      summary: Relire le calendrier tel que Localoge le voit
      parameters:
        - name: from
          in: query
          required: true
          schema: { $ref: '#/components/schemas/Day' }
        - name: to
          in: query
          required: true
          schema: { $ref: '#/components/schemas/Day' }
      responses:
        '200':
          description: Calendrier
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Calendar' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{external_property_id}/reviews:
    parameters:
      - $ref: '#/components/parameters/ExternalPropertyId'
    get:
      tags: [Avis]
      operationId: getPropertyReviews
      summary: Les 100 derniers avis d'un logement (note sur 10)
      responses:
        '200':
          description: Avis
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reviews]
                        properties:
                          reviews:
                            type: array
                            items: { $ref: '#/components/schemas/Review' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /reservations:
    post:
      tags: [Réservations]
      operationId: createReservation
      summary: Envoyer une réservation vendue sur un autre canal (idempotent)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReservationInput' }
      responses:
        '201':
          $ref: '#/components/responses/ReservationCreated'
        '200':
          $ref: '#/components/responses/ReservationCreated'
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/PropertyNotFound' }
        '409': { $ref: '#/components/responses/DatesUnavailable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Réservations]
      operationId: listReservations
      summary: Lister les réservations de ses logements (paginé)
      description: "`updated_since` porte sur la date de modification : une annulation ressort."
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/UpdatedSince'
      responses:
        '200':
          description: Page de réservations
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reservations, truncated]
                        properties:
                          reservations:
                            type: array
                            items: { $ref: '#/components/schemas/Reservation' }
                          truncated: { type: boolean }
                          next_cursor: { $ref: '#/components/schemas/NextCursor' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /reservations/{reservation_ref}:
    parameters:
      - name: reservation_ref
        in: path
        required: true
        description: |
          GET : l'identifiant **Localoge** (`reservation_id`, celui des webhooks) ou le vôtre
          (`external_reservation_id`). PUT : **votre** `external_reservation_id`.
        schema: { type: string, maxLength: 64 }
    get:
      tags: [Réservations]
      operationId: getReservation
      summary: Relire une réservation (identifiant Localoge ou le vôtre)
      responses:
        '200':
          description: La réservation
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reservation]
                        properties:
                          reservation: { $ref: '#/components/schemas/ReservationDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/ReservationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Réservations]
      operationId: updateReservation
      summary: Modifier dates ou voyageurs d'une réservation envoyée
      description: Transactionnel. Un 409 laisse l'ancienne réservation intacte.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                check_in: { $ref: '#/components/schemas/Day' }
                check_out: { $ref: '#/components/schemas/Day' }
                guests_count: { type: integer, minimum: 1, maximum: 50 }
                total_amount: { type: integer, minimum: 0, maximum: 1000000000, description: "En centimes, pour information." }
      responses:
        '200':
          description: Modifiée
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reservation_id, check_in, check_out]
                        properties:
                          reservation_id: { type: string }
                          check_in: { $ref: '#/components/schemas/Day' }
                          check_out: { $ref: '#/components/schemas/Day' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ReservationNotFound' }
        '409': { $ref: '#/components/responses/DatesUnavailable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /reservations/{external_reservation_id}/cancel:
    parameters:
      - $ref: '#/components/parameters/ExternalReservationId'
    post:
      tags: [Réservations]
      operationId: cancelReservation
      summary: Annuler une réservation envoyée
      responses:
        '200':
          description: Annulée (ou déjà annulée)
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reservation_id, status, already_cancelled]
                        properties:
                          reservation_id: { type: string }
                          status: { type: string, const: cancelled }
                          already_cancelled: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ReservationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /reservations/{reservation_ref}/messages:
    parameters:
      - $ref: '#/components/parameters/ReservationRef'
    get:
      tags: [Messagerie]
      operationId: listReservationMessages
      summary: Les 100 derniers messages du séjour, du plus ancien au plus récent
      responses:
        '200':
          description: Conversation
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [messages]
                        properties:
                          messages:
                            type: array
                            items: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ReservationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Messagerie]
      operationId: sendReservationMessage
      summary: Répondre au voyageur au nom de l'hôte
      description: |
        Posé comme venant de l'hôte. Séjour d'origine Localoge : le voyageur est prévenu (temps réel,
        notification, e-mail). Séjour envoyé par vous : rien ne lui est envoyé.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, minLength: 1, maxLength: 4000 }
      responses:
        '201':
          description: Message posé
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [id, sent_at]
                        properties:
                          id: { type: string }
                          sent_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ReservationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events:
    get:
      tags: [Événements]
      operationId: listEvents
      summary: Tous les événements émis pour vous, livrés ou non (paginé, du plus ancien au plus récent)
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: since
          in: query
          description: Date ISO 8601 ; ne rend que les événements créés depuis.
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Page d'événements
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [events]
                        properties:
                          events:
                            type: array
                            items: { $ref: '#/components/schemas/Event' }
                          next_cursor: { $ref: '#/components/schemas/NextCursor' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: Ses abonnements (le secret n'est jamais rendu)
      responses:
        '200':
          description: Abonnements
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [webhooks]
                        properties:
                          webhooks:
                            type: array
                            items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Webhooks]
      operationId: putWebhook
      summary: Déclarer son URL de réception (le secret n'est rendu qu'ici)
      description: |
        HTTPS obligatoire. Refusé (`400 INVALID_PAYLOAD`) pour un nom d'événement inconnu ou une URL
        qui ne se résout pas vers une adresse publique. Une liste `events` vide veut dire tous les événements.
        Chaque appel produit un nouveau secret et invalide l'ancien.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, maxLength: 512 }
                events:
                  type: array
                  maxItems: 20
                  items: { $ref: '#/components/schemas/EventName' }
      responses:
        '201':
          $ref: '#/components/responses/WebhookWithSecret'
        '200':
          $ref: '#/components/responses/WebhookWithSecret'
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Nos livraisons vers votre URL (paginé, de la plus récente à la plus ancienne)
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [pending, delivered, failed] }
        - name: limit
          in: query
          description: De 1 à 200, 50 par défaut.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page de livraisons
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [deliveries]
                        properties:
                          deliveries:
                            type: array
                            items: { $ref: '#/components/schemas/WebhookDelivery' }
                          next_cursor: { $ref: '#/components/schemas/NextCursor' }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/deliveries/{delivery_id}/retry:
    parameters:
      - name: delivery_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Webhooks]
      operationId: retryWebhookDelivery
      summary: Remettre une livraison en file (même event_key)
      responses:
        '200':
          description: Remise en file
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [id, status]
                        properties:
                          id: { type: string }
                          status: { type: string, const: pending }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/test:
    post:
      tags: [Webhooks]
      operationId: sendTestWebhook
      summary: Émettre un webhook.test vers l'URL enregistrée
      responses:
        '202':
          description: Événement de test mis en file
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [event, event_key, status]
                        properties:
                          event: { type: string, const: webhook.test }
                          event_key: { type: string }
                          status: { type: string, const: pending }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhook-echo:
    get:
      tags: [Webhooks]
      operationId: listWebhookEcho
      summary: Les 20 dernières réceptions du miroir webhook-echo, avec notre verdict de signature
      responses:
        '200':
          description: Réceptions du miroir
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [deliveries]
                        properties:
                          deliveries:
                            type: array
                            items: { $ref: '#/components/schemas/EchoReception' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /conformance:
    get:
      tags: [Conformité]
      operationId: getConformance
      summary: Contrôles de conformité calculés sur votre journal
      responses:
        '200':
          description: Conformité
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Conformance' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sandbox/simulate:
    post:
      tags: [Sandbox]
      operationId: sandboxSimulate
      summary: Simuler un événement côté Localoge (sandbox uniquement, 404 en production)
      servers:
        - url: https://sandbox.localoge.com/api/v1/channel
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SimulateInput' }
      responses:
        '201':
          description: Objet créé et événement émis
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [event]
                        properties:
                          event: { type: string }
                          reservation_id: { type: string }
                          listing_id: { type: string }
                          status: { type: string }
        '400': { $ref: '#/components/responses/InvalidPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: NOT_FOUND (en production), PROPERTY_NOT_FOUND ou RESERVATION_NOT_FOUND
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: DATES_UNAVAILABLE ou CONFLICT (réservation annulée, logement pas en pending_validation)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sandbox/reset:
    post:
      tags: [Sandbox]
      operationId: sandboxReset
      summary: Supprimer ses logements, séjours et messages de test (sandbox uniquement)
      servers:
        - url: https://sandbox.localoge.com/api/v1/channel
      responses:
        '200':
          description: Remis à zéro
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reset, properties_removed]
                        properties:
                          reset: { type: boolean, const: true }
                          properties_removed: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sandbox/emails:
    get:
      tags: [Sandbox]
      operationId: sandboxEmails
      summary: Les e-mails que le sandbox aurait envoyés (sandbox uniquement)
      servers:
        - url: https://sandbox.localoge.com/api/v1/channel
      parameters:
        - name: limit
          in: query
          description: De 1 à 200, 50 par défaut.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Boîte de test
          headers: *rateLimitHeaders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [emails]
                        properties:
                          emails:
                            type: array
                            items: { $ref: '#/components/schemas/SandboxEmail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

webhooks:
  reservation.created:
    post:
      operationId: onReservationCreated
      summary: Un voyageur a réservé sur Localoge
      description: Bloquez ces nuits chez vous. Signé par X-Localoge-Signature.
      parameters: &webhookHeaders
        - name: X-Localoge-Signature
          in: header
          required: true
          description: "t=<horodatage>,v1=<HMAC-SHA256 hex de \"<horodatage>.<corps brut>\">"
          schema: { type: string }
        - name: X-Localoge-Event
          in: header
          required: true
          schema: { $ref: '#/components/schemas/EventName' }
        - name: X-Localoge-Event-Key
          in: header
          required: true
          description: Stable d'une tentative à l'autre, clé de déduplication.
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookReservation' }
      responses: &webhookResponses
        '200':
          description: Répondez 2xx en moins de 10 secondes, puis traitez en tâche de fond.
  reservation.updated:
    post:
      operationId: onReservationUpdated
      summary: Une réservation Localoge a changé
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookReservation' }
      responses: *webhookResponses
  reservation.cancelled:
    post:
      operationId: onReservationCancelled
      summary: Une réservation Localoge est annulée
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookReservation' }
      responses: *webhookResponses
  availability.updated:
    post:
      operationId: onAvailabilityUpdated
      summary: L'hôte a modifié ses disponibilités sur Localoge
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookAvailability' }
      responses: *webhookResponses
  property.updated:
    post:
      operationId: onPropertyUpdated
      summary: Une annonce a changé
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPropertyUpdated' }
      responses: *webhookResponses
  property.status_changed:
    post:
      operationId: onPropertyStatusChanged
      summary: Le statut d'une annonce a changé
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPropertyStatusChanged' }
      responses: *webhookResponses
  message.received:
    post:
      operationId: onMessageReceived
      summary: Un message a été écrit dans la conversation d'un séjour
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookMessage' }
      responses: *webhookResponses
  review.created:
    post:
      operationId: onReviewCreated
      summary: Un avis a été publié (note sur 10)
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookReview' }
      responses: *webhookResponses
  payment.succeeded:
    post:
      operationId: onPaymentSucceeded
      summary: Le loyer d'une réservation Localoge est encaissé
      description: "Jamais pour une réservation envoyée par le partenaire. event_key : payment.succeeded:<reservation_id>."
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayment' }
      responses: *webhookResponses
  payment.refunded:
    post:
      operationId: onPaymentRefunded
      summary: Localoge a remboursé le voyageur (total ou partiel)
      description: "Jamais pour une réservation envoyée par le partenaire. event_key : payment.refunded:<refund_id>."
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayment' }
      responses: *webhookResponses
  host.revoked:
    post:
      operationId: onHostRevoked
      summary: Un hôte vous a retiré son autorisation
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookHostRevoked' }
      responses: *webhookResponses
  webhook.test:
    post:
      operationId: onWebhookTest
      summary: Événement de test envoyé à la demande
      parameters: *webhookHeaders
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookTest' }
      responses: *webhookResponses

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "lok_(test|live)_<identifiant>.sk_<secret>"
      description: |
        `Authorization: Bearer lok_live_….sk_…`. L'en-tête `X-Api-Key` (sans « Bearer ») est
        accepté à l'identique.

  headers:
    X-RateLimit-Limit:
      description: Plafond de requêtes par minute pour cette clé.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requêtes restantes dans la fenêtre courante.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Secondes avant le renouvellement de la fenêtre.
      schema: { type: integer }
    Retry-After:
      description: Secondes à attendre avant de réessayer.
      schema: { type: integer }

  parameters:
    ExternalPropertyId:
      name: external_property_id
      in: path
      required: true
      description: Votre identifiant de logement.
      schema: { type: string, minLength: 1, maxLength: 64 }
    ReservationRef:
      name: reservation_ref
      in: path
      required: true
      description: L'identifiant Localoge (reservation_id) ou le vôtre (external_reservation_id).
      schema: { type: string, minLength: 1, maxLength: 64 }
    ExternalReservationId:
      name: external_reservation_id
      in: path
      required: true
      description: Votre identifiant de réservation.
      schema: { type: string, minLength: 1, maxLength: 64 }
    Limit:
      name: limit
      in: query
      description: De 1 à 500, 100 par défaut.
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    Cursor:
      name: cursor
      in: query
      description: Le next_cursor de la page précédente, opaque.
      schema: { type: string }
    UpdatedSince:
      name: updated_since
      in: query
      description: Date ISO 8601 ; ne rend que ce qui a été modifié depuis.
      schema: { type: string, format: date-time }

  responses:
    Ping:
      description: La clé est valide
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    required: [partner, environment, key_id, scopes, server_time]
                    properties:
                      partner: { type: string, description: "Votre identifiant de partenaire (slug)." }
                      environment: { $ref: '#/components/schemas/Environment' }
                      key_id: { type: string, description: "Partie publique de la clé employée." }
                      scopes:
                        type: array
                        items: { type: string }
                        description: Vide = aucune restriction.
                      server_time: { type: string, format: date-time }
    PropertyUpsert:
      description: Logement créé (201) ou mis à jour / inchangé (200)
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data: { $ref: '#/components/schemas/PropertyUpsertResult' }
    PropertyStatus:
      description: Nouveau statut du logement
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    required: [external_property_id, listing_id, status, link_status]
                    properties:
                      external_property_id: { type: string }
                      listing_id: { type: string }
                      status: { $ref: '#/components/schemas/PropertyStatus' }
                      link_status: { $ref: '#/components/schemas/LinkStatus' }
    CalendarResult:
      description: Nuits appliquées et nuits refusées
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data: { $ref: '#/components/schemas/CalendarWriteResult' }
    ReservationCreated:
      description: Créée (201) ou déjà existante, rejeu (200)
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    required: [reservation_id, created, status]
                    properties:
                      reservation_id: { type: string }
                      created: { type: boolean }
                      status: { type: string, enum: [confirmed, cancelled] }
    WebhookWithSecret:
      description: Abonnement enregistré ; le secret n'est montré qu'ici
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/SuccessEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    required: [id, url, secret, events]
                    properties:
                      id: { type: string }
                      url: { type: string, format: uri }
                      secret: { type: string, examples: ['whsec_9f2c…'] }
                      events:
                        type: array
                        items: { $ref: '#/components/schemas/EventName' }
    InvalidPayload:
      description: 400 INVALID_PAYLOAD
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: 401 UNAUTHORIZED (clé absente, invalide, révoquée, expirée, ou de l'autre environnement)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: 403 FORBIDDEN (portée manquante, partenaire suspendu, host_ref inconnu ou révoqué)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: 404 NOT_FOUND (livraison inconnue, route /sandbox/* en production)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    PropertyNotFound:
      description: 404 PROPERTY_NOT_FOUND
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ReservationNotFound:
      description: 404 RESERVATION_NOT_FOUND
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    DatesUnavailable:
      description: 409 DATES_UNAVAILABLE (ne pas réessayer)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: 409 CONFLICT (l'état de l'objet interdit l'opération)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: 429 RATE_LIMITED
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    Environment:
      type: string
      enum: [sandbox, production]
    Day:
      type: string
      pattern: '^\d{4}-\d{2}-\d{2}$'
      examples: ['2026-09-10']
    NextCursor:
      type: [string, 'null']
      description: Curseur de la page suivante ; null sur la dernière page.
    SuccessEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, const: true }
        environment: { $ref: '#/components/schemas/Environment' }
        data: {}
    ErrorCode:
      type: string
      description: Codes stables. On en ajoute, on n'en renomme jamais.
      enum:
        - UNAUTHORIZED
        - FORBIDDEN
        - RATE_LIMITED
        - INVALID_PAYLOAD
        - PROPERTY_NOT_FOUND
        - RESERVATION_NOT_FOUND
        - NOT_FOUND
        - DATES_UNAVAILABLE
        - RESERVATION_EXISTS
        - PROPERTY_EXISTS
        - CONFLICT
        - PAYOUT_ACCOUNT_MISSING
        - UNSUPPORTED
        - INTERNAL
      x-http-status:
        UNAUTHORIZED: 401
        FORBIDDEN: 403
        RATE_LIMITED: 429
        INVALID_PAYLOAD: 400
        PROPERTY_NOT_FOUND: 404
        RESERVATION_NOT_FOUND: 404
        NOT_FOUND: 404
        DATES_UNAVAILABLE: 409
        RESERVATION_EXISTS: 409
        PROPERTY_EXISTS: 409
        CONFLICT: 409
        PAYOUT_ACCOUNT_MISSING: 409
        UNSUPPORTED: 422
        INTERNAL: 500
    ErrorEnvelope:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, const: false }
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string, description: "En anglais ; le détail suit « : »." }
            request_id:
              type: [string, 'null']
              description: À citer au support ; journalisez-le.
    Instance:
      type: object
      required: [instance, sandbox_url, production_url, developers_url]
      properties:
        instance: { type: string, enum: [production, sandbox] }
        sandbox_url: { type: string, const: 'https://sandbox.localoge.com' }
        production_url: { type: string, const: 'https://localoge.com' }
        developers_url: { type: string, const: 'https://developers.localoge.com' }
    PropertyStatus:
      type: string
      enum: [draft, pending_validation, published, paused]
    LinkStatus:
      type: string
      enum: [active, paused, error, archived]
      description: État du lien entre votre logement et le nôtre.
    PhotoInput:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        position: { type: integer, minimum: 0, maximum: 999, description: "0 = couverture." }
        caption: { type: string, maxLength: 250 }
    VideoInput:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        thumbnail_url: { type: string, format: uri }
        duration_sec: { type: integer, minimum: 0, maximum: 3600 }
        caption: { type: string, maxLength: 250 }
        position: { type: integer, minimum: 0, maximum: 999 }
    PropertyFields:
      type: object
      properties:
        title: { type: string, minLength: 1, maxLength: 160 }
        description: { type: string, maxLength: 20000 }
        property_type:
          type: string
          enum: [apartment, house, villa, studio, loft, cottage, chalet, guest_room, bed_and_breakfast, mobile_home, castle, hotel_room, cabin, other]
        address: { type: string, maxLength: 255 }
        city: { type: string, maxLength: 120 }
        postal_code: { type: string, maxLength: 16 }
        country: { type: string, minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 du logement." }
        latitude: { type: number, minimum: -90, maximum: 90 }
        longitude: { type: number, minimum: -180, maximum: 180 }
        max_guests: { type: integer, minimum: 1, maximum: 50 }
        bedrooms: { type: integer, minimum: 0, maximum: 50 }
        beds: { type: integer, minimum: 0, maximum: 100 }
        bathrooms: { type: integer, minimum: 0, maximum: 50 }
        surface_m2: { type: integer, minimum: 0, maximum: 10000 }
        amenities:
          type: array
          maxItems: 200
          items: { type: string, maxLength: 64 }
          description: Clés d'équipement Localoge ; les inconnues sont ignorées (amenities_ignored).
        pets_policy: { type: string, enum: [unspecified, allowed, forbidden] }
        smoking_policy: { type: string, enum: [unspecified, allowed, forbidden] }
        adults_only: { type: boolean }
        videos:
          type: array
          maxItems: 10
          items: { $ref: '#/components/schemas/VideoInput' }
        check_in_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        check_out_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        cancellation_policy: { type: string, enum: [flexible, moderate, firm, strict] }
        base_price: { type: integer, minimum: 0, maximum: 10000000, description: "En centimes." }
        min_price: { type: integer, minimum: 0, maximum: 10000000, description: "En centimes." }
        max_price: { type: integer, minimum: 0, maximum: 10000000, description: "En centimes." }
        cleaning_fee: { type: integer, minimum: 0, maximum: 10000000, description: "En centimes." }
        deposit_amount: { type: integer, minimum: 0, maximum: 100000000, description: "En centimes." }
        currency: { type: string, minLength: 3, maxLength: 3 }
        min_nights: { type: integer, minimum: 1, maximum: 365 }
        max_nights: { type: integer, minimum: 1, maximum: 365 }
        registration_number: { type: string, maxLength: 64 }
        usage_type:
          type: string
          enum: [primary_residence, secondary_residence, legal_entity]
          description: Nécessaire pour publier.
        host_ref: { type: string, maxLength: 48, description: "Compte hôte qui vous a autorisé." }
        payout_model: { type: string, enum: [host, owner] }
        photos:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/PhotoInput' }
    PropertyInput:
      allOf:
        - type: object
          required: [external_property_id, title]
          properties:
            external_property_id: { type: string, minLength: 1, maxLength: 64 }
        - $ref: '#/components/schemas/PropertyFields'
    PropertyInputPartialId:
      description: Mêmes champs que PropertyInput ; external_property_id vient du chemin.
      allOf:
        - type: object
          required: [title]
        - $ref: '#/components/schemas/PropertyFields'
    MediaSyncResult:
      type: object
      description: Bilan de la synchronisation des médias (noms de champs historiques en français).
      properties:
        total: { type: integer }
        ajoutees: { type: integer }
        inchangees: { type: integer }
        refusees:
          type: array
          items:
            type: object
            properties:
              url: { type: string }
              motif: { type: string }
    PropertyUpsertResult:
      type: object
      required: [listing_id, external_property_id, created, unchanged, status, missing]
      properties:
        listing_id: { type: string }
        external_property_id: { type: string }
        created: { type: boolean }
        unchanged: { type: boolean, description: "true si le contenu envoyé était identique au précédent." }
        status: { $ref: '#/components/schemas/PropertyStatus' }
        missing:
          type: array
          items: { type: string }
          description: Ce qui manque encore pour publier.
        photos: { $ref: '#/components/schemas/MediaSyncResult' }
        videos: { $ref: '#/components/schemas/MediaSyncResult' }
        amenities_ignored:
          type: array
          items: { type: string }
    Property:
      type: object
      required: [external_property_id, listing_id, status]
      properties:
        external_property_id: { type: string }
        listing_id: { type: string }
        status: { $ref: '#/components/schemas/PropertyStatus' }
        title: { type: string }
        city: { type: [string, 'null'] }
        country: { type: [string, 'null'] }
        max_guests: { type: [integer, 'null'] }
        base_price: { type: [integer, 'null'], description: "En centimes." }
        payout_model: { type: [string, 'null'] }
        last_sync_at: { type: [string, 'null'], format: date-time }
    PropertySummary:
      type: object
      required: [external_property_id, listing_id, status, link_status]
      properties:
        external_property_id: { type: string }
        listing_id: { type: string }
        status: { $ref: '#/components/schemas/PropertyStatus' }
        link_status: { $ref: '#/components/schemas/LinkStatus' }
        updated_at: { type: string, format: date-time }
    PropertyPhoto:
      type: object
      required: [media_id, url, position, is_cover]
      properties:
        media_id: { type: string }
        url: { type: string, format: uri }
        position: { type: integer }
        is_cover: { type: boolean }
        source_url: { type: [string, 'null'], description: "Adresse d'où la photo a été rapatriée." }
        caption: { type: [string, 'null'] }
    PropertyVideo:
      type: object
      required: [media_id, url, position]
      properties:
        media_id: { type: string }
        url: { type: string, format: uri }
        thumbnail_url: { type: [string, 'null'] }
        position: { type: integer }
        source_url: { type: [string, 'null'] }
    ImportProgress:
      type: object
      required: [id, status, total, processed, succeeded, failed, errors]
      properties:
        id: { type: string }
        status: { type: string }
        total: { type: integer }
        processed: { type: integer }
        succeeded: { type: integer }
        failed: { type: integer }
        errors:
          type: array
          items:
            type: object
            properties:
              external_property_id: { type: string }
              motif: { type: string }
        started_at: { type: [string, 'null'], format: date-time }
        ended_at: { type: [string, 'null'], format: date-time }
    AvailabilityInput:
      type: object
      required: [availability]
      properties:
        availability:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            required: [date, available]
            properties:
              date: { $ref: '#/components/schemas/Day' }
              available: { type: boolean }
    RatesInput:
      type: object
      required: [rates]
      properties:
        rates:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            required: [date]
            properties:
              date: { $ref: '#/components/schemas/Day' }
              price: { type: integer, minimum: 0, maximum: 10000000, description: "En centimes." }
              min_nights: { type: [integer, 'null'], minimum: 1, maximum: 365 }
              no_arrival: { type: [boolean, 'null'] }
              no_departure: { type: [boolean, 'null'] }
    CalendarWriteResult:
      type: object
      required: [property_id, applied, skipped]
      properties:
        property_id: { type: string }
        applied: { type: integer }
        skipped:
          type: array
          description: Nuits refusées parce que vendues, avec la raison.
          items:
            type: object
            properties:
              date: { $ref: '#/components/schemas/Day' }
              reason: { type: string }
    Calendar:
      type: object
      required: [blocked, rates, rules]
      properties:
        blocked:
          type: array
          items:
            type: object
            properties:
              date: { $ref: '#/components/schemas/Day' }
              reason: { type: string }
        rates:
          type: array
          items:
            type: object
            properties:
              date: { $ref: '#/components/schemas/Day' }
              price: { type: integer, description: "En centimes." }
        rules:
          type: array
          items:
            type: object
            properties:
              date: { $ref: '#/components/schemas/Day' }
              min_nights: { type: [integer, 'null'] }
              no_arrival: { type: [boolean, 'null'] }
              no_departure: { type: [boolean, 'null'] }
    ReservationInput:
      type: object
      required: [external_reservation_id, external_property_id, check_in, check_out, guest]
      properties:
        external_reservation_id: { type: string, minLength: 1, maxLength: 64, description: "Clé d'idempotence." }
        external_property_id: { type: string, minLength: 1, maxLength: 64 }
        check_in: { $ref: '#/components/schemas/Day' }
        check_out: { $ref: '#/components/schemas/Day' }
        guest:
          type: object
          required: [email]
          properties:
            first_name: { type: string, maxLength: 60 }
            last_name: { type: string, maxLength: 60 }
            email: { type: string, format: email, maxLength: 255 }
            phone: { type: string, maxLength: 32 }
        guests_count: { type: integer, minimum: 1, maximum: 50, default: 1 }
        adults_count: { type: integer, minimum: 1, maximum: 50 }
        total_amount: { type: integer, minimum: 0, maximum: 1000000000, description: "En centimes, pour information." }
        currency: { type: string, minLength: 3, maxLength: 3 }
        status: { type: string, enum: [confirmed, cancelled], default: confirmed }
    Reservation:
      type: object
      required: [reservation_id, external_property_id, check_in, check_out, status, origin]
      properties:
        reservation_id: { type: string, description: "Identifiant Localoge." }
        external_property_id: { type: string }
        external_reservation_id: { type: [string, 'null'], description: "Le vôtre, si la réservation vient de vous." }
        check_in: { $ref: '#/components/schemas/Day' }
        check_out: { $ref: '#/components/schemas/Day' }
        status: { type: string }
        guests: { type: integer }
        total: { type: integer, description: "En centimes ; 0 sur une réservation venue de vous." }
        origin: { type: string, enum: [localoge, partner] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ReservationDetail:
      type: object
      required: [reservation_id, external_property_id, external_reservation_id, origin, status, check_in, check_out, amounts, created_at, updated_at]
      properties:
        reservation_id: { type: string }
        external_property_id: { type: string }
        external_reservation_id: { type: [string, 'null'] }
        origin: { type: string, enum: [localoge, partner] }
        status: { type: string }
        check_in: { $ref: '#/components/schemas/Day' }
        check_out: { $ref: '#/components/schemas/Day' }
        guests: { type: [integer, 'null'] }
        adults: { type: [integer, 'null'] }
        currency: { type: string, examples: [EUR] }
        amounts:
          type: object
          description: En centimes.
          required: [rent, cleaning_fee, options, tourist_tax, commission, total]
          properties:
            rent: { type: [integer, 'null'] }
            cleaning_fee: { type: [integer, 'null'] }
            options: { type: [integer, 'null'] }
            tourist_tax: { type: [integer, 'null'] }
            commission: { type: [integer, 'null'], description: "Commission Localoge prélevée sur l'hôte ; 0 sur une réservation venue de vous." }
            total: { type: [integer, 'null'] }
        payment_status:
          type: [string, 'null']
          enum: [none, saved_card, authorized, captured, cancelled, partial_capture, null]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Message:
      type: object
      required: [id, from, text, sent_at]
      properties:
        id: { type: string }
        from: { type: string, enum: [guest, host] }
        text: { type: string }
        sent_at: { type: string, format: date-time }
    Review:
      type: object
      required: [id, rating, published_at]
      properties:
        id: { type: string }
        rating: { type: number, minimum: 0, maximum: 10, description: "Note sur 10." }
        comment: { type: [string, 'null'] }
        published_at: { type: string, format: date-time }
    EventName:
      type: string
      enum:
        - reservation.created
        - reservation.updated
        - reservation.cancelled
        - availability.updated
        - property.updated
        - property.status_changed
        - message.received
        - review.created
        - payment.succeeded
        - payment.refunded
        - host.revoked
        - webhook.test
    Event:
      type: object
      required: [id, event, event_key, created_at, data]
      properties:
        id: { type: string }
        event: { $ref: '#/components/schemas/EventName' }
        event_key: { type: string }
        created_at: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
    Webhook:
      type: object
      required: [id, url, events, status]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/EventName' }
        status: { type: string }
    WebhookDelivery:
      type: object
      required: [id, event, event_key, status, attempts, created_at]
      properties:
        id: { type: string }
        event: { $ref: '#/components/schemas/EventName' }
        event_key: { type: string }
        status: { type: string, enum: [pending, delivered, failed] }
        attempts: { type: integer }
        last_http_status: { type: [integer, 'null'] }
        last_response_ms: { type: [integer, 'null'] }
        last_error: { type: [string, 'null'] }
        next_attempt_at: { type: [string, 'null'], format: date-time }
        last_attempt_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        payload: { type: object, additionalProperties: true }
    EchoReception:
      type: object
      description: Une réception du miroir webhook-echo (noms de champs historiques en français).
      properties:
        recuLe: { type: string, format: date-time }
        evenement: { type: [string, 'null'] }
        cleEvenement: { type: [string, 'null'] }
        signature: { type: [string, 'null'] }
        signatureValide: { type: boolean, description: "Notre verdict sur la signature." }
        corps: {}
    Conformance:
      type: object
      required: [passed, failed, warnings, checks]
      properties:
        passed: { type: integer }
        failed: { type: integer }
        warnings: { type: integer }
        checks:
          type: array
          items:
            type: object
            required: [id, label, status]
            properties:
              id:
                type: string
                description: "auth, property_created, property_idempotent, property_published, availability, rates, reservation_sent, reservation_replay, dates_conflict, cancellation, webhook_registered, webhook_delivered, webhook_failures, webhook_signature, reservations_pull, properties_pull, rate_limit"
              label: { type: string }
              status: { type: string, enum: [pass, fail, warn, todo] }
              detail: { type: [string, 'null'] }
    SimulateInput:
      type: object
      required: [event]
      properties:
        event:
          type: string
          enum: [reservation.created, reservation.updated, reservation.cancelled, message.received, review.created, listing.validated]
        external_property_id: { type: string, maxLength: 64, description: "Requis pour reservation.created et listing.validated." }
        reservation_id: { type: string, maxLength: 64, description: "Requis pour les autres événements ; réservation créée par le simulateur." }
        check_in: { $ref: '#/components/schemas/Day' }
        check_out: { $ref: '#/components/schemas/Day' }
        guests: { type: integer, minimum: 1, maximum: 50, default: 2 }
        text: { type: string, minLength: 1, maxLength: 4000 }
        rating: { type: integer, minimum: 1, maximum: 10, default: 9 }
    SandboxEmail:
      type: object
      description: Un e-mail retenu par le sandbox.
      required: [id, to, subject, html, created_at]
      properties:
        id: { type: string }
        to: { type: string }
        subject: { type: string }
        html: { type: string }
        listing_id: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }

    WebhookEnvelope:
      type: object
      required: [event, event_key, created_at, api_version, environment, data]
      properties:
        event: { $ref: '#/components/schemas/EventName' }
        event_key: { type: string, description: "Stable d'une tentative à l'autre." }
        created_at: { type: string, format: date-time }
        api_version: { type: string, const: v1 }
        environment: { $ref: '#/components/schemas/Environment' }
        data: { type: object }
    WebhookReservation:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [reservation_id, external_property_id, check_in, check_out, status]
              properties:
                reservation_id: { type: string }
                external_property_id: { type: string }
                check_in: { $ref: '#/components/schemas/Day' }
                check_out: { $ref: '#/components/schemas/Day' }
                status: { type: string }
                guests: { type: integer }
                total: { type: integer, description: "En centimes." }
                currency: { type: string, const: EUR }
                updated_at: { type: string, format: date-time }
    WebhookAvailability:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [external_property_id, dates]
              properties:
                external_property_id: { type: string }
                dates:
                  type: array
                  items: { $ref: '#/components/schemas/Day' }
    WebhookPropertyUpdated:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [external_property_id, listing_id]
              properties:
                external_property_id: { type: string }
                listing_id: { type: string }
                status: { $ref: '#/components/schemas/PropertyStatus' }
    WebhookPropertyStatusChanged:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [external_property_id, listing_id, status, previous_status, updated_at]
              properties:
                external_property_id: { type: string }
                listing_id: { type: string }
                status: { $ref: '#/components/schemas/PropertyStatus' }
                previous_status: { type: [string, 'null'] }
                reason: { type: string, examples: ['archived by partner', 'validated by Localoge'] }
                updated_at: { type: string, format: date-time }
    WebhookMessage:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [external_property_id, from, text, sent_at]
              properties:
                external_property_id: { type: string }
                reservation_id: { type: [string, 'null'], description: "Identifiant Localoge du séjour." }
                from: { type: string, enum: [guest, host] }
                text: { type: string }
                sent_at: { type: string, format: date-time }
    WebhookReview:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [external_property_id, rating, published_at]
              properties:
                external_property_id: { type: string }
                reservation_id: { type: [string, 'null'] }
                rating: { type: number, description: "Note sur 10." }
                comment: { type: [string, 'null'] }
                published_at: { type: string, format: date-time }
    WebhookPayment:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [reservation_id, external_property_id, amount, reservation_total, currency, occurred_at]
              properties:
                reservation_id: { type: string }
                external_property_id: { type: string }
                amount: { type: integer, description: "Centimes : montant encaissé ou remboursé." }
                reservation_total: { type: integer, description: "Centimes : total de la réservation." }
                currency: { type: string, examples: [EUR] }
                occurred_at: { type: string, format: date-time }
    WebhookHostRevoked:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [host_ref, revoked_at]
              properties:
                host_ref: { type: string }
                revoked_at: { type: string, format: date-time }
    WebhookTest:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            data:
              type: object
              required: [message, sent_at]
              properties:
                message: { type: string, examples: ['Test event from Localoge'] }
                sent_at: { type: string, format: date-time }
