openapi: 3.0.3
info:
  title: 3i Partner API
  version: 1.0.0
  description: |
    API servidor a servidor para emisión, digitalización y webhooks de partners.
    Los cambios aditivos permanecen en v1; un cambio incompatible requiere v2.
    Todas las respuestas autenticadas incluyen X-Request-Id, X-3i-Environment y
    environment. Las claves 3i_live_ usan producción y las 3i_test_ seleccionan
    el sandbox lógico. No se garantiza el orden de los webhooks y un evento puede
    entregarse más de una vez.
servers:
  - url: https://{host}/api/v1/partner
    variables:
      host:
        default: institutodeingenieria.org
security:
  - bearerAuth: []
tags:
  - name: Identity
  - name: Certificates
  - name: Digitizations
  - name: Webhooks
paths:
  /me:
    get:
      operationId: getPartnerIdentity
      tags: [Identity]
      summary: Comprueba la credencial y devuelve el partner
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Partner autenticado
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [data, environment]
                properties:
                  data:
                    $ref: '#/components/schemas/Partner'
                  environment:
                    type: string
                    enum: [live, sandbox]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificate-courses:
    get:
      operationId: listCertificateCourses
      tags: [Certificates]
      summary: Lista cursos disponibles
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificate-students:
    get:
      operationId: listCertificateStudents
      tags: [Certificates]
      summary: Lista alumnos vinculados
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificate-templates:
    get:
      operationId: listCertificateTemplates
      tags: [Certificates]
      summary: Lista diseños disponibles
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificates:
    get:
      operationId: listCertificates
      tags: [Certificates]
      summary: Lista certificados del partner
      description: |
        Sin filtro lista todo el inventario del partner. type=native lista solo
        los certificados nativos, incluidos los emitidos por alumnos al aprobar
        exámenes del partner; type=digitized solo los digitalizados. Cada
        elemento incluye el campo type.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: type
          in: query
          required: false
          description: Filtro por tipo de certificado.
          schema:
            type: string
            enum: [native, digitized, all]
            default: all
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: issueCertificate
      tags: [Certificates]
      summary: Emite un certificado nativo
      description: |
        La respuesta incluye web_url, pdf_url y thumbnail_url. Una interfaz puede
        usar thumbnail_url para un botón de vista previa visual, pdf_url para el
        documento completo y web_url para la verificación pública. En Sandbox las
        URLs apuntan a recursos aislados y marcados sin validez.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CertificateIssuanceRequest'
      responses:
        '201':
          description: Certificado emitido
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataEnvelope'
        '200':
          description: Replay idempotente de una emisión existente
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificates/{certificate}:
    get:
      operationId: getCertificate
      tags: [Certificates]
      summary: Consulta un certificado
      description: Devuelve cualquier certificado del partner (nativo o digitalizado); ajeno o inexistente responde 404.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: certificate
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      operationId: deleteCertificate
      tags: [Certificates]
      summary: Elimina un certificado
      description: |
        Eliminación definitiva de un certificado del partner (nativo o digitalizado),
        equivalente a la del panel: borra el documento y sus renders públicos, libera
        el código y recalcula el cupo del plan. La operación de digitalización asociada,
        si existe, se conserva como histórico con certificate.deleted true. No emite
        webhooks. Un certificado ajeno o inexistente responde 404.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: certificate
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /certificates/{certificate}/notify:
    post:
      operationId: notifyCertificate
      tags: [Certificates]
      summary: Envía o reenvía correos de un certificado
      description: |
        Aplica a certificados nativos y digitalizados del partner. Un digitalizado
        en borrador o inactivo responde 409 certificate_not_active.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: certificate
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationRequest'
      responses:
        '200':
          $ref: '#/components/responses/NotificationSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /digitization-templates:
    get:
      operationId: listDigitizationTemplates
      tags: [Digitizations]
      summary: Lista plantillas de digitalización
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /digitizations:
    get:
      operationId: listDigitizations
      tags: [Digitizations]
      summary: Lista operaciones de digitalización
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: status
          in: query
          schema:
            type: string
            enum: [queued, processing, completed, failed]
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: createDigitization
      tags: [Digitizations]
      summary: Crea una digitalización asíncrona
      description: |
        links.thumbnail_url permite mostrar en un modal el estado temporal y,
        después de completed, la portada definitiva. links.pdf_url abre todas las
        páginas y links.web_url la verificación pública. Deben usarse las URLs
        devueltas por la API en lugar de construirlas manualmente.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [pdf, code, name, template_id]
              properties:
                pdf:
                  type: string
                  format: binary
                code:
                  type: string
                name:
                  type: string
                template_id:
                  type: integer
                user_id:
                  type: integer
                notification:
                  $ref: '#/components/schemas/NotificationRequest'
      responses:
        '202':
          description: Operación aceptada
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataEnvelope'
        '200':
          description: Replay idempotente
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /digitizations/check-code:
    get:
      operationId: checkDigitizationCode
      tags: [Digitizations]
      summary: Comprueba disponibilidad de un código
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: code
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /digitizations/{operation}:
    get:
      operationId: getDigitization
      tags: [Digitizations]
      summary: Consulta una operación
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: operation
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /digitizations/{operation}/notify:
    post:
      operationId: notifyDigitization
      tags: [Digitizations]
      summary: Envía correos de una digitalización completada
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - name: operation
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationRequest'
      responses:
        '200':
          $ref: '#/components/responses/NotificationSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: Lista destinos webhook
      description: Requiere ability * o webhooks:manage.
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Destinos del partner
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Crea un destino webhook
      description: El signing_secret se muestra una sola vez.
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointInput'
      responses:
        '201':
          description: Destino pendiente de verificación
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/DataEnvelope'
                  - type: object
                    required: [signing_secret]
                    properties:
                      signing_secret:
                        type: string
                        pattern: '^whsec_'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints/{endpoint}:
    parameters:
      - $ref: '#/components/parameters/RequestId'
      - $ref: '#/components/parameters/EndpointId'
    get:
      operationId: getWebhookEndpoint
      tags: [Webhooks]
      summary: Consulta un destino
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhooks]
      summary: Edita, activa o desactiva un destino
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 100
                url:
                  type: string
                  format: uri
                  pattern: '^https://'
                events:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items:
                    type: string
                    enum:
                      - digitization.completed
                      - digitization.failed
                      - certificate.issued
                enabled:
                  type: boolean
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhooks]
      summary: Desactiva y elimina lógicamente un destino
      responses:
        '200':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints/{endpoint}/verify:
    post:
      operationId: verifyWebhookEndpoint
      tags: [Webhooks]
      summary: Encola un webhook.test de verificación
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/EndpointId'
      responses:
        '202':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints/{endpoint}/rotate-secret:
    post:
      operationId: rotateWebhookEndpointSecret
      tags: [Webhooks]
      summary: Rota el secreto con solapamiento de 24 horas
      description: |
        Un destino activo continúa activo; uno pendiente o desactivado conserva
        su estado. Durante 24 horas Webhook-Signature contiene la firma nueva y
        la anterior. El secreto anterior nunca se devuelve.
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/EndpointId'
      responses:
        '200':
          description: Secreto rotado
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [data, signing_secret, previous_secret_valid_until]
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookEndpoint'
                  signing_secret:
                    type: string
                    pattern: '^whsec_'
                  previous_secret_valid_until:
                    type: string
                    format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints/{endpoint}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      summary: Lista entregas de un destino
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/EndpointId'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/WebhookDeliveryStatus'
        - name: event_type
          in: query
          schema:
            $ref: '#/components/schemas/WebhookEventType'
        - name: from
          in: query
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          schema:
            type: string
            format: date-time
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          $ref: '#/components/responses/PaginatedSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-deliveries/{delivery}:
    get:
      operationId: getWebhookDelivery
      tags: [Webhooks]
      summary: Devuelve entrega, evento e intentos inmutables
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/DeliveryId'
      responses:
        '200':
          description: Detalle aislado por partner
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-3i-Environment:
              $ref: '#/components/headers/X3iEnvironment'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/WebhookDelivery'
                      - type: object
                        required: [event, attempts]
                        properties:
                          event:
                            $ref: '#/components/schemas/WebhookEvent'
                          attempts:
                            type: array
                            items:
                              $ref: '#/components/schemas/WebhookAttempt'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-deliveries/{delivery}/redeliver:
    post:
      operationId: redeliverWebhookDelivery
      tags: [Webhooks]
      summary: Inicia un nuevo ciclo manual de entrega
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/DeliveryId'
      responses:
        '202':
          $ref: '#/components/responses/DataSuccess'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: 3i_live_... | 3i_test_...
      description: El prefijo live o test selecciona el ambiente y debe coincidir con la clave almacenada.
  headers:
    XRequestId:
      description: Identificador de correlación aceptado o generado por el servidor.
      schema:
        type: string
        minLength: 8
        maxLength: 100
    X3iEnvironment:
      description: Ambiente seleccionado por el prefijo de la clave.
      schema:
        type: string
        enum: [live, sandbox]
    XRateLimitLimit:
      description: Máximo de solicitudes permitido en la ventana actual.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Solicitudes restantes en la ventana actual.
      schema:
        type: integer
  parameters:
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      schema:
        type: string
        minLength: 8
        maxLength: 100
        pattern: '^[A-Za-z0-9._:-]+$'
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 8
        maxLength: 100
        pattern: '^[A-Za-z0-9._:-]+$'
    EndpointId:
      name: endpoint
      in: path
      required: true
      schema:
        type: integer
    DeliveryId:
      name: delivery
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Search:
      name: search
      in: query
      schema:
        type: string
    Page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    PerPage:
      name: per_page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
  responses:
    DataSuccess:
      description: Operación exitosa
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/DataEnvelope'
    PaginatedSuccess:
      description: Lista paginada
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PaginatedEnvelope'
    NotificationSuccess:
      description: Notificación encolada en Live o suprimida en Sandbox.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/NotificationEnvelope'
    Unauthorized:
      description: Clave inválida, vencida o revocada
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: The API key is invalid, expired or revoked.
            code: invalid_api_key
            request_id: req_a49ee319f01b4af9a0b0e64d0592041a
    Forbidden:
      description: Partner, API del partner o scope no autorizado
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Recurso inexistente o ajeno al partner
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: Conflicto de estado o idempotencia
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ValidationError:
      description: Solicitud inválida
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Límite de tráfico excedido
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-3i-Environment:
          $ref: '#/components/headers/X3iEnvironment'
        Retry-After:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    Partner:
      type: object
      required: [id, name, slug, status, environment]
      example:
        id: 60
        name: Partner de ejemplo
        slug: partner-de-ejemplo
        status: accepted
        environment: sandbox
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        status:
          type: string
          enum: [accepted]
        environment:
          type: string
          enum: [live, sandbox]
    DataEnvelope:
      type: object
      required: [data, environment]
      example:
        data:
          id: 845
          status: active
          environment: sandbox
        environment: sandbox
      properties:
        data:
          type: object
          additionalProperties: true
        environment:
          type: string
          enum: [live, sandbox]
    NotificationEnvelope:
      type: object
      required: [data, environment]
      example:
        data:
          status: suppressed
          recipients_count: 1
          environment: sandbox
        environment: sandbox
      properties:
        environment:
          type: string
          enum: [live, sandbox]
        data:
          type: object
          required: [status, recipients_count, environment]
          properties:
            status:
              type: string
              enum: [queued, suppressed]
            recipients_count:
              type: integer
              minimum: 1
            environment:
              type: string
              enum: [live, sandbox]
    PaginatedEnvelope:
      type: object
      required: [data, meta, environment]
      properties:
        data:
          type: array
          items:
            type: object
            additionalProperties: true
        meta:
          type: object
          required: [current_page, total, last_page]
          properties:
            current_page:
              type: integer
            per_page:
              type: integer
            total:
              type: integer
            last_page:
              type: integer
        environment:
          type: string
          enum: [live, sandbox]
    Error:
      type: object
      required: [message, code, request_id]
      properties:
        message:
          type: string
        code:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
        request_id:
          type: string
    NotificationRequest:
      type: object
      required: [emails]
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 10
          uniqueItems: true
          items:
            type: string
            format: email
        subject:
          type: string
          maxLength: 150
        message:
          type: string
          maxLength: 2000
    CertificateIssuanceRequest:
      type: object
      required: [issuance_type, issued_at]
      example:
        issuance_type: free
        holder_name: Ana Torres
        certificate_name: Liderazgo Estratégico
        issued_at: '2026-08-15'
        design:
          type: classic
      properties:
        issuance_type:
          type: string
          enum: [exam, free]
        course_id:
          type: integer
        user_id:
          type: integer
        holder_name:
          type: string
        certificate_name:
          type: string
        issued_at:
          type: string
          format: date
        design:
          type: object
          properties:
            type:
              type: string
              enum: [classic, template]
            template_id:
              type: integer
        template_values:
          type: object
          additionalProperties: true
        notification:
          $ref: '#/components/schemas/NotificationRequest'
    WebhookEventType:
      type: string
      enum:
        - digitization.completed
        - digitization.failed
        - certificate.issued
        - webhook.test
    WebhookDeliveryStatus:
      type: string
      enum: [pending, processing, delivered, failed, terminal]
    WebhookEndpointInput:
      type: object
      required: [name, url, events]
      properties:
        name:
          type: string
          maxLength: 100
        url:
          type: string
          format: uri
          pattern: '^https://'
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum:
              - digitization.completed
              - digitization.failed
              - certificate.issued
    WebhookEndpoint:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpointInput'
        - type: object
          required: [id, environment, status, secret_overlap_expires_at, verified_at, disabled_at, created_at]
          properties:
            id:
              type: integer
            environment:
              type: string
              enum: [live, sandbox]
            status:
              type: string
              enum: [pending_verification, active, disabled]
            secret_overlap_expires_at:
              type: string
              format: date-time
              nullable: true
            verified_at:
              type: string
              format: date-time
              nullable: true
            disabled_at:
              type: string
              format: date-time
              nullable: true
            created_at:
              type: string
              format: date-time
    WebhookDelivery:
      type: object
      required: [id, endpoint_id, event_id, event_type, environment, status, verification, attempt_count, total_attempt_count, redelivery_count]
      properties:
        id:
          type: string
          format: uuid
        endpoint_id:
          type: integer
        event_id:
          type: string
          format: uuid
        event_type:
          $ref: '#/components/schemas/WebhookEventType'
        environment:
          type: string
          enum: [live, sandbox]
        status:
          $ref: '#/components/schemas/WebhookDeliveryStatus'
        verification:
          type: boolean
        attempt_count:
          type: integer
        total_attempt_count:
          type: integer
        redelivery_count:
          type: integer
        response_status:
          type: integer
          nullable: true
        response_body:
          type: string
          nullable: true
          maxLength: 4096
        error:
          type: string
          nullable: true
          maxLength: 4096
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    WebhookAttempt:
      type: object
      required: [id, sequence, cycle, attempt_number, status, started_at]
      properties:
        id:
          type: string
          format: uuid
        sequence:
          type: integer
        cycle:
          type: integer
        attempt_number:
          type: integer
        status:
          type: string
          enum: [processing, delivered, retry_scheduled, terminal, interrupted]
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
        duration_ms:
          type: integer
          nullable: true
        resolved_ip:
          type: string
          nullable: true
        response_status:
          type: integer
          nullable: true
        response_headers:
          type: object
          nullable: true
          description: Solo Retry-After, Content-Type y X-Request-Id.
          additionalProperties:
            type: string
        response_body:
          type: string
          nullable: true
          maxLength: 4096
        error_category:
          type: string
          nullable: true
        error:
          type: string
          nullable: true
          maxLength: 4096
        next_retry_at:
          type: string
          format: date-time
          nullable: true
    WebhookEvent:
      type: object
      required: [id, type, environment, request_id, data, occurred_at]
      properties:
        id:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/WebhookEventType'
        environment:
          type: string
          enum: [live, sandbox]
        request_id:
          type: string
        source_type:
          type: string
          nullable: true
        source_id:
          type: string
          nullable: true
        data:
          type: object
          additionalProperties: true
        occurred_at:
          type: string
          format: date-time
    OutgoingWebhook:
      type: object
      description: |
        Instantánea inmutable del estado al ocurrir el evento. La API de consulta
        es la fuente del estado actual. Deduplicar por id; no asumir orden.
      required: [id, type, api_version, created_at, partner_id, environment, request_id, data]
      properties:
        id:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/WebhookEventType'
        api_version:
          type: string
          enum: [v1]
        created_at:
          type: string
          format: date-time
        partner_id:
          type: integer
        environment:
          type: string
          enum: [live, sandbox]
        request_id:
          type: string
        data:
          type: object
          additionalProperties: true
