openapi: 3.1.0
info:
  title: BeeMatch Integration API
  version: 1.0.0-rc.2
  summary: API empresarial de integración de sólo lectura para ERP, HRIS, nómina y ATS.
  description: |
    API empresarial de consulta para vacantes, postulaciones, candidatos y catálogos desde sistemas
    ERP, HRIS, nómina y ATS. `Application` es el agregado principal: concentra el candidato resumido,
    historial del proceso y evaluaciones sin obligar al integrador a encadenar llamadas.

    La organización se obtiene de la credencial OAuth2; ningún endpoint acepta `company_id`.
    Un candidato sólo es visible cuando tiene una postulación a una vacante de la organización.
    El scope `candidate.pii:read` habilita únicamente el objeto `pii` permitido.

    Nunca se exponen documentos o imágenes de identidad, PIN de vacante, CV o archivos,
    prompts/logs/flags de IA, billing/tokens, WhatsApp, códigos de verificación, expedientes de
    seguridad, respuestas/transcripciones crudas ni sesiones de agentes.

    Convenciones: JSON UTF-8, IDs opacos, fechas RFC 3339 UTC, cursores opacos, errores RFC 9457,
    `X-Request-Id`, rate limits y ETags. Todas las operaciones son idempotentes y de sólo lectura.
  contact: { name: BeeMatch Integrations, email: integrations@beematch.com }
  license: { name: Proprietary, identifier: LicenseRef-Proprietary }
servers:
  - { url: 'https://api.beematch.com/v1', description: 'Producción' }
  - { url: 'https://sandbox.api.beematch.com/v1', description: 'Sandbox con datos sintéticos' }
tags:
  - { name: Platform, description: Disponibilidad técnica. }
  - { name: Job postings, description: Vacantes de la organización autenticada. }
  - { name: Applications, description: Procesos de selección y handoff hacia ATS/HRIS/nómina. }
  - { name: Candidates, description: Perfil consolidado de candidatos autorizados. }
  - { name: Catalogs, description: Catálogos globales de referencia. }
security:
  - OAuth2: []

