openapi: 3.1.0

info:
  title: API de trámites de Managora
  version: "1"
  summary: Canal B2B para preparar expedientes de trámites náuticos.
  description: |
    Su sistema manda los DATOS de un trámite. Nosotros creamos el expediente, se lo validamos
    campo a campo y le enviamos a su cliente final, por correo, un enlace alojado en el que
    revisa, firma el mandato y paga. Usted sigue el estado por esta API o por webhooks.

    La presentación ante la Administración la realiza un profesional de Managora; la API
    automatiza la preparación del expediente, no la presentación.

    El enlace de firma NO se le devuelve nunca al integrador: sale por correo a su cliente, es
    de un solo uso y caduca.

    AUTENTICACIÓN. Cabecera `Authorization: Bearer mgk_<entorno>_<prefijo>_<secreto>`. Una clave
    que empieza por `mgk_sandbox_` es del entorno de PRUEBAS y una que empieza por `mgk_live_`
    cobra de verdad. El servidor y las rutas son los mismos en los dos entornos.

    ENTORNO DE PRUEBAS. Con una clave `mgk_sandbox_` es real el expediente, la validación, el
    CORREO a su cliente (sale de verdad), la pantalla del cliente, la firma del mandato, los
    documentos, los webhooks y el escrito. No es real el cobro, la factura ni la presentación
    ante la Administración. Pruebe solo con buzones suyos.

    CABECERAS DE RESPUESTA. Toda respuesta del canal lleva `X-Managora-Api-Version`. Toda
    respuesta que llega a atenderse lleva además `X-RateLimit-Limit`, `X-RateLimit-Remaining` y
    `X-RateLimit-Reset`.
  contact:
    name: Managora
    email: tramites@managora.net
    url: https://managora.net
  termsOfService: https://managora.net/terminos

servers:
  - url: https://managora.net
    description: |
      Único servidor del canal. El entorno (pruebas o real) lo decide la clave, no la URL.

security:
  - bearerAuth: []

tags:
  - name: Identidad
    description: Quién es su clave y qué puede hacer.
  - name: Catálogo
    description: Qué puede vender y con qué contrato de campos.
  - name: Pedidos
    description: Crear un expediente, seguirlo, reenviar el enlace y cancelar.
  - name: Endpoints de webhook
    description: Alta, cambio, prueba, rotación de secreto y reactivación de sus endpoints.
  - name: Avisos
    description: Historial de los webhooks que le hemos mandado y reenvío de uno concreto.