x-webhooks:
  partnerEvent:
    post:
      operationId: deliverPartnerWebhook
      summary: Evento enviado al endpoint configurado por el partner
      description: |
        Firma HMAC-SHA256 de event_id.timestamp.raw_body. Durante las 24 horas
        posteriores a una rotación puede haber dos valores v1= separados por coma.
      parameters:
        - name: Webhook-Id
          in: header
          required: true
          schema:
            type: string
            format: uuid
        - name: Webhook-Delivery
          in: header
          required: true
          schema:
            type: string
            format: uuid
        - name: Webhook-Request-Id
          in: header
          required: true
          schema:
            type: string
        - name: Webhook-Attempt
          in: header
          required: true
          schema:
            type: integer
            minimum: 1
        - name: Webhook-Timestamp
          in: header
          required: true
          schema:
            type: integer
        - name: Webhook-Signature
          in: header
          required: true
          schema:
            type: string
            example: v1=abc123,v1=def456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutgoingWebhook'
            examples:
              certificateIssued:
                value:
                  id: 0f673c90-1f9a-4f52-a693-a561af184147
                  type: certificate.issued
                  api_version: v1
                  created_at: '2026-08-17T20:00:00Z'
                  partner_id: 123
                  environment: sandbox
                  request_id: req_018f0f2451544c10997cdf8ad7e45188
                  data:
                    certificate:
                      id: 9001
                      code: CERT-2026-0001
                      status: active
      responses:
        2XX:
          description: Evento aceptado; cualquier otro estado se considera fallo.
