openapi: 3.1.0
# ═══════════════════════════════════════════════════════════════════════════
# WAT API v1 — contrato público
#
# Fuente de verdad pública de la Developer Platform de We Are Transfers.
# Está generada A MANO a partir del código (lib/api/** y app/api/v1/**) y
# `tests/unit/openapi.test.ts` comprueba que cada ruta, método y scope del
# código exista aquí y viceversa. Si este fichero y el código discrepan, manda
# el código: corrige el fichero.
# ═══════════════════════════════════════════════════════════════════════════
info:
  title: WAT API
  version: '1.0'
  summary: API pública de We Are Transfers para integraciones (ERP, PMS, CRM, BI…).
  description: |
    La WAT API permite a un software externo leer las reservas, clientes,
    conductores y vehículos de **una** organización de We Are Transfers, recibir
    su ciclo de vida por webhooks firmados y guardar identificadores externos
    para reconciliar los dos sistemas.

    **v1 es de solo lectura sobre el dominio** (reservas, clientes, conductores,
    vehículos, eventos). Las escrituras disponibles son la gestión de webhooks y
    de identificadores externos. Crear, modificar o cancelar reservas, facturas,
    cobros y posición del vehículo están previstos por los scopes pero **no
    implementados** en v1.

    Toda petición se autentica con una clave `wat_live_…` o `wat_test_…` en la
    cabecera `Authorization: Bearer`. La clave decide la organización: ningún
    parámetro puede cambiarla.
  termsOfService: https://wearetransfers.com/legal/terminos
  contact:
    name: We Are Transfers
    url: https://wearetransfers.com/developers
  license:
    name: Propietaria
    url: https://wearetransfers.com/legal

servers:
  - url: https://wearetransfers.com/api/v1
    description: Producción (live). Solo acepta claves `wat_live_…`.
  # NO HAY OTRO SERVIDOR. El prefijo `wat_test_…` existe en el formato de las
  # claves y el servidor comprueba el entorno, pero no existe ningún despliegue
  # público de la WAT API en entorno `test`: una clave `wat_test_…` no abre en
  # ninguna parte y contra este servidor responde 401 `wrong_environment`.
  # Ver /developers/environments.

security:
  - apiKey: []

tags:
  - name: Integrations
    description: Quién soy — la integración, la organización y los scopes efectivos de la clave.
  - name: Bookings
    description: Reservas de la organización (originadas o ejecutadas por ella). Solo lectura.
  - name: Customers
    description: Clientes de la organización — hoteles, agencias, apartamentos y motores públicos.
  - name: Drivers
    description: Conductores de la flota de la organización.
  - name: Vehicles
    description: Vehículos de la organización.
  - name: Events
    description: Registro de eventos de la organización, para reconciliar cuando un webhook se pierde.
  - name: Webhooks
    description: Destinos HTTPS que reciben los eventos, firmados con HMAC-SHA256.
  - name: External IDs
    description: El identificador de tu sistema para una entidad de WAT (uno por integración).

