openapi: 3.1.0
info:
  title: Verificar CURP API
  version: 1.0.0
  description: |
    API para validar una CURP, cotejarla contra los datos que ya tienes de la
    persona y verificarla en el registro de RENAPO.

    - **Validación** (al instante, sin consultar ningún registro): longitud,
      forma, entidad, fecha de nacimiento y dígito verificador.
    - **Cotejo** (al instante): si mandas `persona`, compara cada campo contra
      lo que la CURP codifica — `coincide`, `no_coincide` o `indeterminado`.
    - **Verificación en RENAPO** (opcional, asíncrona): con `verificar: true`
      consultamos el registro; el resultado llega firmado a tu destino
      (webhook, correo o Telegram) y también puedes recogerlo una vez con
      `GET /curp/verificacion/{jobId}`.

    ## Autenticación
    Cada petición lleva tu API key en el encabezado `X-API-Key`. Créala en
    https://verificarcurp.com/dashboard/api-keys.

    ## Cobro
    Un token por respuesta. Una CURP mal formada, un `no_coincide` y un
    `indeterminado` son respuestas y se cobran; una petición mal formada (400)
    no. La verificación en RENAPO va incluida en el mismo token. Si fallamos
    nosotros (500), el token se devuelve.

    ## Retención
    No guardamos la CURP consultada ni los datos del cotejo. En una
    verificación, la CURP se conserva solo mientras se consulta el registro y
    el resultado se borra en cuanto se entrega.
  contact:
    name: Verificar CURP
    url: https://verificarcurp.com/docs
servers:
  - url: https://verificarcurp.com/api/v1
    description: Producción
security:
  - ApiKeyAuth: []
tags:
  - name: CURP
    description: Validación, cotejo y verificación en RENAPO
  - name: Cuenta
    description: Saldo de tokens
  - name: Webhooks
    description: Lo que llega a tu destino

