openapi: 3.0.3
info:
  title: Gate · alpadevs Partner API
  version: 1.1.0
  description: |
    API para ticketeras externas (partners) de Gate, el escáner de puerta de
    alpadevs. Permite sincronizar el inventario de boletos (espejo), anular
    boletos y consultar redenciones.

    **Autenticación**: API key `agk_…` como Bearer token. La llave la emite
    el admin de Gate y se muestra UNA sola vez. Cada llave pertenece a un
    proveedor; solo puede operar sobre experiencias enlazadas a ese proveedor
    por el admin de Gate.

    **Errores**: siempre `{ "error": { "code", "message" } }` con códigos
    estables (ver `ErrorResponse`).

    **Transición de marca (h4f → Gate · alpadevs)**: los endpoints, schemas y
    la firma del webhook NO cambian. Hasta el 2026-12-01: (a) el host legacy
    `h4f-partner-api.alexiz-padilla11.workers.dev` sigue como alias;
    (b) las llaves `h4fk_` existentes siguen siendo válidas — después, rotar
    a `agk_`; (c) el webhook envía también los headers legacy `x-h4f-*` con
    los mismos valores que `x-alpadevs-*`. Detalle en PARTNER_GUIDE.md.
servers:
  - url: https://gate-partners.alpadevs.com
    description: Producción
  - url: https://h4f-partner-api.alexiz-padilla11.workers.dev
    description: Alias legacy (transición; se retira el 2026-12-01)
  - url: http://127.0.0.1:8788
    description: Desarrollo local (wrangler dev)
security:
  - partnerKey: []
tags:
  - name: status
  - name: tickets
  - name: redemptions