paths:
  /integrations/me:
    get:
      tags: [Integrations]
      operationId: getIntegrationMe
      summary: Quién soy
      description: |
        Devuelve la integración y la organización a las que pertenece la clave,
        los scopes efectivos (intersección clave ∩ integración), el entorno y
        los límites. No exige ningún scope: es el primer endpoint que prueba
        cualquiera.
      x-scopes: []
      responses:
        '200':
          description: La identidad de la clave.
          headers:
            X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntegrationMe' }
              example:
                integration: { id: 3f1c2a9e-8d6b-4a1f-9c2e-5b7d8e9f0a11, name: ERP Volcano, kind: erp }
                organization: { id: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d, code: '1003', name: Transfers Levante SL }
                key: { prefix: wat_live_Ab3dEf7h, name: Producción ERP }
                environment: live
                scopes: [bookings:read, customers:read, events:read, webhooks:read, webhooks:write, links:read, links:write]
                rate_limits: { per_minute: { key: 600, organization: 1200, endpoint: 300 } }
                api_version: v1
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /bookings:
    get:
      tags: [Bookings]
      operationId: listBookings
      summary: Listar reservas
      description: |
        Reservas en las que la organización es **originadora o ejecutora**
        (las que le llegan por la bolsa también salen, con `role: executor`).
        Orden: `created_at` descendente. Paginación por cursor.

        Los filtros se **añaden** a la organización de la clave; ninguno puede
        ampliarla. Si se pasa `external_id`, se ignoran los demás filtros y la
        respuesta es la reserva enlazada a ese id por **esta** integración
        (o una lista vacía).

        `passenger` solo aparece con el scope `passengers:read`; `pricing`
        solo con `pricing:read`.
      x-scopes: [bookings:read]
      parameters:
        - name: status
          in: query
          description: Estado público de la reserva.
          schema: { $ref: '#/components/schemas/BookingStatus' }
        - name: customer_id
          in: query
          description: UUID del cliente (`customer.id`).
          schema: { type: string, format: uuid }
        - name: pickup_from
          in: query
          description: Recogida a partir de esta fecha (ISO 8601, inclusive).
          schema: { type: string, format: date-time }
        - name: pickup_to
          in: query
          description: Recogida hasta esta fecha (ISO 8601, inclusive).
          schema: { type: string, format: date-time }
        - name: created_from
          in: query
          description: Creadas a partir de esta fecha (ISO 8601, inclusive).
          schema: { type: string, format: date-time }
        - name: created_to
          in: query
          description: Creadas hasta esta fecha (ISO 8601, inclusive).
          schema: { type: string, format: date-time }
        - name: external_id
          in: query
          description: Tu identificador para la reserva (el guardado con `PUT /bookings/{id}/external-id`). Anula el resto de filtros.
          schema: { type: string, maxLength: 200 }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Página de reservas.
          headers:
            X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Booking' }
                  next_cursor: { $ref: '#/components/schemas/NextCursor' }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /bookings/{id}:
    get:
      tags: [Bookings]
      operationId: getBooking
      summary: Obtener una reserva
      description: |
        Por **UUID** o por **número de reserva de WAT** (solo dígitos, p. ej.
        `10031234`). Una reserva de otra organización es `404`, igual que una
        que no existe: la API no revela si un id es de otra empresa.
      x-scopes: [bookings:read]
      parameters:
        - name: id
          in: path
          required: true
          description: UUID de la reserva o número de reserva de WAT.
          schema: { type: string }
      responses:
        '200':
          description: La reserva.
          headers:
            X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /bookings/{id}/external-id:
    parameters:
      - name: id
        in: path
        required: true
        description: UUID de la reserva (aquí no vale el número de reserva).
        schema: { type: string, format: uuid }
    get:
      tags: [External IDs]
      operationId: getBookingExternalId
      summary: Leer el identificador externo de una reserva
      description: El id que **esta integración** guardó para la reserva. Si no hay enlace, `external_id` es `null` (no es un 404).
      x-scopes: [bookings:read, links:read]
      responses:
        '200':
          description: El enlace (o su ausencia).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExternalIdLink' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [External IDs]
      operationId: setBookingExternalId
      summary: Guardar el identificador externo de una reserva
      description: |
        Asocia tu id a la reserva. Repetir la misma pareja es idempotente
        (`200`). Un `external_id` que ya apunta a **otra** reserva de esta
        integración es `409 conflict`. Admite `Idempotency-Key`.
      x-scopes: [bookings:read, links:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_id]
              properties:
                external_id:
                  type: string
                  description: ASCII imprimible, de 1 a 200 caracteres.
                  minLength: 1
                  maxLength: 200
                  pattern: '^[\x20-\x7E]{1,200}$'
            example: { external_id: VLC-2026-000123 }
      responses:
        '200':
          description: Enlace guardado.
          headers:
            Idempotent-Replayed: { $ref: '#/components/headers/Idempotent-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExternalIdLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [External IDs]
      operationId: deleteBookingExternalId
      summary: Borrar el identificador externo de una reserva
      x-scopes: [bookings:read, links:write]
      responses:
        '200':
          description: Enlace borrado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deleted' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: La reserva no es de la organización, o no tenía enlace.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /customers:
    get:
      tags: [Customers]
      operationId: listCustomers
      summary: Listar clientes
      description: Clientes de la organización (no borrados). Orden `created_at` descendente. Paginación por cursor.
      x-scopes: [customers:read]
      parameters:
        - $ref: '#/components/parameters/active'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Página de clientes.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Customer' } }
                  next_cursor: { $ref: '#/components/schemas/NextCursor' }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /customers/{id}:
    get:
      tags: [Customers]
      operationId: getCustomer
      summary: Obtener un cliente
      x-scopes: [customers:read]
      parameters:
        - $ref: '#/components/parameters/uuidId'
      responses:
        '200':
          description: El cliente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /drivers:
    get:
      tags: [Drivers]
      operationId: listDrivers
      summary: Listar conductores
      description: Conductores de la flota de la organización. Orden `created_at` descendente. Paginación por cursor.
      x-scopes: [drivers:read]
      parameters:
        - $ref: '#/components/parameters/active'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Página de conductores.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Driver' } }
                  next_cursor: { $ref: '#/components/schemas/NextCursor' }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /drivers/{id}:
    get:
      tags: [Drivers]
      operationId: getDriver
      summary: Obtener un conductor
      x-scopes: [drivers:read]
      parameters:
        - $ref: '#/components/parameters/uuidId'
      responses:
        '200':
          description: El conductor.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Driver' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /vehicles:
    get:
      tags: [Vehicles]
      operationId: listVehicles
      summary: Listar vehículos
      description: Vehículos de la organización. Orden `created_at` descendente. Paginación por cursor.
      x-scopes: [vehicles:read]
      parameters:
        - $ref: '#/components/parameters/active'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Página de vehículos.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Vehicle' } }
                  next_cursor: { $ref: '#/components/schemas/NextCursor' }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /vehicles/{id}:
    get:
      tags: [Vehicles]
      operationId: getVehicle
      summary: Obtener un vehículo
      x-scopes: [vehicles:read]
      parameters:
        - $ref: '#/components/parameters/uuidId'
      responses:
        '200':
          description: El vehículo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: Listar eventos
      description: |
        El registro de eventos de la organización, el mismo del que salen los
        webhooks. Sin datos personales: id, tipo, recurso, qué columnas
        cambiaron y el estado anterior. Sirve para reconciliar cuando un
        webhook no llegó. Los eventos `webhook.test` no aparecen.

        Solo hay filas si la organización tiene activada la salida de eventos
        (bandera `integraciones_outbox`).
      x-scopes: [events:read]
      parameters:
        - name: type
          in: query
          description: Tipo de evento (`booking.completed`…).
          schema: { $ref: '#/components/schemas/EventType' }
        - name: resource_id
          in: query
          description: UUID del recurso (hoy siempre una reserva).
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Página de eventos, del más reciente al más antiguo.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Event' } }
                  next_cursor: { $ref: '#/components/schemas/NextCursor' }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: Listar webhooks
      description: Los webhooks de **esta integración**, sin paginación (la API no deja crear más de 10 por integración).
      x-scopes: [webhooks:read]
      responses:
        '200':
          description: Lista de webhooks.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Crear un webhook
      description: |
        Crea un destino. La respuesta incluye `secret` **una sola vez**: no se
        vuelve a mostrar ni se puede recuperar (se guarda cifrado). La URL debe
        ser `https://`, puerto 443, sin usuario ni contraseña, y resolver solo a
        direcciones públicas; se vuelve a comprobar antes de cada entrega.
        Máximo 10 webhooks por integración: el undécimo es `400 validation_error`.
        Admite `Idempotency-Key` (el `409` de abajo es solo el de idempotencia).
        Suscribirse a eventos `booking.*` (o a `*`) exige además `bookings:read`:
        sin él la respuesta es `403 insufficient_scope`. Un webhook entrega la
        reserva, así que pide el mismo permiso que leerla; si a la integración se
        le retira `bookings:read`, las entregas llegan con `data: null`.
      x-scopes: [webhooks:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
            example:
              url: https://erp.ejemplo.com/hooks/wat
              events: [booking.completed, booking.cancelled]
              description: ERP · servicios terminados
      responses:
        '201':
          description: Webhook creado, con su secreto de firma.
          headers:
            Idempotent-Replayed: { $ref: '#/components/headers/Idempotent-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /webhooks/{id}:
    parameters:
      - $ref: '#/components/parameters/uuidId'
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Obtener un webhook
      x-scopes: [webhooks:read]
      responses:
        '200':
          description: El webhook.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Cambiar un webhook
      description: |
        Cambia `url`, `events`, `description` o `active`. Poner `active: true`
        reactiva un webhook desactivado por fallos y pone su contador a cero.
        Un cuerpo sin ningún campo es `400`.
      x-scopes: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookUpdate' }
            example: { active: true }
      responses:
        '200':
          description: El webhook actualizado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Borrar un webhook
      description: Borra el destino y su historial de entregas.
      x-scopes: [webhooks:write]
      responses:
        '200':
          description: Borrado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deleted' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Enviar un evento de prueba
      description: |
        Encola un evento `webhook.test` para este destino. Lo entrega el mismo
        worker que los eventos reales, firmado igual, normalmente en menos de
        un minuto. La respuesta es `202`: la entrega se consulta en
        `GET /webhooks/{id}/deliveries`.

        Tope propio: **5 pruebas por minuto y por DESTINO**, no por webhook. El
        contador va por el `host` de la URL, así que varios webhooks que apunten
        al mismo servidor comparten las cinco: la API no puede usarse para
        amplificar tráfico contra un tercero. Al superarlo, `429 rate_limited`
        con `Retry-After`.

        Si la organización tiene los webhooks desactivados, responde
        `403 api_disabled` y no encola nada: gestionar los webhooks sigue
        permitido, lo que se corta es la entrega.
      x-scopes: [webhooks:write]
      parameters:
        - $ref: '#/components/parameters/uuidId'
      responses:
        '202':
          description: Evento de prueba encolado.
          content:
            application/json:
              schema:
                type: object
                required: [delivery_id, event_id, status]
                properties:
                  delivery_id: { type: string, format: uuid }
                  event_id: { type: string, format: uuid }
                  status: { type: string, const: pending }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotar el secreto de firma (con solape)
      description: |
        Genera un secreto de firma nuevo para este destino y lo devuelve **una
        sola vez**, igual que al crear el webhook.

        `overlap_minutes` es **obligatorio** y es la ventana de solape: durante
        esos minutos WAT firma con el secreto NUEVO y manda además la firma con
        el ANTERIOR, separadas por coma en `X-WAT-Signature`
        (`v1=<nueva>,v1=<vieja>`). Tu verificador debe partir la cabecera por
        comas y aceptar si **alguna** firma cuadra con tu secreto; así puedes
        desplegar el secreto nuevo cuando quieras dentro de la ventana y no
        perder ni un evento ni recibir nada duplicado.

        Con `overlap_minutes: 0` el secreto anterior deja de valer en el acto.
        Pasada la ventana solo viaja la firma nueva. No se guarda más de un
        secreto anterior: rotar dos veces dentro de la misma ventana descarta el
        más viejo de los tres.

        **Manda siempre `Idempotency-Key`.** Un reintento sin ella (tras un
        timeout, por ejemplo) rota OTRA VEZ, y el secreto que tienes desplegado
        sale del solape. Con la clave, el reintento devuelve la réplica
        (`Idempotent-Replayed: true`, `secret: null`, `secret_shown_once: true`)
        y no cambia nada.
      x-scopes: [webhooks:write]
      parameters:
        - $ref: '#/components/parameters/uuidId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookRotateSecret' }
            example: { overlap_minutes: 60 }
      responses:
        '200':
          description: Secreto rotado. `secret` es el nuevo y no se vuelve a mostrar.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookRotated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Listar entregas de un webhook
      description: Las últimas entregas del destino, de la más reciente a la más antigua. Sin cursor; `limit` como en el resto de listas (1–100, por defecto 25; fuera de rango o no entero es `400`).
      x-scopes: [webhooks:read]
      parameters:
        - $ref: '#/components/parameters/uuidId'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Entregas.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

# ═══════════════════════════════════════════════════════════════════════════
# WEBHOOKS (lo que WAT envía a tu servidor)
# ═══════════════════════════════════════════════════════════════════════════
webhooks:
  bookingEvent:
    post:
      summary: Evento de reserva
      description: |
        `POST` a la URL del webhook con el cuerpo JSON de abajo, firmado. El
        cuerpo se construye en el momento de la entrega con los scopes de la
        **integración**: sin `passengers:read` nunca llega el pasajero; sin
        `pricing:read` nunca llegan los precios.

        Responde `2xx` en menos de 10 segundos. Cualquier otra cosa se
        reintenta (ver `/developers/webhooks/retries`). Las redirecciones
        (3xx) no se siguen y cuentan como fallo.

        Firma: `X-WAT-Signature: v1=<hex(hmac_sha256(secret, X-WAT-Timestamp + "." + cuerpo_crudo))>`.
        `X-WAT-Timestamp` son segundos Unix; rechaza lo que tenga más de 5 minutos.
        Durante la ventana de solape de una rotación de secreto (`POST
        /webhooks/{id}/rotate-secret`) la cabecera lleva DOS firmas separadas por
        coma, la nueva primero: `v1=<nueva>,v1=<anterior>`. Parte por comas y
        acepta si alguna cuadra.

        Un reintento es la MISMA entrega: repite `X-WAT-Event-Id`, `X-WAT-Delivery`
        y el cuerpo; solo cambian `X-WAT-Attempt`, `X-WAT-Timestamp` y la firma.
      parameters:
        - name: X-WAT-Event
          in: header
          required: true
          description: Tipo de evento.
          schema: { $ref: '#/components/schemas/EventType' }
        - name: X-WAT-Event-Id
          in: header
          required: true
          description: UUID del evento. Un mismo evento puede entregarse más de una vez (reintentos); úsalo para deduplicar.
          schema: { type: string, format: uuid }
        - name: X-WAT-Delivery
          in: header
          required: true
          description: UUID de la entrega. Es el MISMO en todos los reintentos de esa entrega (y el `id` de `GET /webhooks/{id}/deliveries`). Coincide con `id` del cuerpo.
          schema: { type: string, format: uuid }
        - name: X-WAT-Attempt
          in: header
          required: true
          description: Número de intento de esta entrega, empezando en 1. Coincide con `attempt` en `GET /webhooks/{id}/deliveries`.
          schema: { type: string, pattern: '^[0-9]+$' }
        - name: X-WAT-Timestamp
          in: header
          required: true
          description: Segundos Unix en el momento de firmar.
          schema: { type: string, pattern: '^[0-9]+$' }
        - name: X-WAT-Signature
          in: header
          required: true
          description: '`v1=` + HMAC-SHA256 en hexadecimal de `timestamp + "." + cuerpo`. Durante el solape de una rotación de secreto van dos, separadas por coma (`v1=<nueva>,v1=<anterior>`); nunca más de dos.'
          schema: { type: string, pattern: '^v1=[0-9a-f]{64}(,v1=[0-9a-f]{64})?$' }
        - name: User-Agent
          in: header
          schema: { type: string, const: WAT-Webhooks/1 }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
            example:
              id: 6d2e1f0a-9b8c-4d7e-a6f5-4c3b2a1d0e9f
              type: booking.completed
              version: 1
              created_at: '2026-09-18T10:42:07.318Z'
              organization: { id: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d }
              resource: { type: booking, id: 0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f }
              changes: [status]
              previous_status: en_curso
              data:
                id: 0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
                reference: '10031234'
                external_id: VLC-2026-000123
                status: completed
                service_type: transfer
                hours: null
                pickup_at: '2026-09-18T08:30:00+00:00'
                end_at: null
                pickup: { address: 'Aeropuerto de Valencia (VLC)', lat: 39.4893, lng: -0.4816 }
                destination: { address: 'Hotel Las Arenas, Valencia', lat: 39.4735, lng: -0.3245 }
                distance_km: 12.4
                flight: { number: VY1234, pending: false }
                passengers: { count: 2, luggage: 2, luggage_big: 2, luggage_small: 0, baby_seats: 0, child_seats: 0, booster_seats: 0, wheelchair: false }
                vehicle_category: sedan
                customer: { id: 7e6d5c4b-3a2b-4c1d-9e8f-7a6b5c4d3e2f, name: Hotel Las Arenas }
                assignment:
                  external: false
                  driver: { id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d, name: Marta Gil, nickname: Marta }
                  vehicle: { id: 5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c, plate: 1234 KLM, model: Mercedes Clase E }
                role: originator_and_executor
                channel: customer_engine
                confirmation: { mode: auto, confirmed_at: '2026-09-17T18:02:11+00:00' }
                event_reference: null
                created_at: '2026-09-17T18:02:11.204Z'
      responses:
        '2XX':
          description: Recibido. Cualquier 2xx cuenta como entregado.
  webhookTest:
    post:
      summary: Evento de prueba
      description: Lo que llega tras `POST /webhooks/{id}/test`. Mismas cabeceras y misma firma; `resource.type` es `webhook` y `data` es `null`.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
            example:
              id: 2b3c4d5e-6f70-4a8b-9c0d-1e2f3a4b5c6d
              type: webhook.test
              version: 1
              created_at: '2026-09-18T10:00:00.000Z'
              organization: { id: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d }
              resource: { type: webhook, id: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f }
              changes: []
              previous_status: null
              data: null
      responses:
        '2XX':
          description: Recibido.

# ═══════════════════════════════════════════════════════════════════════════
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: 'wat_live_<40 caracteres> | wat_test_<40 caracteres>'
      description: |
        `Authorization: Bearer wat_live_…`. La clave pertenece a una integración
        de una organización y lleva sus propios scopes. Solo se guarda su hash:
        si la pierdes, se rota. Cada despliegue es de un solo entorno y una clave
        del otro responde `401 wrong_environment`. Hoy el único servidor público
        es `live`: una clave `wat_test_…` NO abre en ninguna parte (no existe el
        despliegue de pruebas). Ver /developers/environments.

  parameters:
    uuidId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    limit:
      name: limit
      in: query
      description: Tamaño de página, de 1 a 100 (por defecto 25). Un valor mayor que 100 se recorta a 100; uno menor que 1 o no entero es `400`.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    cursor:
      name: cursor
      in: query
      description: El `next_cursor` de la página anterior. Va firmado y ligado al recurso; uno manipulado o de otra lista es `400 invalid_cursor`.
      schema: { type: string }
    active:
      name: active
      in: query
      description: '`true` = solo activos, `false` = solo inactivos, ausente = todos. Cualquier otro valor es `400 validation_error`.'
      schema: { type: string, enum: ['true', 'false'] }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Hasta 200 caracteres, única por integración. Misma clave + mismo cuerpo
        → se devuelve la respuesta guardada con `Idempotent-Replayed: true`.
        Misma clave + otro cuerpo → `409 idempotency_conflict`. Misma clave con
        la primera petición aún en curso → `409 idempotency_in_progress`.
        Se recuerda 24 horas.
      schema: { type: string, maxLength: 200 }

  headers:
    X-WAT-Request-Id:
      description: Identificador de la petición (`req_…`). Va en TODAS las respuestas, también en los errores. Dalo al soporte.
      schema: { type: string }
    X-RateLimit-Limit:
      description: Peticiones por minuto que admite la clave (600). Va en toda respuesta con clave válida, también en un `403 insufficient_scope`.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Las que quedan en este minuto (la menor de los tres cubos — clave, organización y endpoint). Una petición rechazada por scope también cuenta.
      schema: { type: integer }
    Retry-After:
      description: Segundos a esperar antes de reintentar. Solo en `429`.
      schema: { type: integer }
    Idempotent-Replayed:
      description: '`true` cuando la respuesta es la guardada de una petición anterior con la misma `Idempotency-Key`.'
      schema: { type: string, const: 'true' }

  responses:
    BadRequest:
      description: Petición mal formada (`validation_error`, `invalid_cursor`).
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { code: validation_error, message: status no válido, request_id: req_9f8e7d6c5b4a39281706, details: { param: status, allowed: [requested, unconfirmed, confirmed, en_route, in_progress, completed, cancelled, no_show, rejected] } }
    Unauthorized:
      description: Sin clave, clave inválida, revocada, caducada o del otro entorno (`unauthorized`, `invalid_key`, `revoked_key`, `expired_key`, `wrong_environment`).
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { code: invalid_key, message: La clave de API no es válida, request_id: req_9f8e7d6c5b4a39281706 }
    Forbidden:
      description: La clave es válida pero no puede (`insufficient_scope`, `integration_disabled`, `api_disabled`, `forbidden`).
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { code: insufficient_scope, message: 'Esta clave no tiene el scope passengers:read', request_id: req_9f8e7d6c5b4a39281706, details: { required_scope: 'passengers:read' } }
    NotFound:
      description: No existe, o no es de esta organización (la respuesta es la misma).
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { code: not_found, message: Reserva no encontrada, request_id: req_9f8e7d6c5b4a39281706 }
    Conflict:
      description: Conflicto (`conflict`, `idempotency_conflict`, `idempotency_in_progress`).
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: El cuerpo supera 256 KB (`payload_too_large`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: El cuerpo no es `application/json` (`unsupported_media_type`). Un cuerpo sin `Content-Type` también lo es.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Demasiadas peticiones (`rate_limited`). Espera `Retry-After` segundos.
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { code: rate_limited, message: Demasiadas peticiones. Espera y vuelve a intentarlo., request_id: req_9f8e7d6c5b4a39281706, details: { retry_after: 12 } }
    Internal:
      description: Error interno (`internal`). El detalle queda en el servidor con el `request_id`.
      headers:
        X-WAT-Request-Id: { $ref: '#/components/headers/X-WAT-Request-Id' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    # ── errores ──────────────────────────────────────────────────────────
    ErrorCode:
      type: string
      description: |
        Códigos estables. Forman parte del contrato; los mensajes no.
        Tres están reservados y hoy NINGÚN endpoint los emite con este sobre:
        `forbidden` (los 403 reales de v1 son `insufficient_scope`,
        `api_disabled` e `integration_disabled`), `method_not_allowed` (el 405
        lo contesta el servidor antes que la API, sin sobre JSON ni
        `X-WAT-Request-Id`) y `not_implemented` (previsto para operaciones
        anunciadas y aún no disponibles; en v1 no existe ninguna). Se conservan
        para no quitar valores de la enumeración.
      enum:
        - unauthorized
        - invalid_key
        - revoked_key
        - expired_key
        - wrong_environment
        - integration_disabled
        - api_disabled
        - insufficient_scope
        - forbidden
        - not_found
        - method_not_allowed
        - validation_error
        - invalid_cursor
        - payload_too_large
        - unsupported_media_type
        - idempotency_conflict
        - idempotency_in_progress
        - conflict
        - rate_limited
        - internal
        - not_implemented
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string, description: Para una persona. Puede cambiar; no lo interpretes. }
            request_id: { type: string, description: El mismo que `X-WAT-Request-Id`. }
            details:
              type: object
              description: Opcional. Campos como `param`, `field`, `allowed`, `required_scope`, `retry_after`.
              additionalProperties: true

    NextCursor:
      type: [string, 'null']
      description: Cursor de la página siguiente, o `null` si no hay más.

    Deleted:
      type: object
      required: [deleted]
      properties:
        deleted: { type: boolean, const: true }

    # ── enumeraciones públicas ───────────────────────────────────────────
    BookingStatus:
      type: string
      description: |
        Estados públicos (estables). Correspondencia interna:
        requested=solicitada · unconfirmed=no_segura · confirmed=confirmada ·
        en_route=en_camino · in_progress=en_curso · completed=completada ·
        cancelled=cancelada · no_show=no_show · rejected=rechazada.
      enum: [requested, unconfirmed, confirmed, en_route, in_progress, completed, cancelled, no_show, rejected]
    VehicleCategory:
      type: string
      description: 'Turismo/Minivan × Standard/VIP: sedan · van · sedan_vip · van_vip.'
      enum: [sedan, van, sedan_vip, van_vip]
    BookingChannel:
      type: string
      description: Por dónde entró la reserva.
      enum: [customer_engine, public_engine, manual, bulk_upload, recurring, api]
    BookingRole:
      type: string
      description: Qué papel tiene la organización de la clave en la reserva.
      enum: [originator, executor, originator_and_executor]
    EventType:
      type: string
      description: Tipos de evento de reserva. `webhook.test` solo llega tras una prueba y no aparece en `GET /events`.
      enum:
        - booking.created
        - booking.updated
        - booking.assigned
        - booking.started
        - booking.completed
        - booking.cancelled
        - booking.no_show
        - booking.deleted
        - webhook.test
    IntegrationKind:
      type: string
      enum: [erp, pms, crm, billing, fleet, bi, automation, custom]
    Environment:
      type: string
      enum: [test, live]
    Scope:
      type: string
      description: Scopes del dominio de WAT. Los marcados «próximamente» se validan pero no abren nada en v1.
      enum:
        - bookings:read
        - bookings:create
        - bookings:write
        - bookings:cancel
        - customers:read
        - customers:write
        - drivers:read
        - vehicles:read
        - pricing:read
        - billing:read
        - invoices:read
        - payments:read
        - passengers:read
        - tracking:read
        - events:read
        - webhooks:read
        - webhooks:write
        - links:read
        - links:write

    # ── recursos ─────────────────────────────────────────────────────────
    IntegrationMe:
      type: object
      required: [integration, organization, key, environment, scopes, rate_limits, api_version]
      properties:
        integration:
          type: object
          required: [id, name, kind]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            kind: { $ref: '#/components/schemas/IntegrationKind' }
        organization:
          type: object
          required: [id, code, name]
          properties:
            id: { type: string, format: uuid, description: Identificador de la organización (estable). }
            code: { type: [string, 'null'], description: Código de empresa (los primeros dígitos del número de reserva). }
            name: { type: [string, 'null'] }
        key:
          type: object
          required: [prefix, name]
          properties:
            prefix: { type: string, description: 'Los primeros caracteres visibles de la clave (`wat_live_` + 8).' }
            name: { type: string }
        environment: { $ref: '#/components/schemas/Environment' }
        scopes:
          type: array
          items: { $ref: '#/components/schemas/Scope' }
          description: Scopes efectivos = scopes de la clave ∩ scopes de la integración.
        rate_limits:
          type: object
          required: [per_minute]
          properties:
            per_minute:
              type: object
              required: [key, organization, endpoint]
              properties:
                key: { type: integer, description: Por clave. }
                organization: { type: integer, description: Por organización (todas sus claves). }
                endpoint: { type: integer, description: Por clave y endpoint. }
        api_version: { type: string, const: v1 }

    Place:
      type: object
      required: [address, lat, lng]
      properties:
        address: { type: [string, 'null'] }
        lat: { type: [number, 'null'] }
        lng: { type: [number, 'null'] }

    Booking:
      type: object
      description: |
        Una reserva vista desde la organización de la clave. `notes`, las
        observaciones, el chat, el cartel y la posición GPS NO forman parte de
        este recurso.
      required:
        - id
        - reference
        - external_id
        - status
        - service_type
        - hours
        - pickup_at
        - end_at
        - pickup
        - destination
        - distance_km
        - flight
        - passengers
        - vehicle_category
        - customer
        - assignment
        - role
        - channel
        - confirmation
        - event_reference
        - group
        - created_at
      properties:
        id: { type: string, format: uuid }
        reference:
          type: [string, 'null']
          description: Número de reserva de WAT (solo dígitos). Sirve en `GET /bookings/{id}`.
        external_id:
          type: [string, 'null']
          description: Tu identificador para esta reserva, si lo guardaste (por integración).
        status: { $ref: '#/components/schemas/BookingStatus' }
        service_type:
          type: string
          enum: [transfer, hourly]
          description: '`transfer` = traslado · `hourly` = disposición por horas.'
        hours: { type: [number, 'null'], description: Solo en `hourly`. }
        pickup_at: { type: string, format: date-time }
        end_at: { type: [string, 'null'], format: date-time }
        pickup: { $ref: '#/components/schemas/Place' }
        destination:
          oneOf:
            - $ref: '#/components/schemas/Place'
            - type: 'null'
        distance_km: { type: [number, 'null'] }
        flight:
          type: [object, 'null']
          properties:
            number: { type: [string, 'null'] }
            pending: { type: boolean, description: El pasajero aún no ha dado el vuelo. }
        passengers:
          type: object
          required: [count, luggage, luggage_big, luggage_small, baby_seats, child_seats, booster_seats, wheelchair]
          properties:
            count: { type: [integer, 'null'] }
            luggage: { type: [integer, 'null'] }
            luggage_big: { type: [integer, 'null'] }
            luggage_small: { type: [integer, 'null'] }
            baby_seats: { type: [integer, 'null'] }
            child_seats: { type: [integer, 'null'] }
            booster_seats: { type: [integer, 'null'] }
            wheelchair: { type: boolean }
        vehicle_category:
          oneOf:
            - $ref: '#/components/schemas/VehicleCategory'
            - type: 'null'
        customer:
          type: [object, 'null']
          description: El cliente por el que entró (`name` es `null` si el cliente ya no es legible).
          properties:
            id: { type: string, format: uuid }
            name: { type: [string, 'null'] }
        assignment:
          type: object
          required: [external, driver, vehicle]
          description: Conductor y vehículo asignados. Si la ejecuta OTRA empresa (bolsa), `external` es `true` y no se exponen su conductor ni su vehículo.
          properties:
            external: { type: boolean }
            driver:
              type: [object, 'null']
              properties:
                id: { type: string, format: uuid }
                name: { type: [string, 'null'] }
                nickname: { type: [string, 'null'] }
            vehicle:
              type: [object, 'null']
              properties:
                id: { type: string, format: uuid }
                plate: { type: [string, 'null'] }
                model: { type: [string, 'null'] }
        role: { $ref: '#/components/schemas/BookingRole' }
        channel:
          oneOf:
            - $ref: '#/components/schemas/BookingChannel'
            - type: 'null'
        confirmation:
          type: object
          properties:
            mode: { type: [string, 'null'] }
            confirmed_at: { type: [string, 'null'], format: date-time }
        event_reference: { type: [string, 'null'], description: Texto libre de referencia (evento, boda, cuenta) que escribió quien dio de alta la reserva. No es el grupo. }
        group:
          type: [object, 'null']
          description: |
            El **grupo** que el cliente creó para juntar reservas al facturar («Congreso Deloitte»).
            Es una entidad con identificador estable: renombrarla no cambia `id`. `null` = sin grupo,
            o el grupo pertenece al cliente de otra empresa (bolsa) y no se expone. Un cambio de grupo
            emite `booking.updated` con `changed: ["booking_group_id"]`.
          required: [id, name]
          properties:
            id: { type: string, format: uuid }
            name: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        passenger:
          type: object
          description: '**Solo con el scope `passengers:read`.** Si la clave no lo tiene, la propiedad no existe.'
          x-requires-scope: passengers:read
          properties:
            name: { type: [string, 'null'] }
            phone: { type: [string, 'null'] }
            email: { type: [string, 'null'] }
        pricing:
          description: |
            **Solo con el scope `pricing:read`.** Si la clave no lo tiene, la propiedad no existe.

            Su forma depende de `role`:
            - `originator` u `originator_and_executor` → `PricingOriginator`: el precio
              completo de la reserva.
            - `executor` (la reserva es de otra empresa y la ejecutas tú, bolsa) →
              `PricingExecutor`: solo tu neto (`net_executor`). El precio del originador y
              el de venta de su cliente no se exponen. Es la misma regla que aplica el panel.
          x-requires-scope: pricing:read
          oneOf:
            - $ref: '#/components/schemas/PricingOriginator'
            - $ref: '#/components/schemas/PricingExecutor'

    PricingOriginator:
      type: object
      description: '`pricing` cuando `role` es `originator` u `originator_and_executor`.'
      required: [currency, total, vat_pct, sale_total, extras, diet, parking, night_surcharge, payment_mode, payment_status, paid_at]
      properties:
        currency: { type: string, const: EUR }
        total: { type: [number, 'null'], description: Precio total de la reserva. }
        vat_pct: { type: [number, 'null'] }
        sale_total: { type: [number, 'null'], description: Precio de venta cuando difiere del total (bolsa). }
        extras:
          type: array
          items:
            type: object
            properties:
              name: { type: [string, 'null'] }
              amount: { type: [number, 'null'] }
              per_passenger: { type: boolean }
        diet: { type: [number, 'null'] }
        parking: { type: [number, 'null'] }
        night_surcharge: { type: [number, 'null'] }
        payment_mode: { type: [string, 'null'] }
        payment_status: { type: [string, 'null'] }
        paid_at: { type: [string, 'null'], format: date-time }

    PricingExecutor:
      type: object
      description: '`pricing` cuando `role` es `executor`: solo el neto que cobra tu empresa por ejecutar el servicio.'
      required: [currency, net_executor]
      additionalProperties: false
      properties:
        currency: { type: string, const: EUR }
        net_executor: { type: [number, 'null'], description: Lo que cobra tu empresa por ejecutar este servicio (neto). }

    Customer:
      type: object
      required: [id, external_id, name, type, active, group, created_at]
      properties:
        id: { type: string, format: uuid }
        external_id: { type: [string, 'null'], description: Tu id para este cliente (por integración). }
        name: { type: [string, 'null'] }
        type:
          type: string
          enum: [account, public_engine]
          description: '`account` = cliente con cuenta (hotel, agencia…) · `public_engine` = motor público.'
        active: { type: boolean }
        group:
          type: [object, 'null']
          properties:
            id: { type: string, format: uuid }
            name: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }

    Driver:
      type: object
      required: [id, external_id, name, nickname, phone, active, status, has_app_access, created_at]
      properties:
        id: { type: string, format: uuid }
        external_id: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        nickname: { type: [string, 'null'] }
        phone: { type: [string, 'null'] }
        active: { type: boolean }
        status: { type: [string, 'null'], description: Estado operativo del conductor tal como lo mantiene la organización. }
        has_app_access: { type: boolean, description: Tiene acceso a la app del conductor. }
        created_at: { type: string, format: date-time }

    Vehicle:
      type: object
      required: [id, external_id, plate, model, nickname, category, seats, luggage, wheelchair_adapted, eco_label, propulsion, active, created_at]
      properties:
        id: { type: string, format: uuid }
        external_id: { type: [string, 'null'] }
        plate: { type: [string, 'null'] }
        model: { type: [string, 'null'] }
        nickname: { type: [string, 'null'] }
        category:
          oneOf:
            - $ref: '#/components/schemas/VehicleCategory'
            - type: 'null'
        seats: { type: [integer, 'null'] }
        luggage: { type: [integer, 'null'] }
        wheelchair_adapted: { type: boolean }
        eco_label: { type: [string, 'null'] }
        propulsion: { type: [string, 'null'] }
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    Event:
      type: object
      required: [id, type, version, created_at, resource, changes, previous_status, reference]
      properties:
        id: { type: string, format: uuid }
        type: { $ref: '#/components/schemas/EventType' }
        version: { type: integer, const: 1 }
        created_at: { type: string, format: date-time, description: Cuándo ocurrió. }
        resource:
          type: object
          required: [type, id]
          properties:
            type: { type: string, enum: [booking] }
            id: { type: string, format: uuid }
        changes:
          type: array
          items: { type: string }
          description: Nombres de las columnas internas que cambiaron (`status`, `pickup_at`, `executor_vehicle_id`…). Vacío en `booking.created`.
        previous_status:
          type: [string, 'null']
          description: Estado interno anterior (`confirmada`, `en_curso`…). Es el enum interno, no el público.
        reference: { type: [string, 'null'], description: Número de reserva de WAT. }

    ExternalIdLink:
      type: object
      required: [id, entity, external_id, created_at, updated_at]
      properties:
        id: { type: string, format: uuid, description: El id de WAT. }
        entity: { type: string, enum: [booking, customer, driver, vehicle] }
        external_id: { type: [string, 'null'], description: '`null` si no hay enlace.' }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }

    Webhook:
      type: object
      required: [id, url, events, active, description, consecutive_failures, disabled_at, disabled_reason, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
          description: Tipos suscritos, o `["*"]` para todos.
        active: { type: boolean }
        description: { type: [string, 'null'] }
        consecutive_failures: { type: integer, description: Fallos seguidos. A 100 el webhook se desactiva solo. }
        disabled_at: { type: [string, 'null'], format: date-time }
        disabled_reason: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookWithSecret:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret:
              type: [string, 'null']
              description: |
                `whsec_…`. Solo en la respuesta de creación de verdad. Guárdalo: no se vuelve a enseñar.

                **Es `null` en la réplica por `Idempotency-Key`** (la respuesta que llega con
                `Idempotent-Replayed: true`), y entonces el cuerpo lleva además
                `secret_shown_once: true`. El secreto no se guarda en la tabla de idempotencia,
                así que un reintento con la misma clave NO lo recupera: si se perdió, hay que
                crear otro webhook. Comprueba `secret` antes de guardarlo.
            secret_shown_once:
              type: boolean
              description: Solo aparece en la réplica por `Idempotency-Key`. Significa que el secreto ya se enseñó en la primera respuesta y no se repite.
    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, maxLength: 500, description: '`https://`, puerto 443, sin credenciales, destino público.' }
        events:
          type: array
          minItems: 1
          items: { type: string }
          description: Tipos de `EventType` (sin `webhook.test`) o `"*"`.
        description: { type: string, maxLength: 200 }
    WebhookRotateSecret:
      type: object
      required: [overlap_minutes]
      properties:
        overlap_minutes:
          type: integer
          minimum: 0
          maximum: 1440
          description: |
            Minutos durante los que las entregas llevarán TAMBIÉN la firma con el
            secreto anterior. `0` = sin solape (el viejo deja de valer ya). No hay
            valor por defecto: la ventana la decide quien va a desplegar.
    WebhookRotated:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret, previous_secret_valid_until]
          properties:
            secret:
              type: [string, 'null']
              description: '`whsec_…` nuevo. No se vuelve a enseñar. Es `null` en la réplica por `Idempotency-Key` (entonces llega `secret_shown_once: true`).'
            secret_shown_once:
              type: boolean
              description: Solo en la réplica por `Idempotency-Key`. El secreto ya se enseñó en la primera respuesta.
            previous_secret_valid_until:
              type: [string, 'null']
              format: date-time
              description: Hasta cuándo viaja también la firma con el secreto anterior. `null` si no hay solape.
    WebhookUpdate:
      type: object
      minProperties: 1
      properties:
        url: { type: string, format: uri, maxLength: 500 }
        events: { type: array, minItems: 1, items: { type: string } }
        description: { type: [string, 'null'], maxLength: 200 }
        active: { type: boolean, description: '`true` reactiva y pone `consecutive_failures` a 0.' }

    WebhookDelivery:
      type: object
      required: [id, event_id, event_type, status, attempt, max_attempts, next_retry_at, last_status, last_error, last_duration_ms, delivered_at, created_at]
      properties:
        id: { type: string, format: uuid }
        event_id: { type: string, format: uuid }
        event_type:
          oneOf:
            - $ref: '#/components/schemas/EventType'
            - type: 'null'
        status:
          type: string
          enum: [pending, delivering, succeeded, failed, dead]
          description: '`failed` = se reintentará · `dead` = agotados los intentos o destino no permitido.'
        attempt: { type: integer }
        max_attempts: { type: integer, description: Siempre 8. Un fallo permanente (3xx o 4xx distinto de 408/429) se agota a los 3 intentos aunque este campo diga 8. }
        next_retry_at: { type: [string, 'null'], format: date-time }
        last_status: { type: [integer, 'null'], description: Código HTTP del último intento; `null` si fue timeout o error de red. }
        last_error: { type: [string, 'null'] }
        last_duration_ms: { type: [integer, 'null'] }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookPayload:
      type: object
      description: El cuerpo de todo webhook, versionado.
      required: [id, type, version, created_at, organization, resource, changes, previous_status, data]
      properties:
        id: { type: string, format: uuid, description: Id de la entrega (igual que `X-WAT-Delivery`). }
        type: { $ref: '#/components/schemas/EventType' }
        version: { type: integer, const: 1 }
        created_at: { type: string, format: date-time, description: Cuándo ocurrió el evento. }
        organization:
          type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }
        resource:
          type: object
          required: [type, id]
          properties:
            type: { type: string, enum: [booking, webhook] }
            id: { type: string, format: uuid }
        changes:
          type: array
          items: { type: string }
          description: Columnas internas que cambiaron. Las del pasajero solo aparecen con `passengers:read`.
        previous_status: { type: [string, 'null'], description: Estado interno anterior. }
        data:
          description: La reserva completa según los scopes de la integración (`Booking`), o `null` (evento de prueba o reserva ya no legible).
          oneOf:
            - $ref: '#/components/schemas/Booking'
            - type: 'null'
