openapi: 3.1.0
info:
  title: AndesRetail Interop API
  version: 1.0.0
  summary: API de la capa de interoperabilidad de AndesRetail
  description: |
    Stock, precios, órdenes y suscripciones de webhooks para canales y partners.
    Diseño en docs/to-be/07-contratos-api.md.

    Tope del cuerpo: 64 KiB (65 536 bytes) por petición, en toda operación con
    cuerpo (createOrder, createWebhookSubscription, receivePaymentCallback). Un
    cuerpo de más de 65 536 bytes recibe 413 PAYLOAD_TOO_LARGE del gateway de
    entrada, antes de llegar al servicio (ADR-016). Una orden de 50 líneas ocupa
    unos pocos KiB.
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
  - url: https://api.andesretail.com
    description: Demo
  - url: http://localhost:8080
    description: Local
tags:
  - name: Stock
    description: Disponibilidad vendible por SKU, tienda y canal
  - name: Prices
    description: Precio por SKU, canal y cliente
  - name: Orders
    description: Creación y consulta de órdenes
  - name: Webhooks
    description: Suscripciones a notificaciones
  - name: Callbacks
    description: Notificaciones entrantes de terceros (pasarela de pagos), sin JWT
security:
  - oauth2: []
paths:
  /v1/stock:
    get:
      operationId: getStock
      tags: [Stock]
      summary: Consulta stock vendible por SKU, tienda y canal
      security:
        - oauth2: [stock:read]
      parameters:
        - $ref: '#/components/parameters/Sku'
        - name: storeId
          in: query
          required: true
          description: Tienda o bodega. Vacío (storeId=), 400 VALIDATION_ERROR
          schema: { type: string, minLength: 1, examples: [BOG-01] }
        - $ref: '#/components/parameters/Channel'
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Stock por SKU
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockResponse' }
              examples:
                ok:
                  value:
                    items:
                      - sku: SKU-1001
                        storeId: BOG-01
                        channel: mkp-andino
                        available: 42
                        status: IN_STOCK
                        asOf: '2026-11-10T18:04:11Z'
                        stale: false
                    notFound: []
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Problem' }
  /v1/prices:
    get:
      operationId: getPrices
      tags: [Prices]
      summary: Consulta precio por SKU, canal y cliente
      security:
        - oauth2: [prices:read]
      parameters:
        - $ref: '#/components/parameters/Sku'
        - $ref: '#/components/parameters/Channel'
        - name: customerId
          in: query
          required: false
          description: Identificador opaco del cliente; define la lista de precios. Sin él se usa la lista por defecto del canal
          schema: { type: string, examples: [cus_8f3a21] }
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Precio por SKU
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceResponse' }
              examples:
                ok:
                  value:
                    items:
                      - sku: SKU-1001
                        channel: mkp-andino
                        priceList: DEFAULT
                        currency: COP
                        listPrice: '189900.00'
                        salePrice: '159900.00'
                        taxIncluded: true
                        validFrom: '2026-11-08T05:00:00Z'
                        validTo: '2026-11-15T04:59:59Z'
                        priceVersion: pv_01JC8Z4T6M
                        asOf: '2026-11-10T18:03:40Z'
                        stale: false
                    notFound: []
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Problem' }
  /v1/orders:
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Crea una orden
      description: |
        Valida catálogo y precio, reserva stock y responde 202. El cobro y la
        confirmación siguen de forma asíncrona; el resultado llega por webhook
        y se consulta en GET /v1/orders/{orderId}.

        Un campo de texto libre (nombre, correo, dirección, payment.token) con
        13 a 19 dígitos que pasan Luhn recibe 400 VALIDATION_ERROR: la capa no
        recibe números de tarjeta. Los identificadores no se revisan así; tienen
        su formato (schema Identifier, ADR-026).
      security:
        - oauth2: [orders:write]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            UUID generado por el cliente. Alcance por partner, 24 horas. El contrato
            y el canal se validan antes de reservar la llave: un 400 o un 403 del
            canal no la consumen, y el cliente reintenta con la misma llave y el
            cuerpo corregido. Se guarda el resultado de una petición válida
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        description: Hasta 64 KiB (65 536 bytes); más, 413 PAYLOAD_TOO_LARGE
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrderRequest' }
            examples:
              delivery:
                value:
                  externalOrderId: MKP-778899
                  customer:
                    customerId: cus_8f3a21
                    name: Laura Gómez
                    email: laura@example.com
                    phone: '+573001234567'
                  fulfillment:
                    type: DELIVERY
                    storeId: BOG-01
                    address:
                      line1: 'Calle 10 # 5-20'
                      city: Bogotá
                      region: DC
                      country: CO
                  lines:
                    - sku: SKU-1001
                      quantity: 2
                      unitPrice: '159900.00'
                      priceVersion: pv_01JC8Z4T6M
                  payment:
                    mode: CAPTURE
                    method: CARD
                    token: tok_4f9a7c2e
                    amount: '319800.00'
                    currency: COP
      responses:
        '200':
          description: Ya existía una orden con ese externalOrderId para el partner; se devuelve
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '202':
          description: Orden aceptada, stock reservado, cobro en curso
          headers:
            Location:
              schema: { type: string }
            X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
            Idempotent-Replayed:
              description: true cuando la respuesta es la repetición de una petición anterior
              schema: { type: boolean }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
              examples:
                accepted:
                  value:
                    orderId: ord_01JC8ZB7Q2X9
                    externalOrderId: MKP-778899
                    channel: mkp-andino
                    status: PENDING_PAYMENT
                    reservation: { expiresAt: '2026-11-10T18:19:30Z' }
                    total: { amount: '319800.00', currency: COP }
                    createdAt: '2026-11-10T18:04:30Z'
                    updatedAt: '2026-11-10T18:04:30Z'
                    links: { self: /v1/orders/ord_01JC8ZB7Q2X9 }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '409': { $ref: '#/components/responses/Problem' }
        '413': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Problem' }
  /v1/orders/{orderId}:
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Consulta el estado de una orden
      security:
        - oauth2: [orders:read]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string, examples: [ord_01JC8ZB7Q2X9] }
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Orden
          headers:
            X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '404': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/webhooks/subscriptions:
    post:
      operationId: createWebhookSubscription
      tags: [Webhooks]
      summary: Crea una suscripción de webhooks
      security:
        - oauth2: [webhooks:manage]
      requestBody:
        required: true
        description: Hasta 64 KiB (65 536 bytes); más, 413 PAYLOAD_TOO_LARGE
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSubscriptionRequest' }
      responses:
        '201':
          description: Suscripción creada. El secreto solo se muestra en esta respuesta
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionCreated' }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '413': { $ref: '#/components/responses/Problem' }
    get:
      operationId: listWebhookSubscriptions
      tags: [Webhooks]
      summary: Lista las suscripciones del partner
      security:
        - oauth2: [webhooks:manage]
      responses:
        '200':
          description: Suscripciones
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /v1/webhooks/subscriptions/{subscriptionId}:
    delete:
      operationId: deleteWebhookSubscription
      tags: [Webhooks]
      summary: Elimina una suscripción
      security:
        - oauth2: [webhooks:manage]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Eliminada
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '404': { $ref: '#/components/responses/Problem' }
  /v1/callbacks/payments/{provider}:
    post:
      operationId: receivePaymentCallback
      tags: [Callbacks]
      summary: Webhook entrante de la pasarela de pagos
      description: >-
        Grupo callbacks del gateway (docs/to-be/12-gateway-modular.md): sin JWT;
        la confianza viene de la firma de la pasarela y de la lista de IP de
        origen. payment-service verifica la firma y confirma el resultado contra
        la API de la pasarela antes de cambiar el estado (08, amenaza 3). Un
        evento ya procesado responde 200 sin un segundo cambio. Un evento bien
        firmado que no trae un cobro (por ejemplo balance.available de Stripe)
        responde 200 y se descarta sin cambiar nada. Un cobro que la capa no
        conoce responde 404, para que la pasarela lo reenvíe. Solo una firma
        ausente, mala o vencida responde 401; un cuerpo bien firmado que no es
        JSON o no trae id y type responde 400
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string, enum: [simulated, stripe] }
        - name: X-Gateway-Signature
          in: header
          required: false
          description: Firma de la pasarela simulada. Obligatoria con provider simulated
          schema: { type: string, maxLength: 512 }
        - name: Stripe-Signature
          in: header
          required: false
          description: Firma de Stripe. Obligatoria con provider stripe
          schema: { type: string, maxLength: 512 }
      requestBody:
        required: true
        description: Hasta 64 KiB (65 536 bytes); más, 413 PAYLOAD_TOO_LARGE
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCallback' }
      responses:
        '200':
          description: Recibido, ya procesado antes, o sin cobro que le interese a la capa
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '404': { $ref: '#/components/responses/Problem' }
        '413': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/Problem' }