paths:
  /curp:
    post:
      tags: [CURP]
      operationId: validarCurp
      summary: Validar, cotejar y (opcional) verificar una CURP
      description: |
        Responde al instante con la validación y, si mandaste `persona`, el
        cotejo. Con `verificar: true` además arranca una consulta al registro
        de RENAPO cuyo estado viene en `verificacion`; el resultado llega a
        `destinationId`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CurpRequest'
            examples:
              validacion:
                summary: Solo validación
                value:
                  curp: PEGJ850101HDFRRL04
              cotejo:
                summary: Validación y cotejo
                value:
                  curp: PEGJ850101HDFRRL04
                  persona:
                    nombres: Julio
                    primerApellido: Pérez
                    segundoApellido: García
                    fechaNacimiento: '1985-01-01'
                    sexo: H
                    entidad: Ciudad de México
              verificacion:
                summary: Con verificación en RENAPO
                value:
                  curp: PEGJ850101HDFRRL04
                  verificar: true
                  destinationId: dst_123
                  idempotencyKey: pedido-8841
      responses:
        '200':
          description: Respuesta (se cobra un token, salvo en una repetición con `idempotencyKey`).
          headers:
            Retry-After:
              description: Segundos para reintentar la verificación, cuando `verificacion.aceptada` es false y esperar ayuda.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CurpResponse'
              examples:
                valida:
                  summary: CURP válida, sin cotejo
                  value:
                    success: true
                    data:
                      curp: PEGJ850101HDFRRL04
                      valida: true
                      razon: null
                      fechaNacimiento: '1985-01-01'
                      sexo: H
                      claveEntidad: DF
                      entidad: Ciudad de México
                      cotejo: null
                      cotejoCampos: null
                    tokens_remaining: 431
                invalida:
                  summary: Dígito verificador incorrecto
                  value:
                    success: true
                    data:
                      curp: PEGJ850101HDFRRL05
                      valida: false
                      razon:
                        kind: check-digit
                        expected: '4'
                        actual: '5'
                      fechaNacimiento: null
                      sexo: null
                      claveEntidad: null
                      entidad: null
                      cotejo: null
                      cotejoCampos: null
                    tokens_remaining: 430
                cotejo:
                  summary: Cotejo con un campo que no coincide
                  value:
                    success: true
                    data:
                      curp: PEGJ850101HDFRRL04
                      valida: true
                      razon: null
                      fechaNacimiento: '1985-01-01'
                      sexo: H
                      claveEntidad: DF
                      entidad: Ciudad de México
                      cotejo: no_coincide
                      cotejoCampos:
                        nombres: coincide
                        primerApellido: coincide
                        fechaNacimiento: no_coincide
                    tokens_remaining: 429
                verificacionAceptada:
                  summary: Verificación en curso
                  value:
                    success: true
                    data:
                      curp: PEGJ850101HDFRRL04
                      valida: true
                      razon: null
                      fechaNacimiento: '1985-01-01'
                      sexo: H
                      claveEntidad: DF
                      entidad: Ciudad de México
                      cotejo: null
                      cotejoCampos: null
                    tokens_remaining: 428
                    verificacion:
                      aceptada: true
                      job_id: clz8k2p0000001
                      estado: pendiente
                      expires_at: '2026-09-29T16:35:00.000Z'
                verificacionRechazada:
                  summary: El registro no está disponible
                  value:
                    success: true
                    data:
                      curp: PEGJ850101HDFRRL04
                      valida: true
                      razon: null
                      fechaNacimiento: '1985-01-01'
                      sexo: H
                      claveEntidad: DF
                      entidad: Ciudad de México
                      cotejo: null
                      cotejoCampos: null
                    tokens_remaining: 427
                    verificacion:
                      aceptada: false
                      codigo: REGISTRY_UNAVAILABLE
                      retry_after: 240
        '400':
          description: Petición mal formada (`INVALID_REQUEST`) o `destinationId` que no es un destino activo de tu cuenta (`DESTINATION_NOT_FOUND`). No se cobra.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Saldo insuficiente.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      block_reason:
                        type: [string, 'null']
        '409':
          description: El `idempotencyKey` ya se usó en tu cuenta para otra CURP (`IDEMPOTENCY_KEY_REUSED`). No se cobra.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'

  /curp/verificacion/{jobId}:
    get:
      tags: [CURP]
      operationId: estadoVerificacion
      summary: Estado de una verificación en RENAPO
      description: |
        El estado de una verificación que arrancaste. No se cobra.

        Si el resultado del registro sigue guardado, esta respuesta lo trae en
        `valores` **una sola vez** y lo borra en la misma operación. Cuando la
        entrega a tu destino tuvo éxito, el resultado ya se borró y aquí solo
        verás los metadatos (`valores_entregados: false`); si tu destino falló,
        esta es la forma de recuperarlo.
      parameters:
        - name: jobId
          in: path
          required: true
          description: El `verificacion.job_id` de la respuesta de `POST /curp`.
          schema:
            type: string
      responses:
        '200':
          description: Estado de la verificación.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationStatusResponse'
              example:
                success: true
                verificacion:
                  job_id: clz8k2p0000001
                  estado: delivered
                  codigo: null
                  duracion_ms: 2140
                  creado_en: '2026-09-29T16:30:00.000Z'
                  expira_en: '2026-09-29T16:35:00.000Z'
                  terminado_en: '2026-09-29T16:30:02.140Z'
                  destino_id: dst_123
                  entrega_ok: false
                  entrega_ms: null
                  cobrado: true
                  intentos: 1
                  max_intentos: 3
                  proximo_intento_en: null
                  reintentos_hasta: null
                  ultimo_codigo: null
                  revision_manual: null
                  valores_entregados: true
                valores:
                  estatus: ACTIVA
                  curp: PEGJ850101HDFRRL04
                  nombres: JULIO
                  primerApellido: PEREZ
                  segundoApellido: GARCIA
                  fechaNacimiento: '01/01/1985'
                  sexo: HOMBRE
                  entidadNacimiento: CIUDAD DE MEXICO
                  nacionalidad: MEXICO
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No existe, no es de tu cuenta o ya venció su periodo de retención.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'

  /balance:
    get:
      tags: [Cuenta]
      operationId: obtenerSaldo
      summary: Saldo de tokens
      responses:
        '200':
          description: Saldo actual.
          content:
            application/json:
              schema:
                type: object
                required: [success, balance]
                properties:
                  success:
                    type: boolean
                    example: true
                  balance:
                    type: integer
                    example: 431
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