paths:
  /status:
    get:
      tags: [status]
      summary: Health check (sin autenticación)
      security: []
      responses:
        "200":
          description: Servicio arriba
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /v1/partner/tickets/bulk:
    post:
      tags: [tickets]
      summary: Carga/actualización idempotente de inventario
      description: |
        Upsert de hasta 1000 boletos por request, idempotente por
        `externalRef` (el ID del boleto en el sistema del partner). Reenviar
        la misma fila actualiza en lugar de duplicar. El `qrContent` se
        normaliza en el servidor (trim; si es una URL con parámetro `chl`,
        se usa ese parámetro como código).

        **Transferencias**: reenviar la fila con el mismo `externalRef` y
        un `holderRef` nuevo cambia de titular (`updated`); reenviarla con
        el mismo `externalRef` y un `qrContent` NUEVO reemite el boleto en
        su misma fila (`reissued`) y el QR viejo deja de existir. Ver
        PARTNER_GUIDE.md § Transferencias.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PartnerBulkRequest"
            example:
              experienceId: "22222222-2222-4222-8222-222222222222"
              rows:
                - qrContent: "ABC.DEF"
                  externalRef: "TG-1001"
                  holderRef: "user-42"
                  holderName: "Ana Pérez"
                  metadata: { priceName: "VIP", experienceName: "Main Event" }
                - qrContent: "https://chart.googleapis.com/chart?cht=qr&chl=GHI.JKL"
                  externalRef: "TG-1002"
                  maxUses: 3
                  holderRef: "+5215512345678"
                - qrContent: "MNO.PQR"
                  externalRef: "TG-1003"
                  status: void
      responses:
        "200":
          description: Resumen del upsert
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkResult"
              example:
                inserted: 2
                updated: 1
                reissued: 0
                rejected:
                  - { index: 4, reason: "EMPTY_CODE" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/KeyInvalid" }
        "403": { $ref: "#/components/responses/ExperienceNotLinked" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

  /v1/partner/tickets/void:
    post:
      tags: [tickets]
      summary: Anula un boleto por externalRef
      description: |
        Marca el boleto como `void`. Es idempotente: anular dos veces
        responde `already_void`. Si el boleto YA fue redimido en puerta, la
        anulación se rechaza con 409 `REDEEMED_CONFLICT` (la redención no se
        pisa).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PartnerVoidRequest"
            example:
              experienceId: "22222222-2222-4222-8222-222222222222"
              externalRef: "TG-1001"
      responses:
        "200":
          description: Resultado del void
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PartnerVoidResult"
              example: { status: voided }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/KeyInvalid" }
        "403": { $ref: "#/components/responses/ExperienceNotLinked" }
        "404": { $ref: "#/components/responses/TicketNotFound" }
        "409":
          description: El boleto ya fue redimido; no se puede anular
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: { code: REDEEMED_CONFLICT, message: "Ticket already redeemed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

  /v1/partner/tickets/{ref}:
    get:
      tags: [tickets]
      summary: Consulta el estado de un boleto
      parameters:
        - name: ref
          in: path
          required: true
          description: externalRef del boleto (URL-encoded si aplica)
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: experienceId
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Estado actual del boleto
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PartnerTicket"
              example:
                externalRef: "TG-1001"
                experienceId: "22222222-2222-4222-8222-222222222222"
                status: redeemed
                redeemedAt: "2026-08-29T10:00:01.000Z"
                syncedAt: "2026-08-28T09:00:00.000Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/KeyInvalid" }
        "403": { $ref: "#/components/responses/ExperienceNotLinked" }
        "404": { $ref: "#/components/responses/TicketNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

  /v1/partner/redemptions:
    get:
      tags: [redemptions]
      summary: Pull de redenciones (cursor por redeemedAt ascendente)
      description: |
        Devuelve redenciones de boletos del proveedor posteriores a `since`.
        Paginación por cursor: si `items.length == limit`, `nextSince` trae
        el `redeemedAt` del último item — pásalo como `?since=` en la
        siguiente llamada. `nextSince: null` significa que no hay más.
      parameters:
        - name: since
          in: query
          required: false
          description: Timestamp ISO 8601 (con zona), exclusivo
          schema:
            type: string
            format: date-time
            example: "2026-08-29T00:00:00Z"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Página de redenciones
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PartnerRedemptionsResponse"
              example:
                items:
                  - externalRef: "TG-1001"
                    experienceId: "22222222-2222-4222-8222-222222222222"
                    redeemedAt: "2026-08-29T10:00:01.000Z"
                nextSince: null
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/KeyInvalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }

components:
  securitySchemes:
    partnerKey:
      type: http
      scheme: bearer
      bearerFormat: "agk_<48 hex>"
      description: >-
        API key del partner emitida por el admin de Gate. Formato actual
        `agk_` + 48 hex. Las llaves legacy `h4fk_` + 48 hex siguen
        autenticando hasta el 2026-12-01; después, rotar.

  responses:
    ValidationFailed:
      description: Body o query inválidos
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: VALIDATION_FAILED, message: "Validation failed" }
    KeyInvalid:
      description: API key ausente, malformada, revocada o desconocida
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: KEY_INVALID, message: "Invalid or revoked API key" }
    ExperienceNotLinked:
      description: La experiencia no está enlazada a este proveedor
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: EXPERIENCE_NOT_LINKED
              message: "Experience is not linked to this provider"
    TicketNotFound:
      description: No existe un boleto con ese externalRef en la experiencia
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: TICKET_NOT_FOUND, message: "Ticket not found" }
    RateLimited:
      description: Límite de ~60 req/min por llave y ruta excedido
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: RATE_LIMITED, message: "Too many requests" }
    Internal:
      description: Error interno
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: INTERNAL, message: "Internal error" }

  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - KEY_INVALID
                - EXPERIENCE_NOT_LINKED
                - VALIDATION_FAILED
                - TICKET_NOT_FOUND
                - REDEEMED_CONFLICT
                - RATE_LIMITED
                - INTERNAL
            message:
              type: string

    TicketImportRow:
      type: object
      required: [qrContent]
      properties:
        qrContent:
          type: string
          minLength: 1
          maxLength: 500
          description: Contenido del QR. Se normaliza en el servidor.
        externalRef:
          type: string
          maxLength: 200
          description: ID del boleto en el sistema del partner (clave de idempotencia).
        status:
          type: string
          enum: [valid, void]
          description: En import solo valid|void; redeemed lo produce la puerta.
        maxUses:
          type: integer
          minimum: 1
          maximum: 1000
          default: 1
          description: Entradas que admite el boleto (pulseras multi-día, pases múltiples).
        holderRef:
          type: string
          maxLength: 200
          description: >-
            Id ESTABLE del titular actual en el sistema del partner (userId,
            teléfono…). Se actualiza en cada upsert; es la base de las
            transferencias y del sello multi-uso (HOLDER_MISMATCH en puerta).
        holderName:
          type: string
          maxLength: 200
          description: Nombre del titular para mostrar al operador en puerta.
        metadata:
          type: object
          additionalProperties: true
          description: Datos libres mostrados al operador (tipo de entrada, asiento…).

    PartnerBulkRequest:
      type: object
      required: [experienceId, rows]
      properties:
        experienceId:
          type: string
          format: uuid
        rows:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: "#/components/schemas/TicketImportRow" }

    BulkResult:
      type: object
      required: [inserted, updated, rejected]
      properties:
        inserted:
          type: integer
          minimum: 0
          description: Boletos nuevos en el espejo.
        updated:
          type: integer
          minimum: 0
          description: Boletos existentes actualizados (mismo externalRef, mismo QR).
        reissued:
          type: integer
          minimum: 0
          description: >-
            Boletos existentes cuyo qrContent CAMBIÓ (mismo externalRef, QR
            nuevo): transferencia con reemisión. Opcional; ausente = 0.
        rejected:
          type: array
          description: Filas rechazadas con índice 0-based y motivo estable.
          items:
            type: object
            required: [index, reason]
            properties:
              index: { type: integer, minimum: 0 }
              reason:
                type: string
                description: >-
                  Motivo estable: EMPTY_CODE, DUPLICATE_IN_BATCH,
                  REDEEMED_CONFLICT (ya redimido: no se modifica, transfiere
                  ni reemite), QR_TAKEN (el QR nuevo pertenece a otro
                  boleto), EXPERIENCE_MISMATCH (ese externalRef existe en
                  otra experiencia). Motivos desconocidos = rechazo genérico.

    PartnerVoidRequest:
      type: object
      required: [experienceId, externalRef]
      properties:
        experienceId: { type: string, format: uuid }
        externalRef: { type: string, minLength: 1, maxLength: 200 }

    PartnerVoidResult:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [voided, already_void]

    PartnerTicket:
      type: object
      required: [externalRef, experienceId, status]
      properties:
        externalRef: { type: string }
        experienceId: { type: string, format: uuid }
        status:
          type: string
          enum: [valid, redeemed, void]
        redeemedAt:
          type: string
          format: date-time
          nullable: true
        syncedAt:
          type: string
          format: date-time
          nullable: true

    PartnerRedemption:
      type: object
      required: [externalRef, experienceId, redeemedAt]
      properties:
        externalRef:
          type: string
          nullable: true
        experienceId: { type: string, format: uuid }
        redeemedAt: { type: string, format: date-time }

    PartnerRedemptionsResponse:
      type: object
      required: [items, nextSince]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/PartnerRedemption" }
        nextSince:
          type: string
          format: date-time
          nullable: true
          description: Cursor para la siguiente página; null si no hay más.

    WebhookEvent:
      type: object
      description: >-
        Body del webhook saliente que Gate envía al notify_url del partner
        (ver bloque `webhooks` comentado al final del archivo y la sección
        "Webhooks" de PARTNER_GUIDE.md).
      required: [eventId, type, createdAt, payload]
      properties:
        eventId:
          type: string
          format: uuid
          description: Único por evento y estable entre reintentos (clave de dedup).
        type:
          type: string
          description: Tipo de evento; hoy `ticket.redeemed`. Tipos nuevos son aditivos.
          example: ticket.redeemed
        createdAt:
          type: string
          format: date-time
        payload:
          type: object
          description: >-
            Claves en snake_case (columnas del espejo tal cual). uses/max_uses
            distinguen cada uso de un boleto multi-uso; holder_ref es el titular
            al momento de redimir.
          additionalProperties: true
          properties:
            ticket_id: { type: string, format: uuid }
            external_ref: { type: string, nullable: true }
            qr_content: { type: string }
            experience_id: { type: string, format: uuid }
            redeemed_at: { type: string, format: date-time }
            redeemed_by_email: { type: string, nullable: true }
            uses: { type: integer }
            max_uses: { type: integer }
            holder_ref: { type: string, nullable: true }