webhooks:
  orderConfirmed:
    post:
      operationId: onOrderConfirmed
      summary: Orden confirmada
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
  paymentApproved:
    post:
      operationId: onPaymentApproved
      summary: Pago aprobado
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
  paymentRejected:
    post:
      operationId: onPaymentRejected
      summary: Pago rechazado
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
  dispatchStarted:
    post:
      operationId: onDispatchStarted
      summary: Despacho iniciado
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
  deliveryConfirmed:
    post:
      operationId: onDeliveryConfirmed
      summary: Entrega confirmada
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
  orderCancelled:
    post:
      operationId: onOrderCancelled
      summary: Orden cancelada
      requestBody:
        content:
          application/cloudevents+json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Recibido }
components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: OAuth2 Client Credentials contra Keycloak
      flows:
        clientCredentials:
          tokenUrl: https://auth.andesretail.com/realms/andesretail/protocol/openid-connect/token
          scopes:
            stock:read: Consultar stock
            prices:read: Consultar precios
            orders:write: Crear órdenes
            orders:read: Consultar órdenes
            webhooks:manage: Administrar suscripciones de webhooks
  parameters:
    Sku:
      name: sku
      in: query
      required: true
      description: >-
        Uno o más SKU, repitiendo el parámetro (sku=A&sku=B). Máximo 50. Cada valor
        se toma entero: una coma es parte del SKU, no un separador. Un valor vacío
        (sku=) es 400 VALIDATION_ERROR; un SKU que no existe o está fuera del
        surtido del canal va en notFound
      schema:
        type: array
        minItems: 1
        maxItems: 50
        items: { type: string, minLength: 1 }
      style: form
      explode: true
    Channel:
      name: channel
      in: query
      required: false
      description: Solo si el partner tiene varios canales. Debe ser uno de los suyos. Por defecto, el canal del token
      schema: { type: string, examples: [mkp-andino] }
    CorrelationId:
      name: X-Correlation-Id
      in: header
      required: false
      description: Si falta, lo genera el gateway. Siempre vuelve en la respuesta
      schema: { type: string, maxLength: 64 }
  headers:
    X-Correlation-Id:
      schema: { type: string }
    RateLimit-Limit:
      description: Cuota del partner en la ventana actual
      schema: { type: integer }
    RateLimit-Remaining:
      schema: { type: integer }
    Retry-After:
      description: Segundos de espera antes de reintentar
      schema: { type: integer }
  responses:
    Problem:
      description: >-
        Error en formato Problem Details (RFC 9457). Retry-After va solo en los errores que se
        reintentan: 409 REQUEST_IN_PROGRESS y 503 DEPENDENCY_UNAVAILABLE.
      headers:
        X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            insufficientStock:
              value:
                type: https://api.andesretail.com/problems/insufficient-stock
                title: Stock insuficiente
                status: 409
                code: INSUFFICIENT_STOCK
                detail: No hay unidades suficientes para una o más líneas.
                instance: /v1/orders
                correlationId: 5e1d0c7a-93b2-4c41-8d0e-6a7f2b9c3d15
                errors:
                  - field: 'lines[0].quantity'
                    code: INSUFFICIENT_STOCK
                    message: 'Disponible: 1'
    RateLimited:
      description: Cuota agotada
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-Correlation-Id: { $ref: '#/components/headers/X-Correlation-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
  schemas:
    Money:
      type: string
      pattern: '^\d+\.\d{2}$'
      description: Decimal como texto. Nunca punto flotante
      examples: ['159900.00']
    Currency:
      type: string
      pattern: '^[A-Z]{3}$'
      description: ISO 4217
      examples: [COP]
    Identifier:
      type: string
      minLength: 1
      maxLength: 64
      pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'
      description: >-
        Identificador en una petición: letras ASCII, dígitos y . _ : -, empieza por
        letra o dígito, hasta 64 caracteres. Fuera del formato, 400 VALIDATION_ERROR
        con el campo en errors[].field. Un campo con este formato no pasa por el
        detector de números de tarjeta, que revisa solo el texto libre (ADR-026)
      examples: [MKP-778899]
    StockResponse:
      type: object
      required: [items, notFound]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/StockItem' }
        notFound:
          type: array
          description: SKU inexistentes o no habilitados para el canal
          items: { type: string }
    StockItem:
      type: object
      required: [sku, storeId, channel, available, status, asOf, stale]
      properties:
        sku: { type: string }
        storeId: { type: string }
        channel: { type: string }
        available:
          type: integer
          minimum: 0
          description: Vendible para el canal. Ya aplica reservas, colchón y tope visible
        status:
          type: string
          enum: [IN_STOCK, LOW_STOCK, OUT_OF_STOCK]
        asOf:
          type: string
          format: date-time
          description: 'Vigente hasta: el más reciente entre el momento del dato y la última sincronización confirmada de su sistema de origen (ADR-018). No es la fecha del último cambio'
        stale:
          type: boolean
          description: true si el dato supera el umbral de desactualización
    PriceResponse:
      type: object
      required: [items, notFound]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PriceItem' }
        notFound:
          type: array
          items: { type: string }
    PriceItem:
      type: object
      required: [sku, channel, priceList, currency, listPrice, salePrice, taxIncluded, priceVersion, asOf, stale]
      properties:
        sku: { type: string }
        channel: { type: string }
        priceList: { type: string }
        currency: { $ref: '#/components/schemas/Currency' }
        listPrice: { $ref: '#/components/schemas/Money' }
        salePrice: { $ref: '#/components/schemas/Money' }
        taxIncluded: { type: boolean }
        validFrom: { type: string, format: date-time }
        validTo: { type: string, format: date-time }
        priceVersion:
          type: string
          description: Se envía en la orden para validar el precio
        asOf:
          type: string
          format: date-time
          description: 'Vigente hasta: el más reciente entre el momento del dato y la última sincronización confirmada de su sistema de origen (ADR-018). No es la fecha del último cambio'
        stale:
          type: boolean
          description: 'true si asOf supera el umbral de desactualización del precio (30 minutos)'
    CreateOrderRequest:
      type: object
      additionalProperties: false
      required: [externalOrderId, customer, fulfillment, lines, payment]
      properties:
        externalOrderId:
          $ref: '#/components/schemas/Identifier'
          description: Identificador de la orden en el canal. Único por partner
          examples: [MKP-778899, WEB-0b8f6f0e-6a1b-4d5c-9c59-0d6f3f0a2b77]
        channel:
          $ref: '#/components/schemas/Identifier'
          description: Solo si el partner tiene varios canales
        customer: { $ref: '#/components/schemas/Customer' }
        fulfillment: { $ref: '#/components/schemas/Fulfillment' }
        lines:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/OrderLineRequest' }
        payment: { $ref: '#/components/schemas/PaymentRequest' }
    Customer:
      type: object
      additionalProperties: false
      required: [name]
      description: Datos personales. Se guardan cifrados y no salen en eventos ni logs
      properties:
        customerId: { $ref: '#/components/schemas/Identifier' }
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email, maxLength: 254 }
        phone: { type: string, maxLength: 20 }
    Fulfillment:
      type: object
      additionalProperties: false
      required: [type, storeId]
      properties:
        type:
          type: string
          enum: [DELIVERY, PICKUP]
        storeId:
          $ref: '#/components/schemas/Identifier'
          description: Ubicación desde la que se reserva y despacha
        address: { $ref: '#/components/schemas/Address' }
    Address:
      type: object
      additionalProperties: false
      required: [line1, city, country]
      properties:
        line1: { type: string, maxLength: 120 }
        line2: { type: string, maxLength: 120 }
        city: { type: string, maxLength: 60 }
        region: { type: string, maxLength: 60 }
        postalCode: { type: string, maxLength: 12 }
        country: { type: string, pattern: '^[A-Z]{2}$' }
    OrderLineRequest:
      type: object
      additionalProperties: false
      required: [sku, quantity, unitPrice]
      properties:
        sku: { $ref: '#/components/schemas/Identifier' }
        quantity: { type: integer, minimum: 1, maximum: 999 }
        unitPrice: { $ref: '#/components/schemas/Money' }
        priceVersion: { $ref: '#/components/schemas/Identifier' }
    PaymentCallback:
      type: object
      description: >-
        Evento de la pasarela, con la forma de cada proveedor. El contrato fija
        solo lo que la capa usa para deduplicar y clasificar; el resto lo
        traduce payment-service
      required: [id, type]
      additionalProperties: true
      properties:
        id: { type: string, maxLength: 255, description: Identificador del evento en la pasarela. Se deduplica por él }
        type: { type: string, maxLength: 128 }
    PaymentRequest:
      type: object
      additionalProperties: false
      required: [mode, amount, currency]
      properties:
        mode:
          type: string
          enum: [CAPTURE, PREPAID]
          description: CAPTURE cobra con el token de la pasarela. PREPAID indica que el canal ya cobró
        method:
          type: string
          enum: [CARD, PSE, WALLET]
        token:
          type: string
          maxLength: 255
          description: Token de la pasarela. Obligatorio en CAPTURE. Nunca un número de tarjeta
        amount: { $ref: '#/components/schemas/Money' }
        currency: { $ref: '#/components/schemas/Currency' }
    Order:
      type: object
      required: [orderId, externalOrderId, channel, status, total, createdAt, updatedAt, links]
      properties:
        orderId: { type: string }
        externalOrderId: { type: string }
        channel: { type: string }
        status: { $ref: '#/components/schemas/OrderStatus' }
        statusReason:
          type: string
          description: Motivo cuando el estado es CANCELLED
          examples: [PAYMENT_REJECTED]
        reservation:
          type: object
          properties:
            expiresAt: { type: string, format: date-time }
        total:
          type: object
          required: [amount, currency]
          properties:
            amount: { $ref: '#/components/schemas/Money' }
            currency: { $ref: '#/components/schemas/Currency' }
        lines:
          type: array
          items:
            type: object
            required: [sku, quantity, unitPrice]
            properties:
              sku: { type: string }
              quantity: { type: integer }
              unitPrice: { $ref: '#/components/schemas/Money' }
        history:
          type: array
          description: Cambios de estado, del más antiguo al más reciente
          items:
            type: object
            required: [status, at]
            properties:
              status: { $ref: '#/components/schemas/OrderStatus' }
              at: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        links:
          type: object
          required: [self]
          properties:
            self: { type: string }
    OrderStatus:
      type: string
      enum: [PENDING_PAYMENT, CONFIRMED, CANCELLED, DISPATCHED, DELIVERED]
      description: Los clientes deben tolerar valores nuevos
    CreateSubscriptionRequest:
      type: object
      additionalProperties: false
      required: [url, eventTypes]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: >-
            Restricciones de seguridad deliberadas (08, amenaza 7), cada una en 400
            VALIDATION_ERROR con errors[].field url: solo HTTPS; absoluta, con host,
            sin usuario ni contraseña y sin fragmento; host con nombre DNS (letras,
            dígitos, guiones y puntos) o dirección IP: un host con "_" no se admite
            porque ningún certificado TLS público lo cubre y el envío nunca llegaría;
            todas las direcciones a las que resuelve deben ser públicas. Un host que
            todavía no resuelve no es un error: la suscripción se crea, el destino se
            revisa otra vez antes de cada envío y, mientras no resuelva, el envío se
            reintenta
        eventTypes:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WebhookEventType' }
    Subscription:
      type: object
      required: [subscriptionId, url, eventTypes, status, createdAt]
      properties:
        subscriptionId: { type: string }
        url: { type: string, format: uri }
        eventTypes:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventType' }
        status:
          type: string
          enum: [ACTIVE, PAUSED]
        createdAt: { type: string, format: date-time }
    SubscriptionCreated:
      allOf:
        - $ref: '#/components/schemas/Subscription'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: Secreto para verificar la firma HMAC. Solo se entrega aquí
    WebhookEventType:
      type: string
      enum:
        - com.andesretail.order.confirmed.v1
        - com.andesretail.order.cancelled.v1
        - com.andesretail.payment.approved.v1
        - com.andesretail.payment.rejected.v1
        - com.andesretail.fulfillment.dispatch-started.v1
        - com.andesretail.fulfillment.delivered.v1
    WebhookEvent:
      type: object
      description: CloudEvents 1.0 en modo estructurado. Firmado con la cabecera X-Andes-Signature
      required: [specversion, id, type, source, subject, time, data]
      properties:
        specversion: { type: string, const: '1.0' }
        id: { type: string }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        source: { type: string }
        subject:
          type: string
          description: orderId
        time: { type: string, format: date-time }
        datacontenttype: { type: string, const: application/json }
        correlationid: { type: string }
        data:
          type: object
          required: [orderId, externalOrderId, status]
          properties:
            orderId: { type: string }
            externalOrderId: { type: string }
            status: { $ref: '#/components/schemas/OrderStatus' }
            reason: { type: string }
    Problem:
      type: object
      required: [type, title, status, code, correlationId]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum:
            - VALIDATION_ERROR
            - UNAUTHENTICATED
            - INSUFFICIENT_SCOPE
            - CHANNEL_NOT_ALLOWED
            - NOT_FOUND
            - INSUFFICIENT_STOCK
            - REQUEST_IN_PROGRESS
            - UNKNOWN_SKU
            - PRICE_MISMATCH
            - IDEMPOTENCY_KEY_REUSED
            - PAYLOAD_TOO_LARGE
            - RATE_LIMITED
            - DEPENDENCY_UNAVAILABLE
            - INTERNAL_ERROR
        detail: { type: string }
        instance: { type: string }
        correlationId: { type: string }
        errors:
          type: array
          items:
            type: object
            required: [code, message]
            properties:
              field: { type: string }
              code: { type: string }
              message: { type: string }