webhooks:
  curpVerification:
    post:
      tags: [Webhooks]
      operationId: curpVerificationWebhook
      summary: Resultado o aviso de una verificación
      description: |
        Lo que llega a un destino de tipo webhook. Cada aviso se entrega una
        sola vez (no se reintenta la entrega).

        **Firma.** `X-Signature` es `sha256=` + HMAC-SHA256 en hexadecimal,
        con el secreto del destino (`whsec_…`), de la cadena
        `X-Signature-Timestamp + "." + cuerpo crudo`. Verifica sobre los bytes
        exactos recibidos, no sobre el JSON re-serializado, compara en tiempo
        constante y rechaza timestamps viejos (p. ej. más de 5 minutos).

        Eventos:
        - `curp.verification.completed` — el registro respondió (incluido `estatus: NO_ENCONTRADA`).
        - `curp.verification.failed` — no obtuvimos respuesta (`estatus: SIN_RESPUESTA`).
        - `curp.verification.retrying` — aviso de avance entre reintentos; nunca trae la CURP.
        - `curp.verification.manual_review` — la verificación pasó a revisión manual.
      parameters:
        - name: X-Signature
          in: header
          required: true
          schema:
            type: string
            example: sha256=5f2b…
        - name: X-Signature-Timestamp
          in: header
          required: true
          description: Segundos Unix que cubre la firma.
          schema:
            type: string
        - name: X-Idempotency-Key
          in: header
          required: true
          description: Tu `idempotencyKey`, o el `job_id` si no mandaste uno.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            examples:
              completed:
                value:
                  version: '1'
                  event: curp.verification.completed
                  idempotencyKey: pedido-8841
                  documentType: curp
                  source: api
                  captureLinkId: null
                  reference: null
                  extractedAt: '2026-09-29T16:30:02.000Z'
                  data:
                    estatus: ACTIVA
                    curp: PEGJ850101HDFRRL04
                    nombres: JULIO
                    primerApellido: PEREZ
                    segundoApellido: GARCIA
                    fechaNacimiento: '01/01/1985'
                    sexo: HOMBRE
                    entidadNacimiento: CIUDAD DE MEXICO
                    nacionalidad: MEXICO
              failed:
                value:
                  version: '1'
                  event: curp.verification.failed
                  idempotencyKey: pedido-8841
                  documentType: curp
                  source: api
                  captureLinkId: null
                  reference: null
                  extractedAt: '2026-09-29T16:35:00.000Z'
                  data:
                    estatus: SIN_RESPUESTA
                    motivo: no-answer
              retrying:
                value:
                  version: '1'
                  event: curp.verification.retrying
                  idempotencyKey: pedido-8841
                  documentType: curp
                  source: api
                  captureLinkId: null
                  reference: null
                  extractedAt: '2026-09-29T16:31:12.000Z'
                  data:
                    job_id: clz8k2p0000001
                    attempt: '1'
                    max_attempts: '3'
                    error_code: REGISTRY_UNAVAILABLE
                    next_attempt_at: '2026-09-29T16:36:12.000Z'
                    retry_in_seconds: '300'
                    retry_deadline_at: '2026-09-30T16:31:00.000Z'
                    idempotency_key: pedido-8841
      responses:
        '2XX':
          description: Recibido. Cualquier otro código cuenta como entrega fallida.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

  responses:
    Unauthorized:
      description: Falta la API key (`MISSING_API_KEY`) o no es válida (`INVALID_API_KEY`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalError:
      description: Error nuestro (`INTERNAL_ERROR`). Si se había cobrado, el token se devuelve.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      required: [success, error, code]
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          description: Mensaje legible, en español.
        code:
          type: string
          enum:
            - INVALID_REQUEST
            - DESTINATION_NOT_FOUND
            - MISSING_API_KEY
            - INVALID_API_KEY
            - INSUFFICIENT_TOKENS
            - IDEMPOTENCY_KEY_REUSED
            - NOT_FOUND
            - INTERNAL_ERROR

    Persona:
      type: object
      description: |
        Lo que ya sabes de la persona. Todos los campos son opcionales; solo se
        comparan los que mandas. `segundoApellido: null` significa "no tiene
        segundo apellido" (y se compara); omitirlo significa "no comparar".
      properties:
        nombres:
          type: string
          maxLength: 200
        primerApellido:
          type: string
          maxLength: 200
        segundoApellido:
          type: [string, 'null']
          maxLength: 200
        fechaNacimiento:
          type: string
          format: date
          description: AAAA-MM-DD
        sexo:
          type: string
          maxLength: 30
          description: H o M. Otro valor responde `indeterminado` en ese campo.
        entidad:
          type: string
          maxLength: 100
          description: Nombre o clave de la entidad de nacimiento.

    Reintentos:
      type: object
      description: |
        Cambia, solo para esta petición, los reintentos automáticos de la
        verificación. Un valor fuera de rango se ajusta al límite; uno de tipo
        incorrecto se ignora (nunca es un 400).
      properties:
        activo:
          type: boolean
          description: Con false no hay reintentos automáticos; pasa directo a revisión manual.
        max:
          type: integer
          description: Intentos en total.
        espera_s:
          type: integer
          description: Segundos antes del primer reintento (la espera se duplica en cada intento).

    CurpRequest:
      type: object
      required: [curp]
      properties:
        curp:
          type: string
          maxLength: 64
          example: PEGJ850101HDFRRL04
          description: Se recortan los espacios y se pasa a mayúsculas.
        persona:
          $ref: '#/components/schemas/Persona'
        verificar:
          type: boolean
          default: false
          description: Consultar también el registro de RENAPO. Requiere `destinationId`.
        destinationId:
          type: string
          maxLength: 64
          description: Destino (webhook, correo o Telegram) de tu cuenta, configurado en el panel en *Destinos*.
        idempotencyKey:
          type: string
          maxLength: 128
          description: |
            Etiqueta tuya para esta verificación, devuelta en la entrega. Repetir
            la llamada con la misma clave no crea otra verificación ni cobra.
            **Nunca pongas la CURP aquí.**
        reintentos:
          $ref: '#/components/schemas/Reintentos'

    CurpInvalidReason:
      description: La PRIMERA regla que la CURP no cumple.
      oneOf:
        - $ref: '#/components/schemas/ReasonLength'
        - $ref: '#/components/schemas/ReasonShape'
        - $ref: '#/components/schemas/ReasonEntidad'
        - $ref: '#/components/schemas/ReasonBirthDate'
        - $ref: '#/components/schemas/ReasonCheckDigit'
      discriminator:
        propertyName: kind
        mapping:
          length: '#/components/schemas/ReasonLength'
          shape: '#/components/schemas/ReasonShape'
          entidad: '#/components/schemas/ReasonEntidad'
          birth-date: '#/components/schemas/ReasonBirthDate'
          check-digit: '#/components/schemas/ReasonCheckDigit'

    ReasonLength:
      type: object
      required: [kind, length]
      properties:
        kind:
          type: string
          enum: [length]
        length:
          type: integer

    ReasonShape:
      type: object
      required: [kind, part, positions]
      properties:
        kind:
          type: string
          enum: [shape]
        part:
          type: string
          enum: [initials, birth-date, sex, entidad, consonants, differentiator, check-digit]
        positions:
          type: array
          items:
            type: integer
          minItems: 2
          maxItems: 2
          description: Posiciones (base 1) de la parte que falló.

    ReasonEntidad:
      type: object
      required: [kind, code]
      properties:
        kind:
          type: string
          enum: [entidad]
        code:
          type: string

    ReasonBirthDate:
      type: object
      required: [kind, year, month, day]
      properties:
        kind:
          type: string
          enum: [birth-date]
        year:
          type: integer
        month:
          type: integer
        day:
          type: integer

    ReasonCheckDigit:
      type: object
      required: [kind, expected, actual]
      properties:
        kind:
          type: string
          enum: [check-digit]
        expected:
          type: string
        actual:
          type: string

    CotejoVerdict:
      type: string
      enum: [coincide, no_coincide, indeterminado]

    CurpAnswer:
      type: object
      description: Todo se deriva de la CURP enviada; nada viene de un registro.
      required: [curp, valida, razon, fechaNacimiento, sexo, claveEntidad, entidad, cotejo, cotejoCampos]
      properties:
        curp:
          type: string
        valida:
          type: boolean
        razon:
          description: Por qué no es válida; null cuando sí lo es.
          anyOf:
            - $ref: '#/components/schemas/CurpInvalidReason'
            - type: 'null'
        fechaNacimiento:
          type: [string, 'null']
          format: date
        sexo:
          type: [string, 'null']
          enum: [H, M, null]
        claveEntidad:
          type: [string, 'null']
          enum: [AS, BC, BS, CC, CL, CM, CS, CH, DF, DG, GT, GR, HG, JC, MC, MN, MS, NT, NL, OC, PL, QT, QR, SP, SL, SR, TC, TS, TL, VZ, YN, ZS, NE, null]
          description: NE = nacido en el extranjero.
        entidad:
          type: [string, 'null']
        cotejo:
          type: [string, 'null']
          enum: [coincide, no_coincide, indeterminado, null]
          description: Veredicto global; null sin `persona` o con CURP inválida.
        cotejoCampos:
          type: [object, 'null']
          description: Veredicto por cada campo enviado en `persona`.
          properties:
            nombres:
              $ref: '#/components/schemas/CotejoVerdict'
            primerApellido:
              $ref: '#/components/schemas/CotejoVerdict'
            segundoApellido:
              $ref: '#/components/schemas/CotejoVerdict'
            fechaNacimiento:
              $ref: '#/components/schemas/CotejoVerdict'
            sexo:
              $ref: '#/components/schemas/CotejoVerdict'
            entidad:
              $ref: '#/components/schemas/CotejoVerdict'

    VerificationAccepted:
      type: object
      required: [aceptada, job_id, estado, expires_at]
      properties:
        aceptada:
          type: boolean
          enum: [true]
        job_id:
          type: string
        estado:
          type: string
          description: '`pendiente` al crearse; en una repetición, el estado actual (`running`, `retry-wait`, `delivered`…).'
        expires_at:
          type: string
          format: date-time
        repetida:
          type: boolean
          description: Presente y true cuando es una repetición con el mismo `idempotencyKey`.
        reintentos:
          $ref: '#/components/schemas/Reintentos'

    VerificationDeclined:
      type: object
      required: [aceptada, codigo, retry_after]
      properties:
        aceptada:
          type: boolean
          enum: [false]
        codigo:
          type: string
          enum: [REGISTRY_UNAVAILABLE, REGISTRY_CEILING_REACHED, TOO_MANY_VERIFICATIONS]
        retry_after:
          type: [integer, 'null']
          description: Segundos para reintentar, o null cuando esperar no ayudaría.

    CurpResponse:
      type: object
      required: [success, data, tokens_remaining]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/CurpAnswer'
        tokens_remaining:
          type: integer
        verificacion:
          description: Solo cuando pediste `verificar`.
          oneOf:
            - $ref: '#/components/schemas/VerificationAccepted'
            - $ref: '#/components/schemas/VerificationDeclined'

    RegistryValues:
      type: object
      description: La respuesta del registro. Solo `estatus` y `curp` son seguros; el resto aparece cuando el registro lo da.
      required: [estatus, curp]
      properties:
        estatus:
          type: string
          enum: [ACTIVA, BAJA, RNE, NO_ENCONTRADA]
        curp:
          type: string
        nombres:
          type: string
        primerApellido:
          type: string
        segundoApellido:
          type: string
        sexo:
          type: string
        fechaNacimiento:
          type: string
          description: En el formato del registro (DD/MM/AAAA).
        entidadNacimiento:
          type: string
        nacionalidad:
          type: string
        documentoProbatorio:
          type: string

    VerificationStatusResponse:
      type: object
      required: [success, verificacion]
      properties:
        success:
          type: boolean
        verificacion:
          type: object
          properties:
            job_id:
              type: string
            estado:
              type: string
              enum: [pending, running, retry-wait, needs-human, delivered, failed, expired]
            codigo:
              type: [string, 'null']
            duracion_ms:
              type: [integer, 'null']
            creado_en:
              type: string
              format: date-time
            expira_en:
              type: string
              format: date-time
            terminado_en:
              type: [string, 'null']
              format: date-time
            destino_id:
              type: [string, 'null']
            entrega_ok:
              type: [boolean, 'null']
            entrega_ms:
              type: [integer, 'null']
            cobrado:
              type: boolean
            intentos:
              type: integer
            max_intentos:
              type: [integer, 'null']
            proximo_intento_en:
              type: [string, 'null']
              format: date-time
            reintentos_hasta:
              type: [string, 'null']
              format: date-time
            ultimo_codigo:
              type: [string, 'null']
            revision_manual:
              type: [string, 'null']
              enum: [exhausted, not-retryable, disabled, window, user-cap, settings-changed, null]
            valores_entregados:
              type: boolean
              description: True solo en la consulta que recogió `valores`.
        valores:
          $ref: '#/components/schemas/RegistryValues'

    WebhookEnvelope:
      type: object
      required: [version, event, idempotencyKey, documentType, source, extractedAt, data]
      properties:
        version:
          type: string
          example: '1'
        event:
          type: string
          enum:
            - curp.verification.completed
            - curp.verification.failed
            - curp.verification.retrying
            - curp.verification.manual_review
        idempotencyKey:
          type: string
        documentType:
          type: string
          enum: [curp]
        source:
          type: string
          enum: [api, web]
        captureLinkId:
          type: [string, 'null']
        reference:
          type: [string, 'null']
        extractedAt:
          type: string
          format: date-time
        data:
          type: object
          description: |
            `completed`: los campos de RegistryValues. `failed`: `estatus: SIN_RESPUESTA` y `motivo`.
            `retrying` y `manual_review`: avance del trabajo, nunca la CURP. Todos los valores son texto.
          additionalProperties:
            type: string