paths:

  /api/partner/v1/me:
    get:
      tags: [Identidad]
      operationId: quienSoy
      summary: Identidad de la clave
      description: |
        La primera llamada de cualquier integración. Dice si la clave vale, en qué entorno
        está, qué permisos tiene, qué cuota y, sobre todo, si cobra de verdad.
      responses:
        "200":
          description: La clave es válida.
          headers:
            X-Managora-Api-Version:
              $ref: "#/components/headers/ApiVersion"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [partner, clave, modelo_cobro, cuota, servicios_disponibles, cobra_de_verdad, aviso]
                properties:
                  partner:
                    type: object
                    properties:
                      id: { type: string }
                      nombre: { type: string }
                  clave:
                    type: object
                    properties:
                      entorno:
                        type: string
                        enum: [live, sandbox]
                      scopes:
                        type: array
                        items: { type: string }
                  modelo_cobro:
                    type: string
                    description: Hoy siempre `cliente_final_paga`.
                  cuota:
                    type: object
                    properties:
                      peticiones_por_minuto: { type: integer, examples: [120] }
                      ventana_segundos: { type: integer, examples: [60] }
                  servicios_disponibles:
                    type: array
                    items: { type: string }
                  cobra_de_verdad:
                    type: boolean
                    description: "`false` con una clave de pruebas."
                  aviso: { type: string }
              examples:
                pruebas:
                  summary: Clave de pruebas
                  value:
                    partner: { id: "prt_8f2c", nombre: "Náutica Ejemplo, S.L." }
                    clave:
                      entorno: sandbox
                      scopes: ["catalogo:leer", "pedidos:crear", "pedidos:leer"]
                    modelo_cobro: cliente_final_paga
                    cuota: { peticiones_por_minuto: 120, ventana_segundos: 60 }
                    servicios_disponibles: ["inscripcion_buque_recreo", "titulo_nautico_per", "despacho_embarcacion_recreo"]
                    cobra_de_verdad: false
                    aviso: "ENTORNO DE PRUEBAS. Los pedidos que cree con esta clave recorren el trámite entero y mandan correos REALES a la dirección que usted declare, pero el pago es simulado: no se cobra nada y no presentamos nada ante la Administración."
        "401":
          $ref: "#/components/responses/NoAutenticado"
        "403":
          $ref: "#/components/responses/Prohibido"
        "429":
          $ref: "#/components/responses/CuotaExcedida"
        "500":
          $ref: "#/components/responses/ErrorInterno"
        "503":
          $ref: "#/components/responses/CanalNoConfigurado"

  /api/partner/v1/services:
    get:
      tags: [Catálogo]
      operationId: listarServicios
      summary: Trámites que puede vender
      description: |
        Con el precio desglosado y sin el contrato de campos. Para los campos de un trámite
        concreto, `GET /api/partner/v1/services/{tramiteId}`.
      parameters:
        - name: category
          in: query
          required: false
          description: Categoría comercial. Hoy hay una, `nautica`.
          schema: { type: string, examples: ["nautica"] }
        - name: lang
          in: query
          required: false
          description: |
            Idioma de lo que se LEE (título, descripción, etiquetas y ayudas). Los VALORES que
            hay que mandar de vuelta van siempre en español, también con `lang=en`.
          schema:
            type: string
            enum: [es, en]
            default: es
      responses:
        "200":
          description: El catálogo abierto a su cuenta.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  categorias:
                    type: array
                    items: { type: string }
                  servicios:
                    type: array
                    items: { $ref: "#/components/schemas/Servicio" }
        "400":
          description: "`lang` no soportado. Código `idioma_no_soportado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Categoría inexistente. Código `categoria_no_disponible`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/services/{tramiteId}:
    get:
      tags: [Catálogo]
      operationId: obtenerServicio
      summary: Contrato de campos de un trámite
      description: |
        ESTA ES LA LISTA VIVA. Sale de la misma ficha que valida el formulario, así que manda
        sobre cualquier ejemplo de esta documentación. Trae qué mandar en `datos`, con tipos,
        opciones literales, ayuda y condiciones de visibilidad; qué se le pedirá a su cliente
        después de pagar; y en qué casos el trámite no procede.
      parameters:
        - name: tramiteId
          in: path
          required: true
          schema: { type: string, examples: ["titulo_nautico_per"] }
        - name: lang
          in: query
          required: false
          schema:
            type: string
            enum: [es, en]
            default: es
      responses:
        "200":
          description: El servicio con sus campos.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  servicio: { $ref: "#/components/schemas/Servicio" }
        "400":
          description: "`lang` no soportado. Código `idioma_no_soportado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "El trámite no está abierto a su cuenta. Código `servicio_no_disponible`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/orders:
    post:
      tags: [Pedidos]
      operationId: crearPedido
      summary: Crear un pedido
      description: |
        Crea el expediente y le manda a su cliente, POR CORREO, el enlace donde revisa, firma
        el mandato y paga. El enlace no viaja en esta respuesta: es la credencial de acceso al
        expediente de una persona.

        `Idempotency-Key` es OBLIGATORIA. Un reintento con el MISMO cuerpo devuelve otra vez el
        mismo 201 con `reintento: true`; con un cuerpo distinto devuelve 409
        `idempotency_key_reutilizada`.

        Antes de crear nada se corta, y en este orden: idempotencia, lista blanca del canal,
        guards de venta, valores de `datos` contra la ficha, obligatorios visibles y el
        generador del escrito. Un pedido que llega a 201 es un pedido que sabemos redactar.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Única por pedido, entre 8 y 200 caracteres.
          schema: { type: string, minLength: 8, maxLength: 200 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NuevoPedido" }
            examples:
              titulo_nautico_per:
                summary: PER, expedición inicial
                description: |
                  Todos los campos obligatorios de la ficha de `titulo_nautico_per`. Los valores
                  de los `select` son literales de la ficha. `fecha_presentacion_prevista` la
                  fijamos nosotros y por eso no va aquí.
                value:
                  tramite: titulo_nautico_per
                  referencia: "PED-2026-4417"
                  idioma: es
                  datos:
                    solicitante_nombre: Ana
                    solicitante_apellido1: Ruiz
                    solicitante_apellido2: Serrano
                    solicitante_dni_nie: "12345678Z"
                    solicitante_domicilio: "Calle del Puerto 14, 3.o B, 07015 Palma"
                    solicitante_email: ana.ruiz@example.com
                    solicitante_telefono: "+34600111222"
                    solicitante_fecha_nacimiento: "1988-04-12"
                    ccaa_o_dgmm_competente: "Illes Balears"
                    clase_tramite: expedicion_inicial
                    titulo_solicitado: per_patron_embarcaciones_recreo
                    ha_superado_examen_teorico: "Sí"
                    fecha_examen_teorico: "2026-06-21"
                    ha_realizado_practicas_basicas_seguridad_navegacion: "Sí"
                    ha_realizado_practicas_radiocomunicaciones: "Sí"
                    ha_certificado_aptitud_psicofisica: "Sí"
                    ha_pagado_tasa: "No"
      responses:
        "201":
          description: |
            Pedido creado. También es la respuesta de un reintento con la misma
            `Idempotency-Key` y el mismo cuerpo, y entonces trae `reintento: true`.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [pedido, enlace_enviado, aviso]
                properties:
                  pedido: { $ref: "#/components/schemas/Pedido" }
                  precio:
                    oneOf:
                      - $ref: "#/components/schemas/Precio"
                      - type: "null"
                  enlace_enviado:
                    type: boolean
                    description: |
                      `true` si el correo con el enlace ha salido. Si es `false`, el pedido está
                      creado pero su cliente no tiene por dónde entrar: reintente con
                      `POST /api/partner/v1/orders/{id}/hosted-link`.
                  aviso_entrega:
                    type: string
                    description: Solo cuando `enlace_enviado` es `false`.
                  sandbox:
                    type: boolean
                    description: Solo en pedidos de pruebas.
                  aviso_sandbox:
                    type: string
                    description: Solo en pedidos de pruebas.
                  reintento:
                    type: boolean
                    description: Solo cuando la respuesta viene del camino de idempotencia.
                  aviso: { type: string }
        "400":
          description: |
            Cuerpo mal formado o trámite fuera de su catálogo. Códigos
            `idempotency_key_requerida`, `json_invalido`, `servicio_fuera_de_su_catalogo`,
            `idioma_no_soportado`, `datos_requeridos`, `campos_desconocidos`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Trámite no disponible. Código `servicio_no_disponible`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            Códigos `idempotency_key_reutilizada`, `servicio_a_medida`, `servicio_fusionado`,
            `cliente_no_elegible`, `referencia_duplicada`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: "Trámite retirado. Código `servicio_retirado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: "El cuerpo pasa de 256 KB. Código `cuerpo_demasiado_grande`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: |
            Los datos no valen. Códigos `datos_invalidos`, `datos_incompletos`,
            `email_cliente_requerido`. `error.detalles` trae el `campo`, la `etiqueta`, el
            `motivo` y, cuando las hay, las `opciones` admitidas.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                valor_no_admitido:
                  value:
                    error:
                      codigo: datos_invalidos
                      mensaje: Hay campos con valores que no admitimos.
                      campo: datos
                      detalles:
                        - campo: titulo_solicitado
                          etiqueta: "Título"
                          motivo: '"PER" no es uno de los valores admitidos.'
                          opciones: [pnb_patron_navegacion_basica, per_patron_embarcaciones_recreo, py_patron_yate, cy_capitan_yate]
        "501":
          description: "Modelo de cobro no disponible. Código `modo_cobro_no_disponible`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

    get:
      tags: [Pedidos]
      operationId: listarPedidos
      summary: Listar sus pedidos
      description: |
        Paginado por cursor, del más reciente al más antiguo. El filtro `estado` se aplica sobre
        la página ya leída, así que una página puede venir más corta y traer cursor igualmente.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 25, default: 25 }
        - name: cursor
          in: query
          description: El `siguiente_cursor` de la página anterior.
          schema: { type: string }
        - name: referencia
          in: query
          description: Su propia referencia, exacta.
          schema: { type: string }
        - name: estado
          in: query
          schema: { $ref: "#/components/schemas/EstadoPedido" }
        - name: desde
          in: query
          description: Fecha de creación, ISO 8601.
          schema: { type: string, examples: ["2026-09-01"] }
        - name: hasta
          in: query
          description: Fecha de creación, ISO 8601.
          schema: { type: string, examples: ["2026-09-30T23:59:59Z"] }
      responses:
        "200":
          description: Una página de pedidos.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  pedidos:
                    type: array
                    items: { $ref: "#/components/schemas/Pedido" }
                  siguiente_cursor:
                    type: [string, "null"]
                  aviso_filtro:
                    type: string
                    description: Solo cuando se ha usado `estado`.
        "400":
          description: "Códigos `estado_desconocido`, `fecha_invalida`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/orders/{id}:
    get:
      tags: [Pedidos]
      operationId: obtenerPedido
      summary: Estado de un pedido
      description: |
        Dónde está, qué falta y quién tiene que mover ficha. Sin enlace: para reenviarlo está
        `/hosted-link`. Un pedido que no es suyo responde 404, no 403.
      parameters:
        - $ref: "#/components/parameters/PedidoId"
      responses:
        "200":
          description: El pedido.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  pedido: { $ref: "#/components/schemas/Pedido" }
        "404":
          description: "Código `pedido_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/orders/{id}/hosted-link:
    post:
      tags: [Pedidos]
      operationId: reenviarEnlace
      summary: Reenviar el enlace a su cliente
      description: |
        El enlace anterior es de un solo uso y caduca, así que el cliente que lo abrió y cerró
        la pestaña, o el que lo recibió hace cuatro días, necesita otro. Sale SIEMPRE por correo
        a la dirección del expediente y no se devuelve en la respuesta.

        No hay parámetro de entrega. Mandar `entrega` en el cuerpo devuelve 400.
      parameters:
        - $ref: "#/components/parameters/PedidoId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_horas:
                  type: integer
                  minimum: 24
                  maximum: 168
                  default: 24
                  description: Horas que vivirá el enlace. Por defecto 24.
            examples:
              tres_dias:
                value: { ttl_horas: 72 }
              por_defecto:
                summary: Cuerpo vacío
                value: {}
      responses:
        "200":
          description: El correo ha salido.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  entregado:
                    type: string
                    examples: ["email"]
                  destinatario: { type: string, format: email }
                  caduca_en_horas: { type: integer }
                  caduca_en: { type: string, format: date-time }
                  proveedor: { type: string }
        "400":
          description: "Códigos `entrega_no_configurable`, `ttl_invalido`, `ttl_fuera_de_rango`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Código `pedido_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "El pedido ya no admite enlace. Código `pedido_terminado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: "El proveedor de correo lo rechazó. Código `no_se_pudo_entregar`. Reintentable."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/orders/{id}/cancel:
    post:
      tags: [Pedidos]
      operationId: cancelarPedido
      summary: Cancelar un pedido
      description: |
        Para el pedido creado por error o el cliente que se echa atrás. Cancelar MATA el enlace
        de su cliente y no se puede reemitir. Es idempotente: cancelar dos veces devuelve 200
        con `ya_estaba_cancelado: true`.

        No se puede cancelar lo ya pagado ni lo ya presentado. Con dinero de por medio la
        decisión es una devolución con su factura rectificativa, y eso se acuerda con nosotros.
      parameters:
        - $ref: "#/components/parameters/PedidoId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                motivo:
                  type: string
                  maxLength: 300
            examples:
              con_motivo:
                value: { motivo: "El cliente se ha echado atrás" }
      responses:
        "200":
          description: Cancelado, o ya lo estaba.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  pedido: { $ref: "#/components/schemas/Pedido" }
                  ya_estaba_cancelado: { type: boolean }
                  aviso: { type: string }
        "404":
          description: "Código `pedido_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "Códigos `pedido_ya_pagado`, `pedido_ya_presentado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/webhooks:
    get:
      tags: [Endpoints de webhook]
      operationId: listarEndpoints
      summary: Sus endpoints de avisos
      description: El secreto de firma no viaja aquí. Si lo pierde, rótelo.
      responses:
        "200":
          description: Sus endpoints.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/Endpoint" }
                  eventos_disponibles:
                    type: array
                    items: { $ref: "#/components/schemas/NombreEvento" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

    post:
      tags: [Endpoints de webhook]
      operationId: crearEndpoint
      summary: Dar de alta un endpoint
      description: |
        Devuelve el SECRETO una sola vez. Guárdelo en ese momento: no se vuelve a enseñar.

        La URL tiene que ser `https`, de un host público, sin usuario ni contraseña. Una IP
        interna o un nombre tipo `localhost` se rechaza con 400.

        `eventos` vacío o ausente significa TODOS.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                eventos:
                  type: array
                  items: { $ref: "#/components/schemas/NombreEvento" }
            examples:
              todos_los_eventos:
                value:
                  url: https://api.ejemplo.com/managora/webhooks
              solo_dos:
                value:
                  url: https://api.ejemplo.com/managora/webhooks
                  eventos: [order.paid, order.submitted]
      responses:
        "201":
          description: Endpoint creado. El `secreto` solo se ve aquí.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/Endpoint" }
                  secreto:
                    type: string
                    description: Empieza por `whsec_`. Se usa tal cual, con el prefijo incluido.
                    examples: ["whsec_3f9a1c7e4b2d8065a1f3c7e9b4d2086f5a1c3e7b"]
                  aviso: { type: string }
                  siguiente_paso: { type: string }
        "400":
          description: "Códigos `url_no_valida`, `evento_desconocido`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "Ya tiene un endpoint con esa URL. Código `endpoint_duplicado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/webhooks/{id}:
    patch:
      tags: [Endpoints de webhook]
      operationId: modificarEndpoint
      summary: Cambiar la URL o los eventos
      description: Mover la URL no cambia el secreto de firma.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                eventos:
                  type: array
                  items: { $ref: "#/components/schemas/NombreEvento" }
            examples:
              mover_url:
                value: { url: "https://api.ejemplo.com/managora/webhooks/v2" }
              cambiar_eventos:
                value: { eventos: [order.paid, order.documents_complete, order.submitted, order.completed] }
      responses:
        "200":
          description: Endpoint actualizado.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/Endpoint" }
                  aviso: { type: string }
        "400":
          description: "Códigos `json_invalido`, `url_no_valida`, `evento_desconocido`, `nada_que_cambiar`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Código `endpoint_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "Código `endpoint_duplicado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

    delete:
      tags: [Endpoints de webhook]
      operationId: bajaEndpoint
      summary: Dar de baja un endpoint
      description: |
        Se apaga y se cancela lo que le quedara encolado. El histórico de entregas se conserva
        y se sigue consultando por `GET /api/partner/v1/events`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: Dado de baja.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  baja: { type: boolean }
                  id: { type: string }
                  url: { type: string }
        "404":
          description: "Código `endpoint_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/webhooks/{id}/test:
    post:
      tags: [Endpoints de webhook]
      operationId: probarEndpoint
      summary: Mandar un ping firmado
      description: |
        Manda AHORA MISMO un aviso `ping` firmado a su endpoint y le devuelve lo que contestó su
        servidor, más el cuerpo exacto que hemos firmado. Compare ese cuerpo, byte a byte, con
        el que recibió: el fallo número uno al validar un HMAC es firmar el JSON reserializado
        en vez del crudo.

        Se puede probar un endpoint apagado. No consume la escalera de reintentos.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: El ping se ha intentado. Mire `entregado`.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  entregado: { type: boolean }
                  url: { type: string }
                  endpoint_activo: { type: boolean }
                  respuesta_de_su_servidor:
                    type: object
                    properties:
                      status: { type: [integer, "null"] }
                      error: { type: [string, "null"] }
                  cuerpo_firmado:
                    type: string
                    description: El JSON exacto sobre el que se calculó la firma.
                  ayuda: { type: string }
                  aviso: { type: string }
        "404":
          description: "Código `endpoint_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/webhooks/{id}/rotate:
    post:
      tags: [Endpoints de webhook]
      operationId: rotarSecreto
      summary: Rotar el secreto de firma
      description: |
        Cambia el secreto y devuelve el nuevo, una sola vez. Durante la ventana de gracia cada
        aviso lleva DOS firmas `v1` en la misma cabecera y le basta con validar una, así que
        puede desplegar el cambio cuando le venga bien.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                horas_gracia:
                  type: integer
                  minimum: 1
                  maximum: 168
                  default: 48
            examples:
              dos_dias:
                value: { horas_gracia: 48 }
      responses:
        "200":
          description: Secreto rotado.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  secreto: { type: string }
                  valido_el_anterior_hasta: { type: string, format: date-time }
                  aviso: { type: string }
        "400":
          description: "Código `gracia_fuera_de_rango`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Código `endpoint_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/webhooks/{id}/reactivate:
    post:
      tags: [Endpoints de webhook]
      operationId: reactivarEndpoint
      summary: Reactivar un endpoint apagado
      description: |
        Un endpoint que falla 20 veces seguidas se apaga solo y le avisamos por correo al
        contacto técnico. Esta ruta lo vuelve a encender y devuelve a la cola lo que quedó sin
        entregar.

        Antes de encender mandamos un ping. Si su servidor sigue sin contestar 2xx, la respuesta
        es 409 y el endpoint sigue apagado.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reencolar:
                  type: boolean
                  default: true
                  description: "`false` para recibir solo los avisos NUEVOS."
            examples:
              con_reencolado:
                value: { reencolar: true }
      responses:
        "200":
          description: Endpoint reactivado.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  activo: { type: boolean }
                  url: { type: string }
                  avisos_reencolados: { type: integer }
                  aviso: { type: string }
        "404":
          description: "Código `endpoint_no_encontrado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "Su servidor sigue sin contestar. Código `endpoint_sigue_sin_responder`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

  /api/partner/v1/events:
    get:
      tags: [Avisos]
      operationId: listarAvisos
      summary: Historial de avisos
      description: |
        Todo lo que le hemos mandado o intentado mandar, con el cuerpo EXACTO que firmamos y el
        resultado de cada entrega. Sirve para conciliar, para recuperar lo que agotó reintentos
        y para ver lo que ocurrió antes de que usted registrara su endpoint.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
        - name: evento
          in: query
          description: Uno de los cinco eventos, o `ping`.
          schema: { type: string }
        - name: estado
          in: query
          schema:
            type: string
            enum: [pendiente, entregando, entregado, fallido, cancelado]
        - name: pedido
          in: query
          description: Identificador de un pedido suyo.
          schema: { type: string }
        - name: desde
          in: query
          schema: { type: string }
        - name: hasta
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Una página de avisos.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  eventos:
                    type: array
                    items: { $ref: "#/components/schemas/Entrega" }
                  siguiente_cursor:
                    type: [string, "null"]
        "400":
          description: "Códigos `evento_desconocido`, `estado_desconocido`, `fecha_invalida`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

    post:
      tags: [Avisos]
      operationId: reencolarAviso
      summary: Volver a encolar un aviso
      description: |
        Mete otra vez en la cola un aviso concreto. Sale por el mismo camino que todos, con su
        escalera de reintentos, no de forma inmediata.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entrega_id]
              properties:
                entrega_id:
                  type: string
                  description: El `entrega_id` de `GET /api/partner/v1/events`, que es el mismo que viajó en la cabecera `Managora-Entrega-Id`.
            examples:
              reencolar:
                value: { entrega_id: "whd_9c1f4a7b2e6d" }
      responses:
        "200":
          description: Reencolado.
          headers:
            X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  reencolado: { type: boolean }
                  entrega_id: { type: string }
                  aviso: { type: string }
        "400":
          description: "Código `entrega_id_requerida`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "Código `entrega_no_encontrada`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "Códigos `entrega_ya_en_cola`, `endpoint_desactivado`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutenticado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/CuotaExcedida" }
        "500": { $ref: "#/components/responses/ErrorInterno" }