# ---------------------------------------------------------------------------
# Webhooks salientes (Gate → partner).
#
# El keyword `webhooks:` es de OpenAPI 3.1; este archivo es 3.0.3, así que el
# bloque va comentado como documentación. Si se migra el spec a 3.1, basta
# descomentarlo. Detalle completo (verificación de firma, backoff,
# dead-letter, dedup) en PARTNER_GUIDE.md § Webhooks.
#
# webhooks:
#   ticketRedeemed:
#     post:
#       summary: "ticket.redeemed — un boleto del partner se redimió en puerta"
#       description: |
#         POST al notify_url configurado por el admin de Gate. Entrega "al
#         menos una vez": deduplica por eventId. Firma HMAC-SHA256 del body:
#         x-alpadevs-signature = "v1=" + hex(hmacSha256(webhook_secret,
#         "<x-alpadevs-timestamp>.<rawBody>")), con timestamp unix en
#         segundos. Timeout de entrega 10 s; sin 2xx se reintenta con backoff
#         exponencial (2^intentos min, tope 60 min) hasta 10 intentos y
#         después pasa a dead-letter (reintento manual desde Admin).
#
#         Transición: hasta el 2026-12-01 se envían TAMBIÉN los headers
#         legacy x-h4f-event-id / x-h4f-timestamp / x-h4f-signature con los
#         mismos valores que sus equivalentes x-alpadevs-*.
#       parameters:
#         - name: x-alpadevs-event-id
#           in: header
#           required: true
#           schema: { type: string, format: uuid }
#         - name: x-alpadevs-timestamp
#           in: header
#           required: true
#           description: Unix timestamp (segundos) usado en la firma
#           schema: { type: string }
#         - name: x-alpadevs-signature
#           in: header
#           required: true
#           description: v1=<hex hmac-sha256>
#           schema: { type: string, pattern: "^v1=[0-9a-f]{64}$" }
#         - name: x-h4f-event-id
#           in: header
#           required: false
#           deprecated: true
#           description: Legacy; mismo valor que x-alpadevs-event-id. Se retira el 2026-12-01.
#           schema: { type: string, format: uuid }
#         - name: x-h4f-timestamp
#           in: header
#           required: false
#           deprecated: true
#           description: Legacy; mismo valor que x-alpadevs-timestamp. Se retira el 2026-12-01.
#           schema: { type: string }
#         - name: x-h4f-signature
#           in: header
#           required: false
#           deprecated: true
#           description: Legacy; mismo valor que x-alpadevs-signature. Se retira el 2026-12-01.
#           schema: { type: string, pattern: "^v1=[0-9a-f]{64}$" }
#       requestBody:
#         required: true
#         content:
#           application/json:
#             schema:
#               $ref: "#/components/schemas/WebhookEvent"
#             example:
#               eventId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
#               type: ticket.redeemed
#               createdAt: "2026-08-29T10:00:01.000Z"
#               payload:
#                 ticket_id: "7b1c0f2e-5a6d-4c3b-9e8f-1a2b3c4d5e6f"
#                 external_ref: "TG-1001"
#                 qr_content: "ORD-77.TK-1001"
#                 experience_id: "22222222-2222-4222-8222-222222222222"
#                 redeemed_at: "2026-08-29T10:00:01.000Z"
#                 redeemed_by_email: "puerta1@organizador.com"
#                 uses: 1
#                 max_uses: 1
#                 holder_ref: "user-8841"
#       responses:
#         "2XX":
#           description: Recibido; el partner deduplica por eventId.