paths:
  /health:
    get:
      tags: [Platform]
      operationId: getHealth
      summary: Verificar disponibilidad
      description: No requiere autenticación ni consulta datos de negocio.
      security: []
      responses:
        '200':
          description: Servicio disponible.
          headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
              example: { status: ok, time: '2026-08-18T16:00:00Z', version: 1.0.0-rc.2 }
        '400': { $ref: '#/components/responses/HealthBadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /job-postings:
    get:
      tags: [Job postings]
      operationId: listJobPostings
      summary: Listar vacantes
      description: |
        Devuelve exclusivamente vacantes de la organización asociada a la credencial.
        Para sincronización incremental use `updated_since`; la continuación se obtiene de
        `meta.next_cursor`. El API no registra ni devuelve eliminaciones.
      x-internal-source:
        collection: jobPostings
        tenant-relation: companyID
        model: Beematch-CMS/functions/src/shared/models/Jobs.ts
      x-data-classification: confidential
      security: [{ OAuth2: ['job_posting:read'] }]
      parameters:
        - { $ref: '#/components/parameters/PageSize' }
        - { $ref: '#/components/parameters/PageCursor' }
        - { $ref: '#/components/parameters/UpdatedSince' }
        - name: status
          in: query
          description: Estados externos separados por coma.
          schema: { type: array, items: { $ref: '#/components/schemas/JobPostingStatus' } }
          style: form
          explode: false
        - { name: active, in: query, schema: { type: boolean } }
        - name: sort
          in: query
          schema: { type: string, enum: [created_at, -created_at, updated_at, -updated_at, title, -title], default: -updated_at }
      responses:
        '200':
          description: Página de vacantes vigentes en la fuente.
          headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' }, RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }, RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }, RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/JobPostingList' } } }
        '400': { $ref: '#/components/responses/JobPostingsBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /job-postings/{job_posting_id}:
    get:
      tags: [Job postings]
      operationId: getJobPosting
      summary: Obtener una vacante
      security: [{ OAuth2: ['job_posting:read'] }]
      parameters:
        - { $ref: '#/components/parameters/JobPostingId' }
        - { $ref: '#/components/parameters/IfNoneMatch' }
      responses:
        '200':
          description: Vacante autorizada.
          headers: { ETag: { $ref: '#/components/headers/ETag' }, X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/JobPosting' } } }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/JobPostingBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /applications:
    get:
      tags: [Applications]
      operationId: listApplications
      summary: Listar postulaciones
      description: |
        Eje de la integración. Sólo incluye postulaciones a vacantes propias. El filtro
        `status` acepta el identificador dinámico de etapa almacenado en `currentStepId`. La lista
        incluye candidato y evaluaciones en versión resumida. Para sincronización incremental use
        `updated_since`; la continuación se obtiene de `meta.next_cursor`.
      x-internal-source:
        collection: jobApplications
        tenant-relation: jobPostingID -> jobPostings.companyID
        related-collections: [candidates, ApplicationStatusLog, specificEvaluationsCandidates, quizCandidates, jobPostingQuizzes, quizzes]
        evaluation-joins:
          bia: specificEvaluationsCandidates by candidateID + jobPostingID
          custom: quizCandidates by candidateUID + quizID from jobPostingQuizzes
          evaluation-name: jobPostingQuizzes.quizID -> quizzes.title
      x-data-classification: restricted
      security: [{ OAuth2: ['application:read'] }]
      parameters:
        - { $ref: '#/components/parameters/PageSize' }
        - { $ref: '#/components/parameters/PageCursor' }
        - { $ref: '#/components/parameters/UpdatedSince' }
        - { name: job_posting_id, in: query, schema: { $ref: '#/components/schemas/OpaqueId' } }
        - { name: candidate_id, in: query, schema: { $ref: '#/components/schemas/OpaqueId' } }
        - name: status
          in: query
          description: Identificadores dinámicos de etapa de `jobApplications.currentStepId`, separados por coma.
          schema: { type: array, items: { $ref: '#/components/schemas/ApplicationStatus' } }
          style: form
          explode: false
        - { name: applied_since, in: query, schema: { $ref: '#/components/schemas/Timestamp' } }
        - name: sort
          in: query
          schema: { type: string, enum: [applied_at, -applied_at, updated_at, -updated_at], default: -updated_at }
      responses:
        '200':
          description: Página de postulaciones agregadas.
          headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' }, RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }, RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }, RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/ApplicationList' } } }
        '400': { $ref: '#/components/responses/ApplicationsBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /applications/{application_id}:
    get:
      tags: [Applications]
      operationId: getApplication
      summary: Obtener el proceso de selección consolidado
      description: Incluye perfil resumido, historial de etapas y evaluaciones sanitizadas.
      security: [{ OAuth2: ['application:read'] }]
      parameters:
        - { $ref: '#/components/parameters/ApplicationId' }
        - { $ref: '#/components/parameters/IfNoneMatch' }
      responses:
        '200':
          description: Postulación autorizada.
          headers: { ETag: { $ref: '#/components/headers/ETag' }, X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Application' } } }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/ApplicationBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /candidates/{candidate_id}:
    get:
      tags: [Candidates]
      operationId: getCandidate
      summary: Obtener el perfil consolidado de un candidato
      description: |
        Consolida educación, experiencia, idiomas y habilidades. Devuelve 404 cuando el
        candidato no tiene una postulación a una vacante de la organización autenticada.
        `pii` sólo aparece con el scope `candidate.pii:read`.
      x-internal-source:
        collections: [candidates, candidateEducation, candidateExperience, candidateLanguages, candidateSkill]
        authorization-relation: jobApplications -> jobPostings.companyID
      x-data-classification: restricted
      security: [{ OAuth2: ['candidate:read', 'candidate.profile:read'] }]
      parameters:
        - { $ref: '#/components/parameters/CandidateId' }
        - { $ref: '#/components/parameters/IfNoneMatch' }
      responses:
        '200':
          description: Candidato autorizado.
          headers: { ETag: { $ref: '#/components/headers/ETag' }, X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Candidate' } } }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/CandidateBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /catalogs/{catalog}:
    get:
      tags: [Catalogs]
      operationId: listCatalogItems
      summary: Listar elementos de un catálogo
      description: |
        Un endpoint parametrizado evita multiplicar recursos casi idénticos. Continúe la
        paginación enviando `meta.next_cursor` como `page_cursor`; null indica el final.
      x-internal-source:
        catalog-collections:
          job-posting-categories: jobPostingCategories
          company-categories-types: company-categories-types
          skill-types: skillTypes
          skills: skills
          languages: languages
          degrees: degrees
          employment-types: employmentTypes
          states: states
          municipalities: municipalities
      x-data-classification: public
      security: [{ OAuth2: ['catalog:read'] }]
      parameters:
        - { $ref: '#/components/parameters/Catalog' }
        - { $ref: '#/components/parameters/PageSize' }
        - { $ref: '#/components/parameters/PageCursor' }
        - { name: active, in: query, schema: { type: boolean, default: true } }
        - { name: q, in: query, description: Prefijo normalizado del nombre., schema: { type: string, minLength: 1, maxLength: 100 } }
      responses:
        '200':
          description: Página de catálogo.
          content: { application/json: { schema: { $ref: '#/components/schemas/CatalogItemList' } } }
        '400': { $ref: '#/components/responses/CatalogsBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    OAuth2:
      type: oauth2
      description: Client Credentials; cada cliente pertenece a una sola organización.
      flows:
        clientCredentials:
          tokenUrl: https://auth.beematch.com/oauth2/token
          scopes:
            job_posting:read: Leer vacantes propias.
            application:read: Leer postulaciones propias y sus resúmenes.
            candidate:read: Leer datos esenciales de identidad de candidatos autorizados.
            candidate.profile:read: Leer perfil profesional consolidado.
            candidate.pii:read: Incluir PII permitida; requiere aprobación contractual.
            catalog:read: Leer catálogos.
    MutualTLS:
      type: mutualTLS
      description: mTLS opcional como defensa adicional; no sustituye OAuth2.

  parameters:
    PageSize: { name: page_size, in: query, description: Tamaño de página., schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
    PageCursor: { name: page_cursor, in: query, description: Valor opaco de `meta.next_cursor` de la página anterior; omítalo en la primera solicitud., schema: { type: string, minLength: 16, maxLength: 2048 } }
    UpdatedSince: { name: updated_since, in: query, description: Cambios desde el instante RFC 3339 UTC., schema: { $ref: '#/components/schemas/Timestamp' } }
    IfNoneMatch: { name: If-None-Match, in: header, description: ETag de una lectura previa., schema: { type: string, maxLength: 200 } }
    JobPostingId: { name: job_posting_id, in: path, required: true, schema: { $ref: '#/components/schemas/OpaqueId' } }
    ApplicationId: { name: application_id, in: path, required: true, schema: { $ref: '#/components/schemas/OpaqueId' } }
    CandidateId: { name: candidate_id, in: path, required: true, schema: { $ref: '#/components/schemas/OpaqueId' } }
    Catalog: { name: catalog, in: path, required: true, schema: { type: string, enum: [job-posting-categories, company-categories-types, skill-types, skills, languages, degrees, employment-types, states, municipalities] } }

  headers:
    XRequestId: { description: ID de trazabilidad recibido o generado., schema: { type: string, pattern: '^[A-Za-z0-9._:-]{8,128}$' } }
    ETag: { description: Versión opaca del recurso., schema: { type: string, example: 'W/"6f8d-01J8"' } }
    RateLimitLimit: { description: Límite de solicitudes en la ventana., schema: { type: integer, minimum: 0 } }
    RateLimitRemaining: { description: Solicitudes restantes., schema: { type: integer, minimum: 0 } }
    RateLimitReset: { description: Epoch UTC en segundos del reinicio., schema: { type: integer, format: int64 } }
    RetryAfter: { description: Segundos antes de reintentar., schema: { type: integer, minimum: 1 } }

  responses:
    HealthBadRequest:
      description: La solicitud de health contiene query parameters u otros datos no admitidos.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalid_query:
              summary: Query parameter no admitido
              value: { type: 'https://api.beematch.com/problems/invalid-request', title: Solicitud inválida, status: 400, detail: 'El endpoint de health no admite parámetros de consulta.', instance: '/v1/health?verbose=true', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/verbose', code: unexpected_parameter, message: 'El parámetro verbose no está permitido.' }] }
    JobPostingsBadRequest:
      description: Uno o más filtros o parámetros de paginación de vacantes son inválidos.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalid_status:
              summary: Estado de vacante inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'status contiene un valor no admitido.', instance: '/v1/job-postings?status=published', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/status/0', code: invalid_enum_value, message: 'Debe ser uno de: draft, publish, paused, closed, expired, archived.' }] }
            invalid_updated_since:
              summary: Fecha de actualización inválida
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'updated_since debe ser una fecha y hora RFC 3339 válida.', instance: '/v1/job-postings?updated_since=ayer', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/updated_since', code: invalid_date_time, message: 'Use una fecha y hora RFC 3339 en UTC.' }] }
            invalid_page_cursor:
              summary: Cursor inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_cursor no es un cursor válido.', instance: '/v1/job-postings?page_cursor=bad', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_cursor', code: invalid_cursor, message: 'Use el valor opaco next_cursor devuelto por una página anterior.' }] }
            invalid_page_size:
              summary: Tamaño de página fuera de rango
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_size debe estar entre 1 y 200.', instance: '/v1/job-postings?page_size=500', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_size', code: out_of_range, message: 'Debe ser un entero entre 1 y 200.' }] }
    JobPostingBadRequest:
      description: El identificador de vacante tiene un formato inválido.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'job_posting_id tiene un formato inválido.', instance: '/v1/job-postings/%20', code: invalid_path_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/path/job_posting_id', code: invalid_identifier, message: 'Debe ser un identificador opaco de 8 a 128 caracteres permitidos.' }] }
    ApplicationsBadRequest:
      description: Uno o más filtros o parámetros de paginación de postulaciones son inválidos.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalid_ids:
              summary: Identificadores de filtro inválidos
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'Uno o más identificadores de filtro tienen formato inválido.', instance: '/v1/applications?job_posting_id=%20&candidate_id=bad!', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/job_posting_id', code: invalid_identifier, message: 'Debe ser un identificador opaco válido.' }, { pointer: '/query/candidate_id', code: invalid_identifier, message: 'Debe ser un identificador opaco válido.' }] }
            invalid_status:
              summary: Identificador de etapa inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'status contiene un identificador de etapa inválido.', instance: '/v1/applications?status=', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/status/0', code: invalid_stage_identifier, message: 'El identificador debe contener entre 1 y 128 caracteres.' }] }
            invalid_dates:
              summary: Filtro de fecha inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'Los filtros de fecha deben usar RFC 3339.', instance: '/v1/applications?applied_since=ayer&updated_since=hoy', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/applied_since', code: invalid_date_time, message: 'Use una fecha y hora RFC 3339 en UTC.' }, { pointer: '/query/updated_since', code: invalid_date_time, message: 'Use una fecha y hora RFC 3339 en UTC.' }] }
            invalid_page_cursor:
              summary: Cursor inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_cursor no es un cursor válido.', instance: '/v1/applications?page_cursor=bad', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_cursor', code: invalid_cursor, message: 'Use el valor opaco next_cursor devuelto por una página anterior.' }] }
            invalid_page_size:
              summary: Tamaño de página fuera de rango
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_size debe estar entre 1 y 200.', instance: '/v1/applications?page_size=0', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_size', code: out_of_range, message: 'Debe ser un entero entre 1 y 200.' }] }
    ApplicationBadRequest:
      description: El identificador de postulación tiene un formato inválido.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'application_id tiene un formato inválido.', instance: '/v1/applications/bad!', code: invalid_path_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/path/application_id', code: invalid_identifier, message: 'Debe ser un identificador opaco de 8 a 128 caracteres permitidos.' }] }
    CandidateBadRequest:
      description: El identificador de candidato tiene un formato inválido.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'candidate_id tiene un formato inválido.', instance: '/v1/candidates/bad!', code: invalid_path_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/path/candidate_id', code: invalid_identifier, message: 'Debe ser un identificador opaco de 8 a 128 caracteres permitidos.' }] }
    CatalogsBadRequest:
      description: El catálogo, la búsqueda o los parámetros de paginación son inválidos.
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalid_catalog:
              summary: Catálogo no admitido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'catalog no identifica un catálogo admitido.', instance: '/v1/catalogs/unknown', code: invalid_path_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/path/catalog', code: invalid_enum_value, message: 'Use uno de los nombres de catálogo documentados.' }] }
            invalid_q:
              summary: Prefijo de búsqueda inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'q debe contener entre 1 y 100 caracteres.', instance: '/v1/catalogs/skills?q=', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/q', code: invalid_length, message: 'Debe contener entre 1 y 100 caracteres.' }] }
            invalid_page_cursor:
              summary: Cursor inválido
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_cursor no es un cursor válido.', instance: '/v1/catalogs/skills?page_cursor=bad', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_cursor', code: invalid_cursor, message: 'Use el valor opaco next_cursor devuelto por una página anterior.' }] }
            invalid_page_size:
              summary: Tamaño de página fuera de rango
              value: { type: 'https://api.beematch.com/problems/invalid-parameter', title: Parámetro inválido, status: 400, detail: 'page_size debe estar entre 1 y 200.', instance: '/v1/catalogs/skills?page_size=500', code: invalid_query_parameter, request_id: req_01J8B6P0J9MX7QZ8, errors: [{ pointer: '/query/page_size', code: out_of_range, message: 'Debe ser un entero entre 1 y 200.' }] }
    Unauthorized: { description: 'Credencial ausente, inválida o expirada.', headers: { WWW-Authenticate: { description: Desafío OAuth2., schema: { type: string } }, X-Request-Id: { $ref: '#/components/headers/XRequestId' } }, content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
    Forbidden: { description: Scope insuficiente. No revela recursos de otro tenant., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
    NotFound: { description: Recurso inexistente o no visible para el tenant., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
    NotModified: { description: Sin cambios; respuesta sin cuerpo., headers: { ETag: { $ref: '#/components/headers/ETag' }, X-Request-Id: { $ref: '#/components/headers/XRequestId' } } }
    TooManyRequests: { description: Rate limit excedido., headers: { Retry-After: { $ref: '#/components/headers/RetryAfter' }, RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }, RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }, RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' } }, content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
    InternalError: { description: Error interno no atribuible al cliente., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
    ServiceUnavailable: { description: Servicio o dependencia temporalmente no disponible., headers: { Retry-After: { $ref: '#/components/headers/RetryAfter' } }, content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }

  schemas:
    OpaqueId: { type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9_-]+$', example: 01J8B6P0J9MX7QZ8W4B4P6JHEA }
    Timestamp: { type: string, format: date-time, description: RFC 3339 en UTC., example: '2026-08-18T16:00:00Z' }
    Health: { type: object, additionalProperties: false, required: [status, time, version], properties: { status: { type: string, enum: [ok, degraded] }, time: { $ref: '#/components/schemas/Timestamp' }, version: { type: string } } }
    CandidateLocation: { type: [object, 'null'], additionalProperties: false, properties: { state_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }, municipality_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] } } }
    PageMeta: { type: object, additionalProperties: false, required: [page_size, has_more, snapshot_at], properties: { page_size: { type: integer, minimum: 0, maximum: 200 }, has_more: { type: boolean }, next_cursor: { type: [string, 'null'], maxLength: 2048 }, snapshot_at: { $ref: '#/components/schemas/Timestamp' } } }
    ListBase: { type: object, required: [meta], properties: { meta: { $ref: '#/components/schemas/PageMeta' } }, description: '`meta.next_cursor` es la única continuación de paginación; envíelo como `page_cursor`. null indica el final.' }

    JobPostingStatus:
      type: string
      enum: [draft, publish, paused, closed, expired, archived]
      description: 'Estado externo de la vacante. `draft` representa registros sin estado; `publish` se conserva exactamente y no se normaliza a `published`.'
    JobPosting:
      type: object
      additionalProperties: false
      required: [id, title, status, active, created_at, updated_at]
      x-internal-source: { collection: jobPostings }
      x-data-classification: confidential
      properties:
        id: { $ref: '#/components/schemas/OpaqueId' }
        jobCode: { type: [string, 'null'], maxLength: 100, description: 'Código externo de la vacante; mapea exactamente `jobPostings.jobCode`.', example: DEV-042 }
        title: { type: string, minLength: 1, maxLength: 240, example: Backend Engineer }
        description: { type: [string, 'null'], maxLength: 30000, description: 'Descripción pública de la vacante; mapea `jobPostings.description`.' }
        job_description: { type: [string, 'null'], maxLength: 30000, description: 'Contexto interno para IA; mapea `jobPostings.jobDescription`. No es la descripción pública de la vacante.' }
        job_description_desirable: { type: [string, 'null'], maxLength: 30000, description: 'Contexto interno para IA sobre aspectos deseables; mapea `jobPostings.jobDescriptionDesirable`. No es contenido público de la vacante.' }
        job_description_non_negotiable: { type: [string, 'null'], maxLength: 30000, description: 'Contexto interno para IA sobre requisitos no negociables; mapea `jobPostings.jobDescriptionNonNegotiable`. No es contenido público de la vacante.' }
        status: { $ref: '#/components/schemas/JobPostingStatus' }
        active: { type: boolean }
        employment_type_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }
        work_schedule: { type: [string, 'null'], enum: [full_time, part_time, null] }
        work_mode: { type: [string, 'null'], enum: [onsite, hybrid, remote, to_be_defined, null] }
        experience_level: { type: [string, 'null'], maxLength: 80 }
        location: { type: [string, 'null'], maxLength: 500, description: 'Dirección formateada de `locationGoogleMaps.formatted_address`.' }
        salary_min: { type: [number, 'null'], minimum: 0, example: 25000 }
        salary_max: { type: [number, 'null'], minimum: 0, example: 35000 }
        publication_date: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }
        expiration_date: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }
        max_candidates: { type: [integer, 'null'], minimum: 0 }
        language_ids: { type: array, maxItems: 50, items: { $ref: '#/components/schemas/OpaqueId' } }
        required_degree_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }
        created_at: { $ref: '#/components/schemas/Timestamp' }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    JobPostingList:
      allOf:
        - { $ref: '#/components/schemas/ListBase' }
        - type: object
          required: [data]
          properties:
            data: { type: array, items: { $ref: '#/components/schemas/JobPosting' } }

    ApplicationStatus: { type: string, minLength: 1, maxLength: 128, description: 'Identificador dinámico raw de etapa. No se normaliza ni se limita a un enum fijo.' }
    StatusEvent: { type: object, additionalProperties: false, required: [step_id, changed_at, changed_by, action], x-internal-source: { collection: ApplicationStatusLog }, properties: { step_id: { $ref: '#/components/schemas/ApplicationStatus' }, changed_at: { $ref: '#/components/schemas/Timestamp' }, changed_by: { type: string, maxLength: 128 }, action: { type: string, minLength: 1, maxLength: 100 }, comment: { type: [string, 'null'], maxLength: 2000 } } }
    Gender: { type: [string, 'null'], maxLength: 100, description: 'Valor de género almacenado en `candidates.gender`; no se normaliza a un enum externo.' }
    CandidateSummary: { type: object, additionalProperties: false, required: [id, display_name], x-internal-source: { collection: candidates, gender: candidates.gender }, x-data-classification: restricted, properties: { id: { $ref: '#/components/schemas/OpaqueId' }, display_name: { type: string, maxLength: 240, example: Ana P. }, gender: { $ref: '#/components/schemas/Gender' }, location: { $ref: '#/components/schemas/CandidateLocation' }, email: { type: [string, 'null'], format: email, description: 'Sólo con `candidate.pii:read`.' }, phone: { type: [string, 'null'], maxLength: 32, description: 'Sólo con `candidate.pii:read`.' } } }
    EvaluationSummary: { type: object, additionalProperties: false, required: [evaluation_id, evaluation_type, name, taken_at], description: 'Agrega evaluaciones BIA desde `specificEvaluationsCandidates` por candidateID + jobPostingID y preguntas personalizadas desde `quizCandidates`. El nombre se resuelve mediante `jobPostingQuizzes.quizID -> quizzes.title`.', properties: { evaluation_id: { $ref: '#/components/schemas/OpaqueId' }, evaluation_type: { type: string, enum: [bia, custom_quiz] }, name: { type: string, maxLength: 240 }, score: { type: [number, 'null'], minimum: 0 }, maximum_score: { type: [number, 'null'], minimum: 0 }, summary: { type: [string, 'null'], maxLength: 5000, description: Sin respuestas ni transcripciones. }, taken_at: { $ref: '#/components/schemas/Timestamp' } } }
    Application:
      type: object
      additionalProperties: false
      required: [id, job_posting_id, candidate, status, status_history, applied_at, updated_at]
      x-internal-source: { collection: jobApplications, matching-score: matchAnalysis.match, status-history: ApplicationStatusLog, status-derivation: most-recent-status-history-event, bia-join: specificEvaluationsCandidates.candidateID + jobPostingID, custom-evaluations: quizCandidates, evaluation-name-resolution: jobPostingQuizzes -> quizzes.title }
      x-data-classification: restricted
      properties:
        id: { $ref: '#/components/schemas/OpaqueId' }
        job_posting_id: { $ref: '#/components/schemas/OpaqueId' }
        candidate: { $ref: '#/components/schemas/CandidateSummary' }
        matchingScore: { type: [number, 'null'], description: 'Puntaje de coincidencia obtenido de `matchAnalysis.match`; null cuando no existe un análisis disponible. El contrato no impone una escala numérica.' }
        status: { $ref: '#/components/schemas/ApplicationStatus', description: 'Valor raw dinámico derivado del `step_id` del evento más reciente de `status_history`.' }
        status_history: { type: array, maxItems: 500, description: 'Historial cronológico de etapas. `status` se obtiene del evento con `changed_at` más reciente.', items: { $ref: '#/components/schemas/StatusEvent' } }
        evaluations: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/EvaluationSummary' } }
        applied_at: { $ref: '#/components/schemas/Timestamp' }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    ApplicationList:
      allOf:
        - { $ref: '#/components/schemas/ListBase' }
        - type: object
          required: [data]
          properties:
            data: { type: array, items: { $ref: '#/components/schemas/Application' } }

    CandidatePii: { type: object, additionalProperties: false, description: 'Sólo con `candidate.pii:read`; nunca contiene identificación oficial, CV ni archivos.', x-data-classification: restricted, properties: { email: { type: [string, 'null'], format: email }, phone: { type: [string, 'null'], maxLength: 32 }, date_of_birth: { type: [string, 'null'], format: date } } }
    Education: { type: object, additionalProperties: false, required: [id, degree_title, completed], properties: { id: { $ref: '#/components/schemas/OpaqueId' }, degree_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }, degree_title: { type: string, maxLength: 240 }, school_name: { type: [string, 'null'], maxLength: 240 }, completed: { type: string, enum: [yes, no, studying] }, start_date: { type: [string, 'null'], format: date }, end_date: { type: [string, 'null'], format: date } } }
    Experience: { type: object, additionalProperties: false, required: [id, job_title, company_name, current_job], properties: { id: { $ref: '#/components/schemas/OpaqueId' }, job_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }, job_title: { type: string, maxLength: 240 }, company_name: { type: string, maxLength: 240 }, description: { type: [string, 'null'], maxLength: 5000 }, current_job: { type: boolean }, start_date: { type: [string, 'null'], format: date }, end_date: { type: [string, 'null'], format: date } } }
    LanguageProficiency: { type: object, additionalProperties: false, required: [id, language_id, proficiency_level], properties: { id: { $ref: '#/components/schemas/OpaqueId' }, language_id: { $ref: '#/components/schemas/OpaqueId' }, proficiency_level: { type: integer, minimum: 1, maximum: 5 }, certification: { type: [string, 'null'], maxLength: 240 } } }
    CandidateSkill: { type: object, additionalProperties: false, required: [id, skill_id, level], properties: { id: { $ref: '#/components/schemas/OpaqueId' }, skill_id: { $ref: '#/components/schemas/OpaqueId' }, custom_name: { type: [string, 'null'], maxLength: 160 }, level: { type: number, minimum: 0, maximum: 5 }, last_tested_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }, recommendation: { type: [string, 'null'], maxLength: 3000 } } }
    Candidate:
      type: object
      additionalProperties: false
      required: [id, display_name, education, experience, languages, skills, created_at, updated_at]
      x-internal-source: { collections: [candidates, candidateEducation, candidateExperience, candidateLanguages, candidateSkill] }
      x-data-classification: restricted
      properties:
        id: { $ref: '#/components/schemas/OpaqueId' }
        display_name: { type: string, maxLength: 240 }
        gender: { $ref: '#/components/schemas/Gender' }
        experience_level: { type: [string, 'null'], maxLength: 80 }
        employment_type_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }
        work_mode: { type: [string, 'null'], maxLength: 80 }
        location: { $ref: '#/components/schemas/CandidateLocation' }
        pii: { oneOf: [{ $ref: '#/components/schemas/CandidatePii' }, { type: 'null' }] }
        education: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/Education' } }
        experience: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/Experience' } }
        languages: { type: array, maxItems: 50, items: { $ref: '#/components/schemas/LanguageProficiency' } }
        skills: { type: array, maxItems: 300, items: { $ref: '#/components/schemas/CandidateSkill' } }
        created_at: { $ref: '#/components/schemas/Timestamp' }
        updated_at: { $ref: '#/components/schemas/Timestamp' }

    CatalogItem: { type: object, additionalProperties: false, required: [id, name, active], properties: { id: { $ref: '#/components/schemas/OpaqueId' }, name: { type: string, maxLength: 240 }, description: { type: [string, 'null'], maxLength: 2000 }, active: { type: boolean }, parent_id: { oneOf: [{ $ref: '#/components/schemas/OpaqueId' }, { type: 'null' }] }, updated_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] } } }
    CatalogItemList: { allOf: [{ $ref: '#/components/schemas/ListBase' }, { type: object, required: [data], properties: { data: { type: array, items: { $ref: '#/components/schemas/CatalogItem' } } } }] }

    Problem:
      type: object
      additionalProperties: false
      description: 'Problem Details compatible con RFC 9457. `code`, `request_id` y `errors` son extensiones estables de BeeMatch.'
      required: [type, title, status, detail, instance, code, request_id, errors]
      properties:
        type: { type: string, format: uri, example: 'https://api.beematch.com/problems/invalid-parameter' }
        title: { type: string, maxLength: 200, example: Parámetro inválido }
        status: { type: integer, minimum: 400, maximum: 599, example: 400 }
        detail: { type: string, maxLength: 5000 }
        instance: { type: string, format: uri-reference }
        code: { type: string, minLength: 1, maxLength: 100, pattern: '^[a-z0-9_]+$', description: Código estable de error a nivel de problema. }
        request_id: { type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }
        errors:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/ProblemError'
    ProblemError:
      type: object
      additionalProperties: false
      required: [pointer, code, message]
      properties:
        pointer: { type: string, minLength: 1, maxLength: 500, pattern: '^/(path|query|header|body)(/.*)?$', description: 'Ubicación del campo inválido mediante un puntero estable relativo a path, query, header o body.' }
        code: { type: string, minLength: 1, maxLength: 100, pattern: '^[a-z0-9_]+$', description: Código estable de validación del campo. }
        message: { type: string, minLength: 1, maxLength: 500, description: Mensaje legible para humanos. }