webhooks:

  ping:
    post:
      summary: Aviso de prueba
      description: Lo dispara `POST /api/partner/v1/webhooks/{id}/test` y también el intento previo de una reactivación.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              ping:
                value:
                  evento: ping
                  pedido_id: null
                  referencia: null
                  ocurrido_en: "2026-09-15T09:12:44.118Z"
                  datos:
                    mensaje: "Aviso de prueba de Managora. Si valida esta firma, su integración está lista."
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

  order.signed:
    post:
      summary: El cliente final firmó el mandato
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              firmado:
                value:
                  evento: order.signed
                  pedido_id: "ord_7b3e9a1c4d25"
                  referencia: "PED-2026-4417"
                  ocurrido_en: "2026-09-15T10:04:11.902Z"
                  datos:
                    firmado_en: "2026-09-15T10:04:09.511Z"
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

  order.paid:
    post:
      summary: Entró el pago
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              pagado:
                value:
                  evento: order.paid
                  pedido_id: "ord_7b3e9a1c4d25"
                  referencia: "PED-2026-4417"
                  ocurrido_en: "2026-09-15T10:07:33.240Z"
                  datos:
                    pagado_en: "2026-09-15T10:07:31.088Z"
                    importe_cents: 14600
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

  order.documents_complete:
    post:
      summary: El cliente subió el último documento obligatorio
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              documentacion_completa:
                value:
                  evento: order.documents_complete
                  pedido_id: "ord_7b3e9a1c4d25"
                  referencia: "PED-2026-4417"
                  ocurrido_en: "2026-09-16T08:41:02.775Z"
                  datos: {}
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

  order.submitted:
    post:
      summary: Un profesional de Managora lo presentó
      description: Trae la referencia REAL que devolvió el organismo. Nunca un número inventado.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              presentado:
                value:
                  evento: order.submitted
                  pedido_id: "ord_7b3e9a1c4d25"
                  referencia: "PED-2026-4417"
                  ocurrido_en: "2026-09-18T12:20:55.401Z"
                  datos:
                    presentado_en: "2026-09-18T12:19:40.000Z"
                    referencia_oficial: "REG-2026-0098431"
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

  order.completed:
    post:
      summary: La Administración resolvió y el expediente se cerró
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventoWebhook" }
            examples:
              finalizado:
                value:
                  evento: order.completed
                  pedido_id: "ord_7b3e9a1c4d25"
                  referencia: "PED-2026-4417"
                  ocurrido_en: "2026-10-09T16:02:18.663Z"
                  datos:
                    finalizado_en: "2026-10-09T16:02:17.220Z"
                    referencia_oficial: "REG-2026-0098431"
      responses:
        "200":
          description: Conteste 2xx en menos de 10 segundos.

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mgk
      description: |
        `Authorization: Bearer mgk_<entorno>_<prefijo>_<secreto>`.

        `<entorno>` es `live` o `sandbox`. El `<prefijo>` son 8 caracteres hexadecimales y no es
        secreto: es lo que se puede enseñar en un log. El `<secreto>` se entrega una sola vez.

        La clave se comprueba contra la base de datos en cada petición, así que una revocación
        surte efecto en la llamada siguiente.

  parameters:
    PedidoId:
      name: id
      in: path
      required: true
      description: Identificador del pedido, el `pedido.id` del 201.
      schema: { type: string }
    EndpointId:
      name: id
      in: path
      required: true
      description: Identificador del endpoint de avisos.
      schema: { type: string }

  headers:
    ApiVersion:
      description: Versión del contrato. Hoy `1`.
      schema: { type: string, examples: ["1"] }
    RateLimitLimit:
      description: Peticiones permitidas por minuto y clave.
      schema: { type: integer, examples: [120] }
    RateLimitRemaining:
      description: Las que le quedan en la ventana.
      schema: { type: integer, examples: [117] }
    RateLimitReset:
      description: |
        Epoch en segundos en el que, como muy tarde, vuelve a haber hueco. La ventana es
        deslizante de 60 segundos.
      schema: { type: integer, examples: [1789050180] }
    RetryAfter:
      description: Segundos que hay que esperar antes de reintentar.
      schema: { type: integer, examples: [60] }
    Allow:
      description: Métodos que sí atiende esa ruta.
      schema: { type: string, examples: ["GET, POST"] }

  responses:
    NoAutenticado:
      description: |
        Falta la credencial o no vale. Códigos `sin_credencial`, `formato_invalido`,
        `clave_desconocida`, `clave_revocada`, `clave_caducada`.
      headers:
        X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            sin_credencial:
              value:
                error:
                  codigo: sin_credencial
                  mensaje: "Falta la cabecera Authorization: Bearer <clave>."
    Prohibido:
      description: |
        Autenticado, pero sin permiso. Códigos `scope_insuficiente` y `partner_suspendido`.
      headers:
        X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    CuotaExcedida:
      description: Más de 120 peticiones en el último minuto con esa clave.
      headers:
        X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            cuota:
              value:
                error:
                  codigo: cuota_excedida
                  mensaje: "Máximo 120 peticiones por minuto y clave. Reintente en un minuto."
    ErrorInterno:
      description: Fallo por nuestra parte. Código `error_interno`. Reintentable.
      headers:
        X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    CanalNoConfigurado:
      description: El canal no está disponible ahora mismo. Código `canal_no_configurado`.
      headers:
        X-Managora-Api-Version: { $ref: "#/components/headers/ApiVersion" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:

    Error:
      type: object
      description: |
        El sobre de error de todo el canal, incluidos los 404 de ruta y los 405 de método.
        Ramifique por `error.codigo`, que es estable, y no por el texto de `error.mensaje`.
      required: [error]
      properties:
        error:
          type: object
          required: [codigo, mensaje]
          properties:
            codigo:
              type: string
              description: Código estable.
              examples: ["datos_invalidos"]
            mensaje:
              type: string
              description: Explicación en español, pensada para una persona.
            campo:
              type: string
              description: El campo o parámetro que lo causó, cuando aplica.
            detalles:
              description: |
                Estructura que depende del código. En los 422 de datos es una lista de
                `{ campo, etiqueta, motivo, opciones }`.
      examples:
        - error:
            codigo: campos_desconocidos
            mensaje: 'Estos campos no existen en "titulo_nautico_per": patron. Consulte GET /services/titulo_nautico_per para el contrato vivo.'
            campo: datos
            detalles: [patron]

    Precio:
      type: object
      description: |
        Importes en CÉNTIMOS de euro. Las tasas oficiales son suplidos: no llevan IVA y quedan
        fuera de la base imponible.
      required: [honorario_neto_cents, iva_pct, honorario_con_iva_cents, tasa_oficial_cents, total_cents, desde, tasa_variable, nota_tasa]
      properties:
        honorario_neto_cents:
          type: integer
          description: Nuestro honorario sin IVA.
          examples: [8182]
        iva_pct:
          type: integer
          examples: [21]
        honorario_con_iva_cents:
          type: integer
          examples: [9900]
        tasa_oficial_cents:
          type: [integer, "null"]
          description: Tasa oficial que adelantamos. `null` cuando se calcula caso por caso.
          examples: [4668]
        total_cents:
          type: integer
          description: |
            LO QUE COBRA LA PASARELA en el enlace de su cliente. Nunca es `null`. Cuando
            `tasa_variable` es `true`, aquí va solo el honorario con IVA y la tasa se comunica
            por escrito y se cobra aparte.
          examples: [14600]
        desde:
          type: boolean
          description: |
            `true` si el honorario depende de lo que responda el cliente, y entonces el importe
            es un mínimo. En el precio de un pedido ya creado siempre es `false`.
        tasa_variable:
          type: boolean
          description: "`true` si la tasa se calcula caso por caso y se cobra aparte."
        nota_tasa:
          type: [string, "null"]
          description: Cómo funciona la tasa de ese trámite, en texto.

    Campo:
      type: object
      description: |
        Un campo del contrato de `datos`. Las `opciones` son los valores LITERALES que hay que
        mandar, también cuando se pide el catálogo con `lang=en`.
      required: [id, etiqueta, tipo, obligatorio]
      properties:
        id:
          type: string
          description: La clave que va en `datos`.
          examples: ["titulo_solicitado"]
        etiqueta:
          type: string
          description: Cómo llamarlo en su interfaz.
        tipo:
          type: string
          description: "`text`, `email`, `phone`, `date`, `number`, `select`, `multi_select`, `text_area`, `dni`, `cif`, `lista`."
          examples: ["select"]
        obligatorio:
          type: boolean
        ayuda:
          type: string
        opciones:
          type: array
          items: { type: string }
          description: Valores admitidos, literales y en español.
        opciones_etiquetas:
          type: object
          additionalProperties: { type: string }
          description: Solo con `lang=en`. Cómo se lee cada opción. No se manda de vuelta.
        subida:
          type: boolean
          description: |
            El valor de este campo es un fichero. Por el canal NO se manda en `datos`: su
            cliente lo sube desde su pantalla después de pagar.
        visible_si:
          description: |
            Condición de visibilidad. Solo se pide, y solo se exige, si se cumple. Lista de
            reglas del tipo `{ "field": "...", "equals": ["..."] }`.
        lo_fijamos_nosotros:
          type: boolean
          description: |
            Lo rellenamos nosotros si no viene. No se lo pregunte a su cliente.

    DocumentoPostPago:
      type: object
      description: Lo que se le pedirá a su cliente final DESPUÉS de pagar, desde su pantalla.
      required: [campo, etiqueta, opcional]
      properties:
        campo: { type: string }
        etiqueta: { type: string }
        opcional: { type: boolean }
        visible_si:
          description: Solo se pide si se cumple la condición.

    NoElegible:
      type: object
      description: |
        Cuándo NO procede el trámite. Publicado para que pueda filtrar antes de vender en lugar
        de comerse un 409 `cliente_no_elegible`.
      required: [cuando, motivo, alternativa]
      properties:
        cuando:
          description: Misma forma de condición que `visible_si`.
        motivo: { type: string }
        alternativa:
          type: [string, "null"]
          description: El trámite que sí procede, cuando lo hay.

    Servicio:
      type: object
      required: [id, titulo, descripcion, organismo, plazo, precio, campos, documentos_post_pago, no_elegible_si, presentacion]
      properties:
        id:
          type: string
          examples: ["titulo_nautico_per"]
        titulo: { type: string }
        descripcion: { type: string }
        organismo:
          type: [string, "null"]
          description: Ante quién se presenta.
        plazo:
          type: [string, "null"]
        precio: { $ref: "#/components/schemas/Precio" }
        campos:
          type: array
          description: Vacío en el listado; relleno en `GET /services/{tramiteId}`.
          items: { $ref: "#/components/schemas/Campo" }
        documentos_post_pago:
          type: array
          items: { $ref: "#/components/schemas/DocumentoPostPago" }
        no_elegible_si:
          type: array
          items: { $ref: "#/components/schemas/NoElegible" }
        presentacion:
          type: string
          const: presentacion_por_gestor
          description: |
            La presentación ante la Administración la realiza un profesional de Managora; la API
            automatiza la preparación del expediente, no la presentación.

    EstadoPedido:
      type: string
      description: |
        Vocabulario propio y estable del canal.

        - `pendiente_cliente`: su cliente aún no ha hecho nada.
        - `firmado_pendiente_pago`: ha firmado el mandato y falta el pago.
        - `pagado_pendiente_firma`: ha pagado y falta la firma del mandato.
        - `pendiente_documentos`: falta algún documento obligatorio.
        - `listo_para_presentar`: completo y en nuestra cola.
        - `presentado`: presentado ante la Administración, con referencia oficial.
        - `finalizado`: resuelto y cerrado.
        - `cancelado`: cancelado.
        - `en_curso`: en tramitación, sin nada pendiente por su parte.
      enum:
        - pendiente_cliente
        - firmado_pendiente_pago
        - pagado_pendiente_firma
        - pendiente_documentos
        - listo_para_presentar
        - presentado
        - finalizado
        - cancelado
        - en_curso

    SiguientePaso:
      type: string
      description: |
        Quién tiene que mover ficha y para qué.

        - `abrir_enlace`, `firmar_mandato`, `pagar`, `subir_documentos`: le toca a su cliente.
        - `presentacion_por_gestor`: nos toca a nosotros. Aquí `sla_habiles` trae el plazo.
        - `ninguno`: no hay nada pendiente.
      enum:
        - abrir_enlace
        - firmar_mandato
        - pagar
        - subir_documentos
        - presentacion_por_gestor
        - ninguno

    Pedido:
      type: object
      required:
        - id
        - referencia
        - tramite
        - estado
        - siguiente_paso
        - sla_habiles
        - sandbox
        - modo_pago
        - cliente_email
        - importe_cobrado_cents
        - firmado_en
        - pagado_en
        - presentado_en
        - referencia_oficial
        - finalizado_en
        - documentos_pendientes
        - creado_en
        - enlace_enviado_en
        - enlace_caduca_en
        - enlace_abierto_en
        - precio
        - cancelado_en
        - motivo_cancelacion
        - nota_presentacion
      properties:
        id:
          type: string
          description: Nuestro identificador del pedido.
        referencia:
          type: [string, "null"]
          description: La suya, tal y como la mandó al crearlo.
        tramite:
          type: string
          examples: ["titulo_nautico_per"]
        estado: { $ref: "#/components/schemas/EstadoPedido" }
        siguiente_paso: { $ref: "#/components/schemas/SiguientePaso" }
        sla_habiles:
          type: [integer, "null"]
          description: Días hábiles que tardamos en presentar. Solo cuando el paso es nuestro.
          examples: [3]
        sandbox:
          type: boolean
          description: Pedido creado con una clave de pruebas.
        modo_pago:
          type: string
          examples: ["cliente_final_paga"]
        cliente_email:
          type: string
          format: email
          description: A donde ha ido el enlace, y a donde irán la factura y los documentos.
        importe_cobrado_cents:
          type: [integer, "null"]
          description: Lo cobrado de verdad. `null` mientras no haya pago.
        firmado_en:
          type: [string, "null"]
          format: date-time
        pagado_en:
          type: [string, "null"]
          format: date-time
        presentado_en:
          type: [string, "null"]
          format: date-time
        referencia_oficial:
          type: [string, "null"]
          description: El número REAL que devolvió el organismo. Nunca uno inventado.
        finalizado_en:
          type: [string, "null"]
          format: date-time
        documentos_pendientes:
          type: array
          description: Ids de los documentos obligatorios que su cliente todavía no ha subido.
          items: { type: string }
        creado_en:
          type: string
          format: date-time
        enlace_enviado_en:
          type: [string, "null"]
          format: date-time
          description: Cuándo salió el correo con el enlace. `null` si no llegó a salir.
        enlace_caduca_en:
          type: [string, "null"]
          format: date-time
        enlace_abierto_en:
          type: [string, "null"]
          format: date-time
          description: Cuándo lo canjeó su cliente. Se quema al abrirlo.
        precio:
          oneOf:
            - $ref: "#/components/schemas/Precio"
            - type: "null"
          description: El precio de ESTE pedido, con las respuestas ya delante.
        cancelado_en:
          type: [string, "null"]
          format: date-time
        motivo_cancelacion:
          type: [string, "null"]
        nota_presentacion:
          type: string
          description: Va en cada respuesta, no solo en la documentación.
          const: "La presentación ante la Administración la realiza un profesional de Managora. La API automatiza la preparación del expediente, no la presentación."

    NuevoPedido:
      type: object
      required: [tramite, datos]
      properties:
        tramite:
          type: string
          description: Uno de los ids de `GET /api/partner/v1/services`.
          examples: ["titulo_nautico_per"]
        referencia:
          type: string
          maxLength: 120
          description: |
            Su propia referencia. Única dentro de su cuenta: repetirla devuelve 409
            `referencia_duplicada`. Viaja de vuelta en cada webhook.
        idioma:
          type: string
          enum: [es, en]
          default: es
          description: |
            El idioma en que su cliente leerá el correo, la pantalla y su factura. Se congela al
            crear el pedido.
        datos:
          type: object
          additionalProperties: true
          description: |
            Las respuestas del cliente, con las claves EXACTAS de
            `GET /api/partner/v1/services/{tramiteId}`. Una clave que no exista en la ficha
            devuelve 400 `campos_desconocidos`: no se ignora en silencio.

            Los campos con `subida: true` NO van aquí. Los campos con
            `lo_fijamos_nosotros: true`, tampoco: los rellenamos nosotros.

            En los `select` se admite la opción literal, la misma sin tildes ni mayúsculas, o su
            forma en minúsculas con guiones bajos. Para un `Sí`/`No` se admiten además `true` y
            `false`. Cualquier otra cosa es 422, con la lista de valores válidos en
            `error.detalles`.

    Endpoint:
      type: object
      required: [id, url, eventos, activo]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        eventos:
          type: array
          items: { $ref: "#/components/schemas/NombreEvento" }
          description: Los cinco si se dio de alta sin lista.
        activo:
          type: boolean
          description: Un endpoint que falla 20 veces seguidas se apaga solo.
        fallos_seguidos: { type: integer }
        desactivado_en:
          type: [string, "null"]
          format: date-time
        rotacion_hasta:
          type: [string, "null"]
          format: date-time
          description: Hasta cuándo sigue valiendo el secreto anterior.
        creado_en:
          type: string
          format: date-time

    NombreEvento:
      type: string
      enum:
        - order.signed
        - order.paid
        - order.documents_complete
        - order.submitted
        - order.completed

    EventoWebhook:
      type: object
      description: |
        El cuerpo de todo aviso. Siempre estas cinco claves y en este orden. `datos` cambia
        según el evento.

        Se entrega AT-LEAST-ONCE. Deduplique por la cabecera `Managora-Entrega-Id`.
      required: [evento, pedido_id, referencia, ocurrido_en, datos]
      properties:
        evento:
          type: string
          description: Uno de los cinco, o `ping`.
          examples: ["order.paid"]
        pedido_id:
          type: [string, "null"]
          description: "`null` solo en el `ping`."
        referencia:
          type: [string, "null"]
          description: La suya.
        ocurrido_en:
          type: string
          format: date-time
          description: |
            Cuándo OCURRIÓ el hecho, no cuándo se entrega. Con reintentos de horas, la
            diferencia importa para conciliar.
        datos:
          type: object
          description: |
            - `order.signed`: `firmado_en`.
            - `order.paid`: `pagado_en`, `importe_cents`.
            - `order.documents_complete`: vacío.
            - `order.submitted`: `presentado_en`, `referencia_oficial`.
            - `order.completed`: `finalizado_en`, `referencia_oficial`.
            - `ping`: `mensaje`.

    Entrega:
      type: object
      description: Un intento de entrega de un aviso, con su resultado.
      required: [entrega_id, evento, pedido_id, endpoint, estado, intentos, cuerpo]
      properties:
        entrega_id:
          type: string
          description: El mismo valor que viajó en la cabecera `Managora-Entrega-Id`.
        evento: { type: string }
        pedido_id:
          type: [string, "null"]
        endpoint:
          type: object
          properties:
            id: { type: string }
            url: { type: string }
        estado:
          type: string
          enum: [pendiente, entregando, entregado, fallido, cancelado]
        intentos: { type: integer }
        ultimo_status:
          type: [integer, "null"]
        ultimo_error:
          type: [string, "null"]
        entregado_en:
          type: [string, "null"]
          format: date-time
        proximo_intento:
          type: [string, "null"]
          format: date-time
        creado_en:
          type: string
          format: date-time
        cuerpo:
          type: string
          description: |
            El cuerpo EXACTO que firmamos, como CADENA y no como objeto. Reserializarlo daría
            otra firma.
