openapi: 3.1.0
info:
  title: Praticable Ticket Verification Protocol
  version: "1.0"
  description: |
    Open protocol for verifying event tickets across independent issuer instances.
    Each customer deployment acts as an autonomous issuer; any scanner implementing
    this protocol can verify tickets from any issuer without prior bilateral integration.

    **Cryptography**: Ed25519 (RFC 8032) signatures, 64-byte, verified via Web Crypto API.

    **Authentication**: scanners enroll by scanning a setup QR, exchanging a single-use
    token for a long-lived Bearer API key scoped to one event (and optionally one gate).
  contact:
    name: Praticable Developer Support
    email: developers@praticable.eu
    url: https://praticable.eu/developers
  license:
    name: Proprietary
    url: https://praticable.eu/legal/terms

servers:
  - url: https://tickets.{customer}.praticable.eu
    description: Per-customer issuer instance
    variables:
      customer:
        default: example
        description: Customer subdomain

security:
  - BearerApiKey: []

paths:
  /.well-known/ticket-issuer.json:
    get:
      operationId: getIssuerDiscovery
      summary: Issuer discovery
      description: |
        Returns the issuer's discovery document, including public keys, supported
        protocol versions, and endpoint URLs. Scanners SHOULD cache this document
        with a TTL of at most 1 hour and re-fetch if verification fails with an
        unknown `kid`.
      tags: [Discovery]
      security: []
      responses:
        "200":
          description: Discovery document
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IssuerDiscovery"
              example:
                protocol_version: 1
                issuer: https://tickets.example.com
                name: Festival des Arts
                keys:
                  - kid: a1b2c3d4
                    alg: Ed25519
                    public_key: base64url-encoded-raw-32-byte-public-key
                    valid_from: "2026-01-01T00:00:00Z"
                    valid_until: null
                endpoints:
                  check_in: https://tickets.example.com/api/v1/tickets/check-in
                  status: https://tickets.example.com/api/v1/tickets/{tid}/status
                  scanner_enroll: https://tickets.example.com/api/v1/scanners/enroll
                supported_versions: [1]

  /api/v1/scanners/enroll:
    post:
      operationId: enrollScanner
      summary: Exchange enrollment token for API key
      description: |
        Exchanges a single-use enrollment token (from a setup QR) for a long-lived
        API key scoped to one event and optionally one gate. A given token is valid
        for exactly one successful exchange; further attempts fail with `INVALID_TOKEN`.
      tags: [Scanner]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrollRequest"
            example:
              token: st_01HXXX...opaque-enrollment-token
              scanner_id: 550e8400-e29b-41d4-a716-446655440000
              scanner_name: "iPhone 14 - Entree Nord"
              scanner_platform: ios-18.2
      responses:
        "200":
          description: Enrollment successful
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollResponse"
              example:
                api_key: sk_live_01HXXX...long-opaque-key
                api_key_id: sck_01HXXX...ULID
                scope:
                  eid: evt_01HXXX...ULID
                  event_name: Gala Concert
                  event_date: "2026-06-15T20:00:00Z"
                  gate_id: north
                  expires_at: "2026-06-16T02:00:00Z"
                issuer:
                  name: Festival des Arts
                  iss: https://tickets.example.com
        "400":
          description: Invalid or expired token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                invalid_token:
                  summary: Token malformed, expired, or already used
                  value:
                    error:
                      code: INVALID_TOKEN
                      message: Enrollment token has already been consumed.
                      details: {}
                unsupported_version:
                  summary: Protocol version not supported
                  value:
                    error:
                      code: PROTOCOL_VERSION_UNSUPPORTED
                      message: This issuer does not support protocol version 2.
                      details: {}
        "403":
          description: Token revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: TOKEN_REVOKED
                  message: This enrollment token was revoked by the organizer.
                  details: {}
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/tickets/check-in:
    post:
      operationId: checkInTicket
      summary: Check in a ticket
      description: |
        Marks a ticket as checked in. **Idempotent**: replaying the same request
        with an identical body and the same `idempotency_key` returns the same result.

        A second request with the same `tid` but a different `idempotency_key` is
        treated as a double-scan attempt and returns `already_used`.

        Issuers MUST accept `scanned_at` timestamps up to 48 hours in the past to
        support queued offline scans.
      tags: [Tickets]
      parameters:
        - name: Idempotency-Key
          in: header
          description: |
            UUID v4 per scan attempt. The server deduplicates retries sharing the
            same key. Also accepted in the request body as `idempotency_key`.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckInRequest"
            example:
              tid: 01HXXX...ULID
              eid: evt_01HXXX...ULID
              scanned_at: "2026-06-15T19:42:13Z"
              scanner_id: gate-north-01
              gate_id: north
              idempotency_key: 550e8400-e29b-41d4-a716-446655440000
      responses:
        "200":
          description: |
            Check-in result. The `status` field indicates whether the ticket was
            admitted or denied; both are returned as 200 with different status values.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckInResponse"
              examples:
                valid:
                  summary: First successful check-in
                  value:
                    status: valid
                    ticket:
                      tid: 01HXXX...ULID
                      eid: evt_01HXXX...ULID
                      holder_name: J. Smith
                      ticket_type: VIP
                      checked_in_at: "2026-06-15T19:42:13Z"
                already_used:
                  summary: Ticket already checked in
                  value:
                    status: already_used
                    ticket:
                      tid: 01HXXX...ULID
                      eid: evt_01HXXX...ULID
                      holder_name: J. Smith
                      ticket_type: VIP
                      checked_in_at: "2026-06-15T19:30:00Z"
                revoked:
                  summary: Ticket revoked (refund, fraud)
                  value:
                    status: revoked
                    ticket:
                      tid: 01HXXX...ULID
                      eid: evt_01HXXX...ULID
                      holder_name: J. Smith
                      ticket_type: VIP
                      checked_in_at: null
                expired:
                  summary: Ticket expiry passed
                  value:
                    status: expired
                    ticket:
                      tid: 01HXXX...ULID
                      eid: evt_01HXXX...ULID
                      holder_name: J. Smith
                      ticket_type: VIP
                      checked_in_at: null
                wrong_event:
                  summary: Valid ticket but for a different event
                  value:
                    status: wrong_event
                    ticket:
                      tid: 01HXXX...ULID
                      eid: evt_01HZZZ...ULID
                      holder_name: J. Smith
                      ticket_type: Standard
                      checked_in_at: null
        "400":
          description: Invalid request or signature
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                invalid_request:
                  summary: Malformed payload
                  value:
                    error:
                      code: INVALID_REQUEST
                      message: "Missing required field: tid"
                      details: { field: tid }
                invalid_signature:
                  summary: Scanner reports bad signature
                  value:
                    error:
                      code: INVALID_REQUEST
                      message: Scanner reported invalid_signature. Logged for audit.
                      details: {}
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Ticket not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: NOT_FOUND
                  message: Ticket 01HXXX does not exist at this issuer.
                  details: {}
        "409":
          description: Idempotency conflict
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: IDEMPOTENCY_CONFLICT
                  message: Same Idempotency-Key was used with a different payload.
                  details: {}
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/tickets/{tid}/status:
    get:
      operationId: getTicketStatus
      summary: Get ticket status
      description: |
        Returns the current ticket state without mutating it. Useful for scanner
        pre-checks, admin tools, or post-event reconciliation.
      tags: [Tickets]
      parameters:
        - name: tid
          in: path
          required: true
          description: Ticket ID (ULID)
          schema:
            type: string
          example: 01HXXX...ULID
      responses:
        "200":
          description: Current ticket state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketStatus"
              example:
                tid: 01HXXX...ULID
                eid: evt_01HXXX...ULID
                status: valid
                holder_name: J. Smith
                ticket_type: VIP
                issued_at: "2026-05-01T10:00:00Z"
                checked_in_at: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Ticket not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: NOT_FOUND
                  message: Ticket does not exist at this issuer.
                  details: {}
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    BearerApiKey:
      type: http
      scheme: bearer
      description: |
        API key obtained via the scanner enrollment exchange. Each key is scoped
        to exactly one event (and optionally one gate) and expires at the
        `expires_at` timestamp from the enrollment response. Store in platform
        secure storage (iOS Keychain, Android Keystore).

  schemas:
    IssuerDiscovery:
      type: object
      required: [protocol_version, issuer, name, keys, endpoints, supported_versions]
      properties:
        protocol_version:
          type: integer
          description: Current protocol major version
          example: 1
        issuer:
          type: string
          format: uri
          description: Issuer base URL. Canonical form, HTTPS, no trailing slash.
          example: https://tickets.example.com
        name:
          type: string
          description: Human-readable issuer/customer name
          example: Festival des Arts
        keys:
          type: array
          items:
            $ref: "#/components/schemas/PublicKey"
          description: Active public keys. May contain multiple entries during rotation.
        endpoints:
          $ref: "#/components/schemas/IssuerEndpoints"
        supported_versions:
          type: array
          items:
            type: integer
          description: Protocol versions this issuer supports
          example: [1]

    PublicKey:
      type: object
      required: [kid, alg, public_key, valid_from]
      properties:
        kid:
          type: string
          description: Key identifier (first 8 hex chars of SHA-256 of the raw public key)
          example: a1b2c3d4
        alg:
          type: string
          enum: [Ed25519]
          description: Signature algorithm
        public_key:
          type: string
          description: Base64url-encoded raw 32-byte Ed25519 public key
        valid_from:
          type: string
          format: date-time
          description: Start of validity window
        valid_until:
          type: string
          format: date-time
          nullable: true
          description: End of validity window. Null means currently active with no scheduled expiry.

    IssuerEndpoints:
      type: object
      required: [check_in, status, scanner_enroll]
      properties:
        check_in:
          type: string
          format: uri
          description: POST endpoint for ticket check-in
        status:
          type: string
          format: uri-template
          description: GET endpoint for ticket status (contains `{tid}` placeholder)
        scanner_enroll:
          type: string
          format: uri
          description: POST endpoint for scanner enrollment exchange

    EnrollRequest:
      type: object
      required: [token, scanner_id]
      properties:
        token:
          type: string
          description: |
            Opaque enrollment token from the setup QR. Single-use, minimum
            128 bits of entropy from a cryptographically secure RNG.
          example: st_01HXXX...opaque-enrollment-token
        scanner_id:
          type: string
          description: |
            Stable unique identifier generated and persisted by the scanner app
            (UUID recommended). Used to identify this device across re-enrollments.
          example: 550e8400-e29b-41d4-a716-446655440000
        scanner_name:
          type: string
          description: Human-readable device label shown in the admin UI
          example: "iPhone 14 - Entree Nord"
        scanner_platform:
          type: string
          description: Platform info for support and compatibility tracking
          example: ios-18.2

    EnrollResponse:
      type: object
      required: [api_key, api_key_id, scope, issuer]
      properties:
        api_key:
          type: string
          description: |
            Long-lived API key. MUST be stored in platform secure storage
            (iOS Keychain, Android Keystore). Never log, include in crash
            reports, or persist in plain text.
          example: sk_live_01HXXX...long-opaque-key
        api_key_id:
          type: string
          description: Key identifier safe to log, used for audit trails
          example: sck_01HXXX...ULID
        scope:
          $ref: "#/components/schemas/EnrollScope"
        issuer:
          type: object
          required: [name, iss]
          properties:
            name:
              type: string
              description: Commercial name of the issuer
            iss:
              type: string
              format: uri
              description: Issuer base URL

    EnrollScope:
      type: object
      required: [eid, event_name, event_date, expires_at]
      properties:
        eid:
          type: string
          description: Event ID this key is scoped to
        event_name:
          type: string
          description: Authoritative event name from the issuer
        event_date:
          type: string
          format: date-time
          description: Event date/time
        gate_id:
          type: string
          nullable: true
          description: Gate this key is scoped to, or null for all gates
        expires_at:
          type: string
          format: date-time
          description: API key expiry (typically event end + buffer)

    CheckInRequest:
      type: object
      required: [tid, eid, scanned_at, scanner_id, idempotency_key]
      properties:
        tid:
          type: string
          description: Ticket ID
          example: 01HXXX...ULID
        eid:
          type: string
          description: Event ID
          example: evt_01HXXX...ULID
        scanned_at:
          type: string
          format: date-time
          description: |
            Timestamp of the scan (ISO 8601 UTC). The issuer MUST accept
            timestamps up to 48 hours in the past for queued offline scans.
          example: "2026-06-15T19:42:13Z"
        scanner_id:
          type: string
          description: Stable unique device identifier
          example: gate-north-01
        gate_id:
          type: string
          description: Gate identifier for audit purposes
          example: north
        idempotency_key:
          type: string
          format: uuid
          description: |
            UUID v4 per scan attempt. Deduplicates retries from the same
            attempt. A second request with the same `tid` but a different
            key is treated as a double-scan and returns `already_used`.
          example: 550e8400-e29b-41d4-a716-446655440000

    CheckInResponse:
      type: object
      required: [status, ticket]
      properties:
        status:
          type: string
          enum: [valid, already_used, revoked, wrong_event, expired, invalid_signature]
          description: |
            - `valid`: ticket was valid and is now checked in (first successful scan)
            - `already_used`: ticket was already checked in; includes original `checked_in_at`
            - `revoked`: ticket has been revoked (refund, fraud, etc.)
            - `wrong_event`: valid ticket but for a different event
            - `expired`: ticket expiry has passed
            - `invalid_signature`: scanner reports the signature did not verify
        ticket:
          $ref: "#/components/schemas/CheckInTicket"

    CheckInTicket:
      type: object
      required: [tid, eid]
      properties:
        tid:
          type: string
          description: Ticket ID
        eid:
          type: string
          description: Event ID
        holder_name:
          type: string
          description: Display name of the ticket holder
        ticket_type:
          type: string
          description: Ticket type label (e.g. VIP, Standard)
        checked_in_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the (first) check-in, or null if not checked in

    TicketStatus:
      type: object
      required: [tid, eid, status, issued_at]
      properties:
        tid:
          type: string
          description: Ticket ID
        eid:
          type: string
          description: Event ID
        status:
          type: string
          enum: [valid, checked_in, revoked, expired]
          description: Current ticket state
        holder_name:
          type: string
          description: Display name of the ticket holder
        ticket_type:
          type: string
          description: Ticket type label
        issued_at:
          type: string
          format: date-time
          description: When the ticket was issued
        checked_in_at:
          type: string
          format: date-time
          nullable: true
          description: When the ticket was checked in, or null

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - FORBIDDEN
                - INVALID_REQUEST
                - INVALID_TOKEN
                - TOKEN_REVOKED
                - NOT_FOUND
                - RATE_LIMITED
                - IDEMPOTENCY_CONFLICT
                - INTERNAL
                - PROTOCOL_VERSION_UNSUPPORTED
              description: Machine-readable error code
            message:
              type: string
              description: Human-readable description
            details:
              type: object
              additionalProperties: true
              description: Optional structured details about the error

    TicketPayload:
      type: object
      description: |
        JSON structure encoded in the ticket QR code. The QR content format is
        `tkt1.<base64url(payload_json)>.<base64url(signature)>`. The signature
        is computed over the exact bytes of the base64url-encoded payload using
        the issuer's Ed25519 private key.
      required: [v, iss, kid, tid, eid, ev, iat, exp]
      properties:
        v:
          type: integer
          description: Protocol major version
          example: 1
        iss:
          type: string
          format: uri
          description: Issuer base URL (HTTPS, no trailing slash)
          example: https://tickets.example.com
        kid:
          type: string
          description: Key identifier of the signing key
          example: a1b2c3d4
        tid:
          type: string
          description: Ticket ID (ULID recommended, unique within issuer)
          example: 01HXXX...ULID
        eid:
          type: string
          description: Event ID (ULID recommended, unique within issuer)
          example: evt_01HXXX...ULID
        ev:
          type: object
          required: [name, date, venue]
          description: Event metadata for offline display (informational only)
          properties:
            name:
              type: string
              example: Gala Concert
            date:
              type: string
              format: date-time
              example: "2026-06-15T20:00:00Z"
            venue:
              type: string
              example: Royal Hall
        tt:
          type: string
          description: Ticket type label for display
          example: VIP
        hn:
          type: string
          description: Holder display name (keep short, no PII beyond display name)
          example: J. Smith
        iat:
          type: integer
          description: Issued-at Unix timestamp (seconds)
          example: 1745000000
        exp:
          type: integer
          description: Expiry Unix timestamp (seconds)
          example: 1765000000

    SetupQRPayload:
      type: object
      description: |
        JSON structure encoded in the scanner setup QR code. Format is
        `tsetup1.<base64url(setup_json)>`. The `tsetup1.` prefix distinguishes
        setup QRs from ticket QRs (`tkt1.`).
      required: [v, iss, enroll_url, token, eid, expires_at]
      properties:
        v:
          type: integer
          description: Protocol major version
          example: 1
        iss:
          type: string
          format: uri
          description: Issuer base URL
        enroll_url:
          type: string
          format: uri
          description: Exact URL for enrollment exchange; must match the discovery document
        token:
          type: string
          description: Opaque enrollment token (>= 128 bits entropy, single-use)
          example: st_01HXXX...opaque-enrollment-token
        eid:
          type: string
          description: Event ID this setup QR enrolls the scanner for
        event_name:
          type: string
          description: Event name shown to operator for confirmation
        gate_id:
          type: string
          description: Optional gate identifier; scopes the issued API key to this gate
        expires_at:
          type: string
          format: date-time
          description: Token expiry (typically event end + buffer)

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: UNAUTHORIZED
              message: API key is missing, expired, or revoked.
              details: {}

    Forbidden:
      description: API key valid but lacks permission
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: FORBIDDEN
              message: API key does not have permission for this resource.
              details: {}

    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: RATE_LIMITED
              message: Rate limit exceeded. See Retry-After header.
              details: {}

    InternalError:
      description: Server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: INTERNAL
              message: An internal error occurred. Please retry with exponential backoff.
              details: {}

tags:
  - name: Discovery
    description: Issuer discovery and public key distribution
  - name: Scanner
    description: Scanner enrollment and authentication
  - name: Tickets
    description: Ticket check-in and status
