openapi: 3.0.3
info:
  title: PassFast API
  description: |
    Apple Wallet and Google Wallet pass platform API. Create, manage, and distribute Apple Wallet and Google Wallet passes
    with template-based generation, push update support, and webhook integrations.

    ## Authentication

    Most endpoints authenticate via **API key** passed as a Bearer token:

    ```
    Authorization: Bearer sk_live_...
    ```

    - **Secret keys** (`sk_live_`) grant full access and should only be used server-side.
    - **Publishable keys** (`pk_live_`) grant limited scopes and are safe for client-side use.

    Member management endpoints use **Supabase JWT** authentication instead of API keys.

    ## Multi-App Support

    If your organization has multiple apps, include the `X-App-Id` header to target a specific app.
    For single-app orgs this header is optional.
  version: 1.0.0
  contact:
    name: PassFast Support
  license:
    name: Proprietary

servers:
  - url: https://api.passfa.st/functions/v1
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Passes
    description: Generate, list, download, update, and void wallet passes (Apple and Google)
  - name: Templates
    description: Create and manage pass templates
  - name: Images
    description: Upload and manage images used in pass templates
  - name: Certificates
    description: Manage Apple signing certificates and Google Wallet credentials
  - name: Organization
    description: Organization settings, apps, and webhook configuration
  - name: API Keys
    description: Create and manage API keys
  - name: Members
    description: Organization member and invitation management (JWT auth)
  - name: Webhook Events
    description: View webhook event delivery history
  - name: Pass Sharing
    description: Public pass distribution via share tokens

paths:
  # ---------------------------------------------------------------------------
  # Passes
  # ---------------------------------------------------------------------------
  /generate-pass:
    post:
      operationId: generatePass
      x-codeSamples:
        - lang: bash
          label: Both wallets (recommended)
          source: |
            curl -X POST https://api.passfa.st/functions/v1/generate-pass \
              -H "Authorization: Bearer sk_live_YOUR_KEY" \
              -H "X-App-Id: YOUR_APP_ID" \
              -H "Content-Type: application/json" \
              -d '{"template_id":"YOUR_TEMPLATE_ID","serial_number":"PASS-001","wallet_type":"both","data":{"memberName":"Jane"}}'
        - lang: bash
          label: Apple only (binary .pkpass)
          source: |
            curl -X POST https://api.passfa.st/functions/v1/generate-pass \
              -H "Authorization: Bearer sk_live_YOUR_KEY" \
              -H "X-App-Id: YOUR_APP_ID" \
              -H "Content-Type: application/json" \
              -d '{"template_id":"YOUR_TEMPLATE_ID","serial_number":"PASS-001","data":{"memberName":"Jane"}}' \
              --output pass.pkpass
        - lang: bash
          label: Google only (JSON with save_url)
          source: |
            curl -X POST https://api.passfa.st/functions/v1/generate-pass \
              -H "Authorization: Bearer sk_live_YOUR_KEY" \
              -H "X-App-Id: YOUR_APP_ID" \
              -H "Content-Type: application/json" \
              -d '{"template_id":"YOUR_TEMPLATE_ID","serial_number":"PASS-001","wallet_type":"google","data":{"memberName":"Jane"}}'
      summary: Generate a wallet pass
      description: |
        Generates a wallet pass from a published template. For Apple passes (default),
        returns a signed `.pkpass` binary file directly. For Google passes
        (`wallet_type: "google"`), returns a JSON object containing a `save_url`
        that the user can open to add the pass to Google Wallet.

        When `wallet_type: "both"`, generates both Apple and Google passes in a single
        call. The response is always JSON with `apple` and `google` keys. Partial success
        is allowed — if one wallet fails, the other is still returned with a warning.
        Returns 201 if at least one succeeds.

        The pass ID is returned in the `X-Pass-Id` response header (for single-wallet).

        If the app has a validation webhook configured, the webhook is called once
        before generation (not per wallet type). A webhook rejection returns 403;
        a webhook error returns 502 (fail-closed).

        **Scope:** `passes:create`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - template_id
                - serial_number
                - data
              properties:
                template_id:
                  type: string
                  format: uuid
                  description: ID of the published template to use.
                serial_number:
                  type: string
                  description: Unique serial number for this pass.
                  example: PASS-001
                data:
                  type: object
                  additionalProperties: true
                  description: Dynamic field values merged into the template structure.
                external_id:
                  type: string
                  description: Optional external identifier for cross-system lookups.
                expires_at:
                  type: string
                  format: date-time
                  description: Optional expiration timestamp for the pass.
                get_or_create:
                  type: boolean
                  default: false
                  description: |
                    When true, if a pass with the same serial_number already exists and is active,
                    return the existing .pkpass (200) instead of a 409 error. The response includes
                    an `X-Pass-Existed: true` header. If the existing pass is voided/expired,
                    returns 409. When false (default), duplicate serials always return 409.
                locations:
                  type: array
                  maxItems: 10
                  description: |
                    GPS locations where the pass is relevant (shown on lock screen).
                    Overrides template defaults if provided.
                  items:
                    type: object
                    required:
                      - latitude
                      - longitude
                    properties:
                      latitude:
                        type: number
                        minimum: -90
                        maximum: 90
                        description: Latitude in degrees.
                      longitude:
                        type: number
                        minimum: -180
                        maximum: 180
                        description: Longitude in degrees.
                      altitude:
                        type: number
                        description: Altitude in meters (optional).
                      relevantText:
                        type: string
                        description: Text displayed on lock screen when user is near this location.
                relevant_date:
                  type: string
                  format: date-time
                  description: ISO 8601 date when the pass is relevant (appears on lock screen).
                max_distance:
                  type: number
                  minimum: 0
                  description: Maximum distance in meters from a location for lock screen relevance.
                wallet_type:
                  type: string
                  enum:
                    - apple
                    - google
                    - both
                  default: apple
                  description: |
                    Target wallet platform. Defaults to `apple`. When set to `google`,
                    the response is a JSON object with a `save_url` instead of a binary .pkpass file.
                    When set to `both`, generates both Apple and Google passes in one call and
                    returns a JSON object with `apple`, `google`, and `warnings` keys.
                strip_image_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: |
                    Override the template's strip/hero image for this pass only. The referenced
                    image must belong to the same app and have purpose `strip` (or a `strip_*`
                    variant). Applied to Apple `strip.png` and Google `heroImage` atomically
                    (same value for both wallets in `wallet_type: "both"`). Omit or send `null`
                    to use the template's strip image.
      responses:
        "200":
          description: Existing pass returned (only when `get_or_create` is true).
          headers:
            X-Pass-Id:
              description: UUID of the existing pass record.
              schema:
                type: string
                format: uuid
            X-Pass-Existed:
              description: Always `"true"` when returning an existing pass.
              schema:
                type: string
                enum:
                  - "true"
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
        "201":
          description: |
            New pass generated successfully. Response format depends on `wallet_type`:
            - `apple` (default): returns binary `.pkpass` file
            - `google`: returns JSON with `save_url`
            - `both`: returns JSON with `apple`, `google`, and `warnings` keys
          headers:
            X-Pass-Id:
              description: UUID of the newly created pass record (not set for dual generation).
              schema:
                type: string
                format: uuid
            X-Pass-Warnings:
              description: |
                JSON-encoded array of non-fatal warning messages (Apple single-wallet only).
                Present when the generation produced a valid pass but with caveats — e.g.
                the template had no icon uploaded so a grey fallback was injected, or a
                field group exceeded Apple's per-style cap and was truncated. Consumers
                should parse with `JSON.parse(header)` and surface the messages to users.
              schema:
                type: string
                description: JSON array, e.g. `["No icon image uploaded; using 29×29 grey fallback."]`.
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/GoogleGenerateResponse"
                  - $ref: "#/components/schemas/DualGenerateResponse"
        "400":
          description: |
            Invalid request body. The `error.code` field disambiguates:
            - `validation_error` — `data` fails the template's `field_schema`
            - `invalid_datetime_format` — `relevant_date` or `expires_at` missing timezone
            - `invalid_barcode_format` — `structure.barcode.format` not in the allowed enum (template save only)
            - `invalid_transit_type` — `boardingPass` template missing/invalid `structure.transitType` (publish only)
            - `missing_apple_certificate` — app lacks Apple signer cert (publish only)
            - `missing_google_credentials` — app lacks Google service account (publish only)
            - `cert_expired` — the signer cert's `notAfter` has passed
            - `cert_not_yet_valid` — the signer cert's `notBefore` is in the future
            - `duplicate_field_key` — two fields share the same `key` across groups (template save only)
            - `webhook_error` / `webhook_misconfigured` — validation webhook unreachable or missing secret
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          description: |
            Billing limit reached or payment required. `error.code` disambiguates:
            - `free_limit_reached` — account has used all 100 lifetime free passes (pooled across every org the billing owner owns). Add a payment method.
            - `subscription_canceled` — subscription was canceled; add payment method to resume.
            - `payment_past_due` — payment failed; update payment method to resume.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Validation webhook rejected the pass generation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: webhook_rejected
                message: Validation webhook rejected the request.
        "409":
          description: Duplicate serial number.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: |
            Internal error. `error.code` may be:
            - `credential_decrypt_failed` — platform or org credential ciphertext could not be decrypted (key mismatch or corruption)
            - `internal_error` — unexpected failure; check logs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: Validation webhook unreachable or returned an error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: webhook_error
                message: Validation webhook failed.

  /manage-passes:
    get:
      operationId: listPasses
      summary: List passes
      description: |
        Returns a paginated list of passes for the current app.

        **Scope:** `passes:read`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: template_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by template ID.
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - invalidated
              - expired
          description: Filter by pass status.
        - name: external_id
          in: query
          schema:
            type: string
          description: Filter by external ID.
        - name: serial_number
          in: query
          schema:
            type: string
          description: Filter by serial number.
        - name: created_after
          in: query
          schema:
            type: string
            format: date-time
          description: Only return passes created after this timestamp.
        - name: created_before
          in: query
          schema:
            type: string
            format: date-time
          description: Only return passes created before this timestamp.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Maximum number of results to return.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of results to skip.
        - name: wallet_type
          in: query
          schema:
            type: string
            enum:
              - apple
              - google
          description: Filter by wallet platform type.
      responses:
        "200":
          description: List of passes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pass"

  /manage-passes/{id}:
    get:
      operationId: getPass
      summary: Get a pass
      description: |
        Returns the full details of a single pass.

        **Scope:** `passes:read`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/PassId"
      responses:
        "200":
          description: Pass details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pass"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      operationId: updatePass
      summary: Update a pass
      description: |
        Updates the dynamic data of an active pass. Optionally sends a push
        notification to registered devices so they fetch the updated pass.
        At least one of `data`, `expires_at`, `locations`, `relevant_date`,
        or `max_distance` is required.

        **Scope:** `passes:manage`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/PassId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePassRequest"
      responses:
        "200":
          description: Pass updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdatePassResponse"
        "400":
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-passes/serial/{serial_number}:
    get:
      operationId: getPassBySerial
      summary: Get a pass by serial number
      description: |
        Returns the full details of a single pass looked up by serial number.
        When a serial has both Apple and Google passes, use `?wallet_type=` to select which one (defaults to `apple`).

        **Scope:** `passes:read`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/SerialNumber"
        - $ref: "#/components/parameters/WalletTypeQuery"
      responses:
        "200":
          description: Pass details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pass"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      operationId: updatePassBySerial
      summary: Update a pass by serial number
      description: |
        Updates the dynamic data of an active pass looked up by serial number.
        Optionally sends a push notification to registered devices.
        At least one of `data`, `expires_at`, `locations`, `relevant_date`,
        or `max_distance` is required.
        When a serial has both Apple and Google passes, use `?wallet_type=` to select which one (defaults to `apple`).

        **Scope:** `passes:manage`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/SerialNumber"
        - $ref: "#/components/parameters/WalletTypeQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePassRequest"
      responses:
        "200":
          description: Pass updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdatePassResponse"
        "400":
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-passes/serial/{serial_number}/download:
    get:
      operationId: downloadPassBySerial
      summary: Download a .pkpass file by serial number
      description: |
        Downloads the `.pkpass` binary for an active pass looked up by serial number.
        When a serial has both Apple and Google passes, use `?wallet_type=` to select which one (defaults to `apple`).

        **Scope:** `passes:download`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/SerialNumber"
        - $ref: "#/components/parameters/WalletTypeQuery"
      responses:
        "200":
          description: The .pkpass binary file.
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
        "400":
          description: Pass is not in active status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-passes/serial/{serial_number}/void:
    post:
      operationId: voidPassBySerial
      summary: Void a pass by serial number
      description: |
        Marks a pass (looked up by serial number) as invalidated and rebuilds the `.pkpass`
        file with Apple's `voided: true` flag. Registered devices are sent push notifications
        so the pass appears voided immediately in Apple Wallet.
        When a serial has both Apple and Google passes, use `?wallet_type=` to select which one (defaults to `apple`).

        **Scope:** `passes:manage`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/SerialNumber"
        - $ref: "#/components/parameters/WalletTypeQuery"
      responses:
        "200":
          description: Pass voided successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  serial_number:
                    type: string
                  status:
                    type: string
                    example: invalidated
                  voided_at:
                    type: string
                    format: date-time
                  pkpass_rebuilt:
                    type: boolean
                    description: Whether the .pkpass was rebuilt with voided flag
                  devices_notified:
                    type: integer
                    description: Number of devices sent push notifications
                  warning:
                    type: string
                    description: Present when pkpass_rebuilt is false — explains why
                  updated_at:
                    type: string
                    format: date-time
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Pass is already voided or expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-passes/{id}/download:
    get:
      operationId: downloadPass
      summary: Download a .pkpass file
      description: |
        Downloads the `.pkpass` binary for an active pass.

        **Scope:** `passes:download`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/PassId"
      responses:
        "200":
          description: The .pkpass binary file.
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
        "400":
          description: Pass is not in active status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-passes/{id}/void:
    post:
      operationId: voidPass
      summary: Void a pass
      description: |
        Marks a pass as invalidated and rebuilds the `.pkpass` file with Apple's `voided: true` flag.
        Registered devices are sent push notifications so the pass appears voided immediately in Apple Wallet.
        Rebuild failure is non-fatal — the pass is still invalidated at the DB level.

        **Scope:** `passes:manage`
      tags:
        - Passes
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/PassId"
      responses:
        "200":
          description: Pass voided successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  serial_number:
                    type: string
                  status:
                    type: string
                    example: invalidated
                  voided_at:
                    type: string
                    format: date-time
                  pkpass_rebuilt:
                    type: boolean
                    description: Whether the .pkpass was rebuilt with voided flag
                  devices_notified:
                    type: integer
                    description: Number of devices sent push notifications
                  warning:
                    type: string
                    description: Present when pkpass_rebuilt is false — explains why
                  updated_at:
                    type: string
                    format: date-time
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Pass is already voided or expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Templates
  # ---------------------------------------------------------------------------
  /manage-templates:
    post:
      operationId: createTemplate
      summary: Create a template
      description: |
        Creates a new pass template in draft status.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - pass_style
                - structure
              properties:
                name:
                  type: string
                  description: Human-readable template name.
                description:
                  type: string
                  description: Optional description of the template.
                pass_style:
                  $ref: "#/components/schemas/PassStyle"
                google_pass_type:
                  $ref: "#/components/schemas/GooglePassType"
                structure:
                  $ref: "#/components/schemas/TemplateStructure"
                field_schema:
                  type: object
                  additionalProperties: true
                  description: Optional JSON schema for validating dynamic data.
                wallet_types:
                  type: array
                  items: { type: string, enum: [apple, google] }
                  default: [apple]
                icon_image_id: { type: string, format: uuid }
                logo_image_id: { type: string, format: uuid }
                google_logo_image_id: { type: string, format: uuid }
                google_wide_logo_image_id: { type: string, format: uuid }
                strip_image_id: { type: string, format: uuid }
                thumbnail_image_id: { type: string, format: uuid }
                background_image_id: { type: string, format: uuid }
      responses:
        "201":
          description: Template created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"

    get:
      operationId: listTemplates
      summary: List templates
      description: |
        Returns all templates for the current app. By default returns non-archived
        templates. Set `archived=true` to return only archived templates.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: archived
          in: query
          schema:
            type: boolean
            default: false
          description: When true, return only archived templates instead of active ones.
      responses:
        "200":
          description: List of templates.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Template"

  /manage-templates/{id}:
    get:
      operationId: getTemplate
      summary: Get a template
      description: |
        Returns the full details of a single template.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/TemplateId"
      responses:
        "200":
          description: Template details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "404":
          description: Template not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    patch:
      operationId: updateTemplate
      summary: Update a template
      description: |
        Updates a draft template. Published templates cannot be modified.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/TemplateId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                description:
                  type: string
                pass_style:
                  $ref: "#/components/schemas/PassStyle"
                google_pass_type:
                  allOf: [{ $ref: "#/components/schemas/GooglePassType" }]
                  nullable: true
                structure:
                  $ref: "#/components/schemas/TemplateStructure"
                field_schema:
                  type: object
                  additionalProperties: true
                icon_image_id: { type: string, format: uuid, nullable: true }
                logo_image_id: { type: string, format: uuid, nullable: true }
                google_logo_image_id: { type: string, format: uuid, nullable: true }
                google_wide_logo_image_id: { type: string, format: uuid, nullable: true }
                strip_image_id: { type: string, format: uuid, nullable: true }
                thumbnail_image_id: { type: string, format: uuid, nullable: true }
                background_image_id: { type: string, format: uuid, nullable: true }
                wallet_types:
                  type: array
                  items:
                    type: string
                    enum:
                      - apple
                      - google
                  description: Wallet platforms this template supports.
      responses:
        "200":
          description: Template updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "404":
          description: Template not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Template is published and cannot be modified.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    delete:
      operationId: deleteTemplate
      summary: Delete a template
      description: |
        Soft-deletes a template by marking it as archived. Use `permanent=true`
        to permanently delete the template and its associated data.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/TemplateId"
        - name: permanent
          in: query
          schema:
            type: boolean
            default: false
          description: When true, permanently deletes the template instead of archiving it.
      responses:
        "200":
          description: Template deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Template not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-templates/{id}/publish:
    post:
      operationId: publishTemplate
      summary: Publish a template
      description: |
        Publishes a draft template, making it available for pass generation.
        Published templates cannot be modified.

        **Scope:** `templates:manage`
      tags:
        - Templates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - $ref: "#/components/parameters/TemplateId"
      responses:
        "200":
          description: Template published.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "404":
          description: Template not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Template is already published or in an invalid state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Images
  # ---------------------------------------------------------------------------
  /manage-images:
    post:
      operationId: uploadImage
      summary: Upload an image
      description: |
        Uploads an image for use in pass templates or per-pass strip overrides.
        Send as multipart form data with a `purpose` field and a `file` field
        containing the PNG image.

        **Upload behaviour by purpose:**
        - `strip` — **accumulates**. Each upload creates a new image; the returned
          `id` can be passed as `strip_image_id` on `POST /v1/passes` or
          `PATCH /v1/passes/{id}` to give individual passes their own banner.
          Previous `strip` images are NOT deleted — manage them via
          `DELETE /v1/images/{id}` when no longer referenced.
        - All other purposes (`icon`, `logo`, `thumbnail`, `background`, `footer`,
          and all `_2x`/`_3x` variants) — **replace-on-upload**. Uploading a new
          image of the same purpose deletes the previous one from storage and DB.
          These are app-wide template assets, not per-pass.

        **Scope:** `images:manage`
      tags:
        - Images
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - purpose
                - file
              properties:
                purpose:
                  type: string
                  description: |
                    The intended use of the image. `strip` uploads accumulate
                    (for per-pass `strip_image_id` overrides); all other purposes
                    replace existing uploads of the same purpose for the app.
                  enum:
                    - icon
                    - icon_2x
                    - icon_3x
                    - logo
                    - logo_2x
                    - logo_3x
                    - thumbnail
                    - thumbnail_2x
                    - strip
                    - strip_2x
                    - background
                    - background_2x
                    - footer
                    - footer_2x
                  example: icon
                file:
                  type: string
                  format: binary
                  description: PNG image file.
      responses:
        "201":
          description: Image uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  purpose:
                    type: string
                  storage_path:
                    type: string

    get:
      operationId: listImages
      summary: List images
      description: |
        Returns all images for the current app, including signed preview URLs.

        **Scope:** `images:manage`
      tags:
        - Images
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: List of images.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Image"

  /manage-images/{id}:
    delete:
      operationId: deleteImage
      summary: Delete an image
      description: |
        Deletes an image (storage file + DB row). Always succeeds when the image exists.

        Any template column pointing at this image is silently cleared (the template
        falls back to no image in that slot — re-attach a new one if needed).
        Any pass with `strip_image_id` pointing at this image has its override nullified
        and reverts to the template's strip on next render.

        Use `GET /manage-images/{id}/usage` first if you need to know the blast radius
        before deleting.

        **Scope:** `images:manage`
      tags:
        - Images
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Image ID.
      responses:
        "200":
          description: Image deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Image not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-images/{id}/usage:
    get:
      operationId: getImageUsage
      summary: Get image usage / reference counts
      description: |
        Report how an image is referenced across templates and passes. Useful before
        DELETE to confirm an image is unused, or to find which templates a shared
        asset is attached to.

        - `template_refs` lists every template column (e.g. `strip_image_id`,
          `icon_image_id`, `google_logo_image_id`) that references this image.
        - `pass_refs_count` is the number of passes whose per-pass `strip_image_id`
          points at this image.
        - `safe_to_delete` is `true` when `total_refs` is 0.

        **Scope:** `images:manage`
      tags:
        - Images
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Image ID.
      responses:
        "200":
          description: Usage report.
          content:
            application/json:
              schema:
                type: object
                required:
                  - image_id
                  - purpose
                  - template_refs
                  - pass_refs_count
                  - total_refs
                  - safe_to_delete
                properties:
                  image_id:
                    type: string
                    format: uuid
                  purpose:
                    type: string
                    example: strip
                  template_refs:
                    type: array
                    items:
                      type: object
                      required:
                        - template_id
                        - template_name
                        - column
                      properties:
                        template_id:
                          type: string
                          format: uuid
                        template_name:
                          type: string
                        column:
                          type: string
                          example: strip_image_id
                  pass_refs_count:
                    type: integer
                    minimum: 0
                  total_refs:
                    type: integer
                    minimum: 0
                  safe_to_delete:
                    type: boolean
        "404":
          description: Image not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Certificates
  # ---------------------------------------------------------------------------
  /manage-certs/p12:
    post:
      operationId: uploadP12Certificate
      summary: Upload a .p12 certificate bundle
      description: |
        Uploads a PKCS#12 (.p12) bundle containing signer certificate and private key.
        The bundle is decrypted with the provided password, and individual certificates
        are extracted and stored with AES-256-GCM encryption.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - p12_data
              properties:
                p12_data:
                  type: string
                  format: byte
                  description: Base64-encoded .p12 file data.
                password:
                  type: string
                  description: Password to decrypt the .p12 bundle (if password-protected).
      responses:
        "201":
          description: Certificates extracted and stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: P12 bundle processed successfully.
                  certificates:
                    type: array
                    items:
                      $ref: "#/components/schemas/Certificate"

  /manage-certs:
    post:
      operationId: uploadCertificate
      summary: Upload a single certificate
      description: |
        Uploads a single PEM-encoded certificate or key.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - cert_type
                - cert_data
              properties:
                cert_type:
                  type: string
                  enum:
                    - signer_cert
                    - signer_key
                    - wwdr
                  description: Type of certificate being uploaded.
                cert_data:
                  type: string
                  format: byte
                  description: Base64-encoded certificate or key data.
      responses:
        "201":
          description: Certificate stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Certificate"

    get:
      operationId: listCertificates
      summary: List certificates
      description: |
        Returns all certificates for the current app.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: List of certificates.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Certificate"

  /manage-certs/{id}:
    delete:
      operationId: deleteCertificate
      summary: Delete a certificate
      description: |
        Deletes a certificate.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Certificate ID.
      responses:
        "200":
          description: Certificate deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Certificate not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-certs/google:
    post:
      operationId: uploadGoogleCredentials
      summary: Upload Google credentials
      description: |
        Uploads Google service account credentials for Google Wallet pass signing.
        The service account JSON and issuer ID are stored securely.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - service_account_json
                - issuer_id
              properties:
                service_account_json:
                  type: object
                  additionalProperties: true
                  description: Google service account JSON key file contents.
                issuer_id:
                  type: string
                  description: Google Wallet issuer ID.
      responses:
        "201":
          description: Google credentials stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  service_account_email:
                    type: string
                    description: Email address from the service account.
                  issuer_id:
                    type: string
                    description: Google Wallet issuer ID.
                  created_at:
                    type: string
                    format: date-time
                  is_active:
                    type: boolean

    get:
      operationId: listGoogleCredentials
      summary: List Google credentials
      description: |
        Returns all active Google Wallet credentials for the current app.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: List of Google credentials.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    service_account_email:
                      type: string
                      description: Email address from the service account.
                    issuer_id:
                      type: string
                      description: Google Wallet issuer ID.
                    created_at:
                      type: string
                      format: date-time
                    is_active:
                      type: boolean

  /manage-certs/google/{id}:
    delete:
      operationId: deactivateGoogleCredential
      summary: Deactivate Google credential
      description: |
        Deactivates a Google Wallet credential.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Google credential ID.
      responses:
        "200":
          description: Google credential deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Google credential not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-certs/google/test:
    post:
      operationId: testGoogleConnection
      summary: Test Google connection
      description: |
        Tests the configured Google Wallet credentials by attempting to
        authenticate with the Google Wallet API.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: Google connection test result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    description: Whether the connection test succeeded.
                  error:
                    type: string
                    nullable: true
                    description: Error message if the test failed.

  /manage-certs/test:
    post:
      operationId: testAppleCertificates
      summary: Test Apple certificates
      description: |
        Generates an ephemeral test `.pkpass` file to verify that the uploaded
        Apple signing certificates are valid and complete. The test pass is
        not stored — it is returned directly as a binary download.

        **Scope:** `certs:manage`
      tags:
        - Certificates
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: Ephemeral test `.pkpass` binary.
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
        "400":
          description: Missing or incomplete certificates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Organization
  # ---------------------------------------------------------------------------
  /manage-org:
    get:
      operationId: getOrganization
      summary: Get organization details
      description: |
        Returns the current organization's settings.

        **Scope:** `org:read`
      tags:
        - Organization
      responses:
        "200":
          description: Organization details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"

    patch:
      operationId: updateOrganization
      summary: Update organization settings
      description: |
        Updates the current organization's settings, including APNs credentials.

        **Scope:** `org:manage`
      tags:
        - Organization
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Organization display name.
                slug:
                  type: string
                  description: URL-friendly slug.
                apns_key_id:
                  type: string
                  description: Apple Push Notification service Key ID.
                apns_key_p8:
                  type: string
                  description: APNs .p8 private key contents.
      responses:
        "200":
          description: Organization updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "400":
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-org/app:
    get:
      operationId: getApp
      summary: Get current app details
      description: |
        Returns the current app's settings.

        **Scope:** `org:read`
      tags:
        - Organization
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: App details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/App"

    post:
      operationId: createApp
      summary: Create a new app
      description: |
        Creates a new app within the organization.

        **Scope:** `org:manage`
      tags:
        - Organization
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  default: New App
                  description: App display name.
      responses:
        "201":
          description: App created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/App"

    patch:
      operationId: updateApp
      summary: Update app settings
      description: |
        Updates the current app's settings, including webhook configuration.
        Set `regenerate_webhook_secret` to `true` to generate a new webhook
        signing secret; the new secret is returned in `webhook_secret_raw`
        (shown only once).

        **Scope:** `org:manage`
      tags:
        - Organization
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: App display name.
                apple_team_id:
                  type: string
                  description: Apple Developer Team ID.
                pass_type_identifier:
                  type: string
                  description: Apple pass type identifier (e.g., pass.com.example.myapp).
                webhook_url:
                  type: string
                  format: uri
                  description: URL for async event webhook delivery.
                validation_webhook_url:
                  type: string
                  format: uri
                  description: URL for pre-generation validation webhooks.
                signing_mode:
                  type: string
                  enum:
                    - managed
                    - custom
                  description: Apple signing mode. `managed` uses platform credentials; `custom` uses your own.
                google_signing_mode:
                  type: string
                  enum:
                    - managed
                    - custom
                  description: Google signing mode. `managed` uses platform credentials; `custom` uses your own.
                onboarding_completed:
                  type: boolean
                  description: Whether onboarding has been completed for this app.
                regenerate_webhook_secret:
                  type: boolean
                  description: Set to true to regenerate the webhook signing secret.
      responses:
        "200":
          description: App updated. Includes `webhook_secret_raw` if secret was regenerated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/App"
                  - type: object
                    properties:
                      webhook_secret_raw:
                        type: string
                        description: Raw webhook secret (only returned when regenerated, shown once).

    delete:
      operationId: deleteApp
      summary: Deactivate an app
      description: |
        Deactivates the current app. This does not permanently delete data.

        **Scope:** `org:manage`
      tags:
        - Organization
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: App deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  is_active:
                    type: boolean
                    example: false
                  message:
                    type: string
        "400":
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-org/app/test-webhook:
    post:
      operationId: testWebhook
      summary: Test the validation webhook
      description: |
        Sends a sample validation webhook payload to the configured URL
        and returns the result.

        **Scope:** `org:manage`
      tags:
        - Organization
      parameters:
        - $ref: "#/components/parameters/XAppId"
      responses:
        "200":
          description: Webhook test result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_url:
                    type: string
                    format: uri
                  success:
                    type: boolean
                  approved:
                    type: boolean
                  reason:
                    type: string
                    nullable: true
                  status_code:
                    type: integer
                  duration_ms:
                    type: number
        "400":
          description: No validation webhook URL configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-org/managed-signing-status:
    get:
      operationId: getManagedSigningStatus
      summary: Check managed signing availability
      description: |
        Returns whether managed (platform) signing credentials are available
        for Apple and Google wallets. When `apple_ready` or `google_ready` is
        `true`, apps can use `signing_mode: "managed"` or
        `google_signing_mode: "managed"` without uploading their own credentials.

        **Scope:** `org:read`
      tags:
        - Organization
      responses:
        "200":
          description: Managed signing availability status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  apple_ready:
                    type: boolean
                    description: Whether platform Apple signing credentials are available.
                  google_ready:
                    type: boolean
                    description: Whether platform Google signing credentials are available.

  /manage-org/webhook-events:
    get:
      operationId: listWebhookEvents
      summary: List webhook events
      description: |
        Returns a paginated list of webhook event delivery records for the current app.

        **Scope:** `org:read`
      tags:
        - Webhook Events
      parameters:
        - $ref: "#/components/parameters/XAppId"
        - name: event_type
          in: query
          schema:
            type: string
            enum:
              - pass.created
              - pass.updated
              - pass.voided
              - pass.expired
              - device.registered
              - device.unregistered
          description: Filter by event type.
        - name: delivery_status
          in: query
          schema:
            type: string
            enum:
              - pending
              - delivered
              - failed
          description: Filter by delivery status.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Maximum number of results to return.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of results to skip.
      responses:
        "200":
          description: List of webhook events.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WebhookEvent"

  # ---------------------------------------------------------------------------
  # API Keys
  # ---------------------------------------------------------------------------
  /manage-keys:
    get:
      operationId: listApiKeys
      summary: List API keys
      description: |
        Returns all API keys for the current organization.

        **Scope:** `org:manage`
      tags:
        - API Keys
      responses:
        "200":
          description: List of API keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKey"

    post:
      operationId: createApiKey
      summary: Create an API key
      description: |
        Creates a new API key. The response contains `id`, `name`, `key_type`,
        `key_prefix`, `raw_key`, and `message`. The full raw key is shown only
        once and cannot be retrieved again.

        **Scope:** `org:manage`
      tags:
        - API Keys
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - key_type
              properties:
                name:
                  type: string
                  description: Human-readable label for the key.
                  example: Production Backend
                key_type:
                  type: string
                  enum:
                    - secret
                    - publishable
                  description: Key type. Secret keys have full access; publishable keys have limited scopes.
      responses:
        "201":
          description: API key created. The `raw_key` is shown only once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  key_type:
                    type: string
                    enum:
                      - secret
                      - publishable
                  key_prefix:
                    type: string
                    description: First characters of the key for identification.
                    example: sk_live_abc1...
                  raw_key:
                    type: string
                    description: The full API key value (shown only once).
                    example: sk_live_abc123...
                  message:
                    type: string
                    description: Reminder to store the key securely.

  /manage-keys/{id}:
    patch:
      operationId: revokeApiKey
      summary: Revoke an API key
      description: |
        Revokes an API key, making it inactive. Revoked keys cannot authenticate.

        **Scope:** `org:manage`
      tags:
        - API Keys
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: API key ID.
      responses:
        "200":
          description: API key revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  is_active:
                    type: boolean
                    example: false
                  message:
                    type: string

    delete:
      operationId: deleteApiKey
      summary: Delete an API key
      description: |
        Permanently deletes an API key. The key **must be revoked first**
        (via PATCH) before it can be deleted. Attempting to delete an active
        key returns 400.

        **Scope:** `org:manage`
      tags:
        - API Keys
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: API key ID.
      responses:
        "200":
          description: API key deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  message:
                    type: string
        "400":
          description: Cannot delete an active API key — revoke it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Members (JWT auth)
  # ---------------------------------------------------------------------------
  /manage-members:
    get:
      operationId: listMembers
      summary: List organization members
      description: |
        Returns all members of the current organization.

        **Auth:** Supabase JWT (any role)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
      responses:
        "200":
          description: List of members.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Member"

  /manage-members/invitations:
    get:
      operationId: listInvitations
      summary: List pending invitations
      description: |
        Returns all pending invitations for the current organization.
        Only invitations with `pending` status are returned.

        **Auth:** Supabase JWT (admin or higher)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
      responses:
        "200":
          description: List of pending invitations.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invitation"

  /manage-members/invite:
    post:
      operationId: inviteMember
      summary: Invite a member
      description: |
        Sends an email invitation to join the organization with the specified role.
        The invitation expires after 7 days.

        **Auth:** Supabase JWT (admin or higher)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
                - role
              properties:
                email:
                  type: string
                  format: email
                  description: Email address of the person to invite.
                role:
                  type: string
                  enum:
                    - admin
                    - editor
                    - viewer
                  description: Role to assign to the new member.
      responses:
        "201":
          description: Invitation created and email sent.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Invitation"
                  - type: object
                    properties:
                      accept_url:
                        type: string
                        format: uri
                        description: URL the invitee can use to accept the invitation.
                      email_sent:
                        type: boolean
                        description: Whether the invitation email was sent successfully.

  /manage-members/accept:
    post:
      operationId: acceptInvitation
      summary: Accept an invitation
      description: |
        Accepts a pending invitation using its token. The authenticated user
        is added to the organization with the invited role.

        **Auth:** Supabase JWT
      tags:
        - Members
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
              properties:
                token:
                  type: string
                  format: uuid
                  description: Invitation acceptance token.
      responses:
        "200":
          description: Invitation accepted; user added to organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  organization_id:
                    type: string
                    format: uuid
                  user_id:
                    type: string
                    format: uuid
                  role:
                    type: string

  /manage-members/{id}:
    patch:
      operationId: updateMemberRole
      summary: Update a member's role
      description: |
        Changes the role of an existing organization member.

        **Auth:** Supabase JWT (admin or higher)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Member record ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - role
              properties:
                role:
                  type: string
                  enum:
                    - admin
                    - editor
                    - viewer
                  description: New role for the member.
      responses:
        "200":
          description: Member role updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  role:
                    type: string
        "400":
          description: Invalid role or cannot change own role.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Member not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    delete:
      operationId: removeMember
      summary: Remove a member
      description: |
        Removes a member from the organization.

        **Auth:** Supabase JWT (admin or higher)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Member record ID.
      responses:
        "200":
          description: Member removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  removed:
                    type: boolean
                    example: true
        "400":
          description: Cannot remove the owner or yourself.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Member not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /manage-members/invitations/{id}:
    delete:
      operationId: revokeInvitation
      summary: Revoke an invitation
      description: |
        Revokes a pending invitation so it can no longer be accepted.

        **Auth:** Supabase JWT (admin or higher)
      tags:
        - Members
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/XOrgId"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Invitation ID.
      responses:
        "200":
          description: Invitation revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  status:
                    type: string
                    example: revoked
        "400":
          description: Invitation is not in a revocable state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Invitation not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ---------------------------------------------------------------------------
  # Pass Sharing
  # ---------------------------------------------------------------------------
  /share-pass/create:
    post:
      operationId: createShareToken
      summary: Create a share token
      description: |
        Creates a public share token for a pass, enabling distribution via URL,
        QR code, or messaging. The share URL points to a public page where
        recipients can add the pass to Apple or Google Wallet without logging in.

        Idempotent — if the pass already has a share token, the existing token
        is returned (200) instead of creating a new one (201).

        For dual-wallet passes (same serial number with both Apple and Google),
        the share token is automatically applied to all sibling passes.

        **Scope:** `passes:manage`
      tags:
        - Pass Sharing
      parameters:
        - $ref: "#/components/parameters/XAppId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - pass_id
              properties:
                pass_id:
                  type: string
                  format: uuid
                  description: ID of the pass to share.
      responses:
        "201":
          description: Share token created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShareToken"
        "200":
          description: Existing share token returned (pass already shared).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShareToken"
        "400":
          description: Pass is not active or missing pass_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Pass not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /share-pass/{token}:
    get:
      operationId: getSharePassMetadata
      summary: Get shared pass metadata
      description: |
        Returns public metadata for a shared pass. No authentication required.
        Used by the public share page to display wallet buttons and pass info.
      tags:
        - Pass Sharing
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
          description: Share token (32 hex characters).
      responses:
        "200":
          description: Pass metadata for the share page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  serial_number:
                    type: string
                  status:
                    type: string
                    enum:
                      - active
                      - invalidated
                      - expired
                  has_apple:
                    type: boolean
                    description: Whether an Apple Wallet pass exists.
                  has_google:
                    type: boolean
                    description: Whether a Google Wallet pass exists.
                  google_save_url:
                    type: string
                    nullable: true
                    description: Google Wallet save URL (if Google pass exists).
                  template_name:
                    type: string
                  pass_style:
                    type: string
                  app_name:
                    type: string
                  org_name:
                    type: string
        "404":
          description: Share token not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /share-pass/{token}/download:
    get:
      operationId: downloadSharedPass
      summary: Download shared Apple .pkpass
      description: |
        Downloads the Apple `.pkpass` file for a shared pass. No authentication
        required. Only works for active Apple passes.
      tags:
        - Pass Sharing
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
          description: Share token (32 hex characters).
      responses:
        "200":
          description: Apple .pkpass binary file.
          content:
            application/vnd.apple.pkpass:
              schema:
                type: string
                format: binary
        "400":
          description: Pass is not active.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Share token not found or no Apple pass available.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

# =============================================================================
# Components
# =============================================================================
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key (`sk_live_*` or `pk_live_*`) or Supabase JWT token.

  parameters:
    XAppId:
      name: X-App-Id
      in: header
      required: false
      schema:
        type: string
        format: uuid
      description: Target app ID. Optional for single-app organizations; required for multi-app.

    XOrgId:
      name: X-Org-Id
      in: header
      required: true
      schema:
        type: string
        format: uuid
      description: Target organization ID (required for JWT-authenticated endpoints).

    PassId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Pass ID.

    SerialNumber:
      name: serial_number
      in: path
      required: true
      schema:
        type: string
      description: Pass serial number (unique within app + wallet type).

    WalletTypeQuery:
      name: wallet_type
      in: query
      required: false
      schema:
        type: string
        enum:
          - apple
          - google
        default: apple
      description: |
        Wallet type to look up when a serial number has both Apple and Google passes.
        Defaults to `apple` for backward compatibility.

    TemplateId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Template ID.

  schemas:
    # -------------------------------------------------------------------------
    # Enums
    # -------------------------------------------------------------------------
    PassStyle:
      type: string
      enum:
        - boardingPass
        - coupon
        - eventTicket
        - generic
        - storeCard
      description: Apple Wallet pass style.

    GooglePassType:
      type: string
      enum:
        - generic
        - loyalty
        - eventTicket
        - offer
        - flight
        - transit
        - giftCard
      description: |
        Google Wallet class type override. If unset, PassFast auto-maps from
        the Apple `pass_style` (+ `structure.transitType` for boardingPass):
        - `generic` → generic
        - `storeCard` → loyalty (or giftCard via override)
        - `eventTicket` → eventTicket
        - `coupon` → offer
        - `boardingPass` + transitType PKTransitTypeAir → flight
        - `boardingPass` + transitType Train/Bus/Boat/Generic → transit

    TransitType:
      type: string
      enum:
        - PKTransitTypeAir
        - PKTransitTypeTrain
        - PKTransitTypeBus
        - PKTransitTypeBoat
        - PKTransitTypeGeneric
      description: Apple Wallet transitType enum. Required for `boardingPass` templates at publish.

    GoogleTransitType:
      type: string
      enum:
        - BUS
        - RAIL
        - TRAM
        - FERRY
        - OTHER
      description: Google Wallet transit class transitType enum. Auto-derived from Apple's `transitType` if not set explicitly.

    RedemptionChannel:
      type: string
      enum:
        - INSTORE
        - ONLINE
        - BOTH
        - TEMPORARY_PRICE_REDUCTION
      description: Google Wallet OfferClass redemption channel.

    BarcodeFormat:
      type: string
      enum:
        - PKBarcodeFormatQR
        - PKBarcodeFormatPDF417
        - PKBarcodeFormatAztec
        - PKBarcodeFormatCode128
      description: Apple Wallet barcode format. Automatically mapped to Google's equivalent (QR_CODE, PDF_417, AZTEC, CODE_128) when the template targets Google Wallet.

    TextAlignment:
      type: string
      enum:
        - PKTextAlignmentLeft
        - PKTextAlignmentCenter
        - PKTextAlignmentRight
        - PKTextAlignmentNatural

    NumberStyle:
      type: string
      enum:
        - PKNumberStyleDecimal
        - PKNumberStylePercent
        - PKNumberStyleScientific
        - PKNumberStyleSpellOut

    DateStyle:
      type: string
      enum:
        - PKDateStyleNone
        - PKDateStyleShort
        - PKDateStyleMedium
        - PKDateStyleLong
        - PKDateStyleFull

    DataDetectorType:
      type: string
      enum:
        - PKDataDetectorTypePhoneNumber
        - PKDataDetectorTypeLink
        - PKDataDetectorTypeAddress
        - PKDataDetectorTypeCalendarEvent

    PassStatus:
      type: string
      enum:
        - active
        - invalidated
        - expired
      description: Current status of a pass.

    CertType:
      type: string
      enum:
        - signer_cert
        - signer_key
        - wwdr
      description: Type of Apple signing certificate.

    OrgRole:
      type: string
      enum:
        - owner
        - admin
        - editor
        - viewer
      description: Organization member role.

    InvitationStatus:
      type: string
      enum:
        - pending
        - accepted
        - expired
        - revoked
      description: Status of an organization invitation.

    EventType:
      type: string
      enum:
        - pass.created
        - pass.updated
        - pass.voided
        - pass.expired
        - device.registered
        - device.unregistered
      description: Webhook event type.

    DeliveryStatus:
      type: string
      enum:
        - pending
        - delivered
        - failed
      description: Webhook event delivery status.

    KeyType:
      type: string
      enum:
        - secret
        - publishable
      description: API key type.

    ImagePurpose:
      type: string
      enum:
        - icon
        - icon_2x
        - icon_3x
        - logo
        - logo_2x
        - logo_3x
        - thumbnail
        - thumbnail_2x
        - strip
        - strip_2x
        - background
        - background_2x
        - footer
        - footer_2x
      description: Intended use of a pass image.

    # -------------------------------------------------------------------------
    # Structure sub-schemas (shared between Template and its create/update bodies)
    # -------------------------------------------------------------------------

    PassField:
      type: object
      description: |
        A single field inside a pass. Used in `headerFields`, `primaryFields`,
        `secondaryFields`, `auxiliaryFields`, `backFields`, and
        `additionalInfoFields` (poster-style eventTicket only).
      required:
        - key
      properties:
        key:
          type: string
          description: Unique key identifying the field. Must be unique across ALL field groups in a template.
        label:
          type: string
          description: Human-readable label rendered above the value on the pass face.
        value:
          description: |
            Static value for the field. Number types are preserved for
            Apple's `numberStyle` / `currencyCode` formatters. For dynamic
            values, omit `value` and set `dataKey` instead.
          oneOf:
            - { type: string }
            - { type: number }
        dataKey:
          type: string
          description: |
            PassFast-specific: name of a key in the generate-pass request
            body's `data` object whose value should populate this field.
        attributedValue:
          type: string
          description: "Apple: HTML-flavored value (used for rendering links, etc.)."
        changeMessage:
          type: string
          description: "Apple: notification text when this field's value changes. Must contain `%@` placeholder for the new value."
        textAlignment: { $ref: "#/components/schemas/TextAlignment" }
        currencyCode:
          type: string
          description: "Apple: ISO 4217 currency code. Value is formatted as currency."
        numberStyle: { $ref: "#/components/schemas/NumberStyle" }
        dateStyle: { $ref: "#/components/schemas/DateStyle" }
        timeStyle: { $ref: "#/components/schemas/DateStyle" }
        isRelative:
          type: boolean
          description: "Apple: whether the date is displayed relative to the current time."
        ignoresTimeZone:
          type: boolean
          description: "Apple: whether to display the date in the pass's local time rather than the user's."
        dataDetectorTypes:
          type: array
          description: "Apple: back fields only. Limits which kinds of data are detected and linked."
          items: { $ref: "#/components/schemas/DataDetectorType" }
        row:
          type: integer
          enum: [0, 1]
          description: "Apple: auxiliary/secondary layout control (0 or 1)."
        semantics:
          type: object
          additionalProperties: true
          description: "Apple: per-field semantic tags for Siri / Maps / Do-Not-Disturb integration."

    Location:
      type: object
      required: [latitude, longitude]
      properties:
        latitude:
          type: number
          minimum: -90
          maximum: 90
        longitude:
          type: number
          minimum: -180
          maximum: 180
        altitude:
          type: number
          nullable: true
        relevantText:
          type: string
          nullable: true
          description: "Apple: lock-screen text when the device enters this location."

    Beacon:
      type: object
      required: [proximityUUID]
      properties:
        proximityUUID:
          type: string
          format: uuid
        major:
          type: integer
          minimum: 0
          maximum: 65535
        minor:
          type: integer
          minimum: 0
          maximum: 65535
        relevantText:
          type: string

    BarcodeConfig:
      type: object
      properties:
        format: { $ref: "#/components/schemas/BarcodeFormat" }
        messageDataKey:
          type: string
          description: Key in generate-pass `data` object that holds the barcode message.
        altTextDataKey:
          type: string
          description: Key in generate-pass `data` object whose value becomes the human-readable alt text.
        altText:
          type: string
          description: Static alternate text. Only used when `altTextDataKey` is not set.
        messageEncoding:
          type: string
          default: iso-8859-1
          description: Text encoding for the barcode message. Default is `iso-8859-1` per Apple spec.

    NfcConfig:
      type: object
      required: [message]
      properties:
        message:
          type: string
          description: The data transmitted during an NFC interaction.
        encryptionPublicKey:
          type: string
          description: ECC P-256 public key (base64-encoded) for encrypting the NFC payload.
        requiresAuthentication:
          type: boolean

    FlightHeader:
      type: object
      required: [carrier, flightNumber]
      properties:
        carrier:
          type: object
          required: [carrierIataCode]
          properties:
            carrierIataCode:
              type: string
              description: 2-letter IATA carrier code (e.g. `BA`, `AA`).
        flightNumber:
          type: string
          description: Flight number (digits only per Google spec).

    AirportInfo:
      type: object
      required: [airportIataCode]
      properties:
        airportIataCode:
          type: string
          description: 3-letter IATA airport code (e.g. `LHR`, `JFK`).
        terminal:
          type: string
        gate:
          type: string

    RotatingBarcode:
      type: object
      description: |
        Google Wallet rotating barcode (TOTP-based). Supply a pre-configured
        object; PassFast does not synthesize the HMAC secret.
      properties:
        type:
          type: string
          enum:
            - QR_CODE
            - AZTEC
            - PDF_417
            - CODE_128
            - DATA_MATRIX
        valuePattern:
          type: string
          description: Pattern for the rotating value, e.g. `rotating-{totp_value}`.
        totpDetails:
          type: object
          properties:
            periodMillis:
              type: string
              description: TOTP period as a string of milliseconds (e.g. `"30000"`).
            algorithm:
              type: string
              enum: [TOTP_SHA1, TOTP_SHA256, TOTP_SHA512]
            parameters:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                    description: Base16 (hex) encoded TOTP secret.
                  valueLength:
                    type: integer

    # -------------------------------------------------------------------------
    # TemplateStructure — the full JSONB shape that drives pass emission
    # -------------------------------------------------------------------------
    TemplateStructure:
      type: object
      description: |
        Complete pass structure. Drives both Apple `pass.json` emission and
        Google Wallet class/object JSON. Fields are grouped below by platform,
        but the single JSONB object carries all of them for dual-wallet templates.
      required:
        - backgroundColor
        - foregroundColor
      properties:
        # --- Colors (accepts #rrggbb or rgb(r,g,b); auto-converted per platform) ---
        backgroundColor:
          type: string
          description: "Accepts `#rrggbb`, `#rgb`, or `rgb(r,g,b)`. Apple emits `rgb(...)`; Google emits `#rrggbb`."
        foregroundColor:
          type: string
        labelColor:
          type: string
          description: "Apple only — field label color."

        # --- Branding ---
        logoText:
          type: string
          description: "Apple: text beside the logo on the pass face."
        googleLogoText:
          type: string
          description: "Google: brand/card title (required for Google Wallet rendering)."
        organizationName:
          type: string
          description: "Apple top-level org name. Defaults to 'PassFast' if unset."
        description:
          type: string
          description: "Apple top-level description. Defaults to 'PassFast Pass' if unset."

        # --- Field groups ---
        headerFields: { type: array, items: { $ref: "#/components/schemas/PassField" } }
        primaryFields: { type: array, items: { $ref: "#/components/schemas/PassField" } }
        secondaryFields: { type: array, items: { $ref: "#/components/schemas/PassField" } }
        auxiliaryFields: { type: array, items: { $ref: "#/components/schemas/PassField" } }
        backFields: { type: array, items: { $ref: "#/components/schemas/PassField" } }
        additionalInfoFields:
          type: array
          description: "Apple poster-style eventTicket only. Rendered inside the eventTicket object."
          items: { $ref: "#/components/schemas/PassField" }

        # --- Barcode ---
        barcode: { $ref: "#/components/schemas/BarcodeConfig" }

        # --- Relevance + geo ---
        locations:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/Location" }
        relevantDate:
          type: string
          format: date-time
          description: "ISO 8601 datetime with timezone suffix. Naive datetimes (no Z/offset) are rejected."
        relevantDates:
          type: array
          description: "Apple plural relevantDates[] — multiple time windows. Entries without timezone are filtered out."
          items:
            oneOf:
              - { type: string, format: date-time }
              - type: object
                properties:
                  startDate: { type: string, format: date-time }
                  endDate: { type: string, format: date-time, nullable: true }
        maxDistance:
          type: integer
          minimum: 1
          description: "Apple only — max distance (meters) for location relevance."
        beacons:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/Beacon" }

        # --- Apple boardingPass ---
        transitType: { $ref: "#/components/schemas/TransitType" }

        # --- Apple poster eventTicket ---
        eventLogoText: { type: string }
        footerBackgroundColor: { type: string }
        suppressHeaderDarkening: { type: boolean }
        useAutomaticColors: { type: boolean }

        # --- Apple top-level passthrough ---
        associatedStoreIdentifiers:
          type: array
          items: { type: integer }
          description: "Apple iTunes app IDs for 'Open in App'."
        appLaunchURL:
          type: string
          format: uri
        nfc: { $ref: "#/components/schemas/NfcConfig" }
        suppressStripShine: { type: boolean }
        groupingIdentifier:
          type: string
          description: "Apple: groups related passes. Also mapped to Google's `groupingInfo.groupingId` on the object."
        semantics:
          type: object
          additionalProperties: true
          description: "Apple: top-level semantic tags (event, transit, balance categories)."

        # --- Apple pass-style action URLs (inside pass-style object) ---
        # boardingPass
        addChangeInfoURL: { type: string, format: uri }
        bagDropURL: { type: string, format: uri }
        changeSeatURL: { type: string, format: uri }
        checkInURL: { type: string, format: uri }
        entertainmentURL: { type: string, format: uri }
        flightStatusURL: { type: string, format: uri }
        jumpToURL: { type: string, format: uri }
        seatUpgradeURL: { type: string, format: uri }
        shoppingURL: { type: string, format: uri }
        trackBagsURL: { type: string, format: uri }
        baggagePolicyURL: { type: string, format: uri }
        # eventTicket
        accessibilityURL: { type: string, format: uri }
        addOnURL: { type: string, format: uri }
        contactVenueEmail: { type: string, format: email }
        contactVenuePhoneNumber: { type: string }
        contactVenueWebsite: { type: string, format: uri }
        directionsInformationURL: { type: string, format: uri }
        liveBroadcastURL: { type: string, format: uri }
        merchandiseURL: { type: string, format: uri }
        orderFoodURL: { type: string, format: uri }
        parkingInformationURL: { type: string, format: uri }
        purchaseParkingURL: { type: string, format: uri }
        sellURL: { type: string, format: uri }
        transferURL: { type: string, format: uri }
        transitInformationURL: { type: string, format: uri }

        # --- Google Flight ---
        flightHeader: { $ref: "#/components/schemas/FlightHeader" }
        origin: { $ref: "#/components/schemas/AirportInfo" }
        destination: { $ref: "#/components/schemas/AirportInfo" }
        localScheduledDepartureDateTime:
          type: string
          description: "Google Flight: ISO 8601 **without** timezone offset (local airport time). Automatically stripped if a Z/offset is supplied."
        localScheduledArrivalDateTime:
          type: string
          description: "Google Flight: same format as departure."

        # --- Google Transit ---
        transitOperatorName: { type: string }
        googleTransitType: { $ref: "#/components/schemas/GoogleTransitType" }
        tripType:
          type: string
          enum: [ROUND_TRIP, ONE_WAY]

        # --- Google EventTicket ---
        venueName: { type: string }
        venueAddress: { type: string }
        doorsOpen: { type: string, format: date-time }
        dateTimeStart: { type: string, format: date-time }
        dateTimeEnd: { type: string, format: date-time }
        # Data-key mappings (which dynamic_data key fills which structured Google field)
        seatDataKey: { type: string }
        rowDataKey: { type: string }
        sectionDataKey: { type: string }
        gateDataKey: { type: string }
        ticketHolderNameDataKey: { type: string }
        ticketNumberDataKey: { type: string }

        # --- Google GiftCard ---
        pinLabel: { type: string }
        cardNumberLabel: { type: string }
        eventNumberLabel: { type: string }
        cardNumberDataKey: { type: string }
        pinDataKey: { type: string }
        balanceDataKey: { type: string }
        balanceCurrencyCode:
          type: string
          description: "ISO 4217 code used when `balance` is supplied as a plain number (dollars → micros)."
        eventNumberDataKey: { type: string }

        # --- Google Loyalty ---
        loyaltyPointsDataKey: { type: string }
        loyaltyPointsLabel: { type: string }
        secondaryLoyaltyPointsDataKey: { type: string }
        secondaryLoyaltyPointsLabel: { type: string }
        discoverableProgram:
          type: object
          additionalProperties: true
          description: "Google Loyalty: public program listing. Requires `countryCode` on the class (set via `discoverableProgram`)."

        # --- Google Offer ---
        redemptionChannel: { $ref: "#/components/schemas/RedemptionChannel" }

        # --- Google shared class-level passthroughs ---
        messages:
          type: array
          maxItems: 10
          description: "Google class-level messages."
          items: { type: object, additionalProperties: true }
        enableSmartTap: { type: boolean }
        callbackOptions:
          type: object
          additionalProperties: true
        securityAnimation:
          type: object
          additionalProperties: true
        viewUnlockRequirement:
          type: string
          enum: [UNLOCK_REQUIRED_TO_VIEW, VIEW_UNLOCK_REQUIREMENT_UNSPECIFIED]

        # --- Google shared object-level passthroughs ---
        rotatingBarcode: { $ref: "#/components/schemas/RotatingBarcode" }
        smartTapRedemptionValue: { type: string }
        smartTapRedemptionDataKey: { type: string }
        objectMessages:
          type: array
          maxItems: 10
          items: { type: object, additionalProperties: true }
      additionalProperties: true

    # -------------------------------------------------------------------------
    # Resource Schemas
    # -------------------------------------------------------------------------
    Pass:
      type: object
      properties:
        id:
          type: string
          format: uuid
        template_id:
          type: string
          format: uuid
        serial_number:
          type: string
        status:
          $ref: "#/components/schemas/PassStatus"
        dynamic_data:
          type: object
          additionalProperties: true
          description: Current dynamic field values for this pass.
        external_id:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        voided_at:
          type: string
          format: date-time
          nullable: true
        authentication_token:
          type: string
          description: Token used by Apple devices to authenticate with the web service.
        pkpass_storage_path:
          type: string
          description: Storage path of the .pkpass file.
        pkpass_hash:
          type: string
          description: Hash of the .pkpass file for change detection.
        organization_id:
          type: string
          format: uuid
        app_id:
          type: string
          format: uuid
        last_updated_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the last pass content update (for Apple web service).
        wallet_type:
          type: string
          enum:
            - apple
            - google
          description: Wallet platform this pass was generated for.
        google_save_url:
          type: string
          format: uri
          nullable: true
          description: Google Wallet save URL (only present for Google passes).
        google_object_id:
          type: string
          nullable: true
          description: Google Wallet object ID (only present for Google passes).
        strip_image_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Per-pass strip/hero image override. NULL means the template's strip
            image is used.

    UpdatePassRequest:
      type: object
      properties:
        data:
          type: object
          additionalProperties: true
          description: New dynamic field values to merge into the pass.
        push_update:
          type: boolean
          default: false
          description: If true, send a push notification to registered devices.
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Expiration timestamp (set to null to remove expiration).
        locations:
          type: array
          maxItems: 10
          description: GPS locations where the pass is relevant (overrides template defaults).
          items:
            type: object
            required:
              - latitude
              - longitude
            properties:
              latitude:
                type: number
                minimum: -90
                maximum: 90
              longitude:
                type: number
                minimum: -180
                maximum: 180
              altitude:
                type: number
              relevantText:
                type: string
        relevant_date:
          type: string
          format: date-time
          description: ISO 8601 date when the pass is relevant (lock screen).
        max_distance:
          type: number
          minimum: 0
          description: Maximum distance in meters from a location for lock screen relevance.
        strip_image_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Override the template's strip/hero image for this pass. The referenced
            image must belong to the same app and have purpose `strip` (or a `strip_*`
            variant). Applied to Apple `strip.png` and Google `heroImage` atomically
            (synced to the sibling pass for dual-wallet). Send `null` to clear the
            override and revert to the template's strip image. Omit the field to
            leave the current override unchanged.

    UpdatePassResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
        expires_at:
          type: string
          format: date-time
          nullable: true
        devices_notified:
          type: integer
          description: Number of devices that were sent a push notification.
        wallet_type:
          type: string
          enum: [apple, google]
          description: The wallet provider for this pass.
        updated_at:
          type: string
          format: date-time

    GoogleGenerateResponse:
      type: object
      description: Response when `wallet_type` is `"google"`.
      properties:
        id:
          type: string
          format: uuid
        serial_number:
          type: string
        wallet_type:
          type: string
          enum: [google]
        save_url:
          type: string
          format: uri
          description: Google Wallet save URL.
        google_object_id:
          type: string
        status:
          type: string
          example: active
        external_id:
          type: string
          nullable: true

    DualGenerateResponse:
      type: object
      description: Response for dual generation — contains both Apple and Google pass results.
      properties:
        apple:
          type: object
          nullable: true
          description: Apple pass result (null if Apple generation failed).
          properties:
            id:
              type: string
              format: uuid
            serial_number:
              type: string
            wallet_type:
              type: string
              enum: [apple]
            status:
              type: string
              example: active
            download_url:
              type: string
              description: Relative URL to download the .pkpass file.
        google:
          type: object
          nullable: true
          description: Google pass result (null if Google generation failed).
          properties:
            id:
              type: string
              format: uuid
            serial_number:
              type: string
            wallet_type:
              type: string
              enum: [google]
            status:
              type: string
              example: active
            save_url:
              type: string
              format: uri
              description: Google Wallet save URL for the user to add the pass.
            google_object_id:
              type: string
              description: Google Wallet object ID.
        warnings:
          type: array
          items:
            type: string
          description: Warnings for partial failures (e.g. one wallet succeeded but the other failed).

    Template:
      type: object
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        app_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
          nullable: true
        pass_style:
          $ref: "#/components/schemas/PassStyle"
        google_pass_type:
          allOf:
            - $ref: "#/components/schemas/GooglePassType"
          nullable: true
          description: Explicit Google Wallet class type override. When set, bypasses the Apple `pass_style` auto-mapping.
        structure:
          $ref: "#/components/schemas/TemplateStructure"
        field_schema:
          type: object
          additionalProperties: true
          nullable: true
          description: Optional JSON schema for validating dynamic data at generate-pass time.
        is_published:
          type: boolean
          description: Whether the template has been published and is available for pass generation.
        is_archived:
          type: boolean
          description: Whether the template has been soft-deleted (archived).
        icon_image_id:
          type: string
          format: uuid
          nullable: true
        logo_image_id:
          type: string
          format: uuid
          nullable: true
        google_logo_image_id:
          type: string
          format: uuid
          nullable: true
          description: "Google: square (660×660) logo for notifications / thumbnails."
        google_wide_logo_image_id:
          type: string
          format: uuid
          nullable: true
          description: "Google: rectangular (1280×400) wide logo rendered on the card face."
        strip_image_id:
          type: string
          format: uuid
          nullable: true
        thumbnail_image_id:
          type: string
          format: uuid
          nullable: true
        background_image_id:
          type: string
          format: uuid
          nullable: true
        wallet_types:
          type: array
          items:
            type: string
            enum:
              - apple
              - google
          description: Wallet platforms this template supports.
        google_class_id:
          type: string
          nullable: true
          description: Google Wallet class ID (only present for published templates supporting Google).
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    Image:
      type: object
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        app_id:
          type: string
          format: uuid
        purpose:
          $ref: "#/components/schemas/ImagePurpose"
        storage_path:
          type: string
        mime_type:
          type: string
          description: MIME type of the image (e.g., image/png).
        size_bytes:
          type: integer
          description: File size in bytes.
        width:
          type: integer
          nullable: true
          description: Image width in pixels.
        height:
          type: integer
          nullable: true
          description: Image height in pixels.
        preview_url:
          type: string
          format: uri
          description: Signed URL for previewing the image (time-limited).
        uploaded_at:
          type: string
          format: date-time

    Certificate:
      type: object
      properties:
        id:
          type: string
          format: uuid
        cert_type:
          $ref: "#/components/schemas/CertType"
        cert_hash:
          type: string
          description: SHA-256 hash of the certificate data.
        common_name:
          type: string
          nullable: true
          description: Common name (CN) from the certificate subject.
        valid_from:
          type: string
          format: date-time
          nullable: true
          description: Certificate validity start date.
        valid_until:
          type: string
          format: date-time
          nullable: true
          description: Certificate validity end date.
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time

    Organization:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        apns_key_id:
          type: string
          nullable: true
        billing_plan:
          type: string
          nullable: true
        monthly_pass_limit:
          type: integer
          nullable: true
        features:
          type: object
          additionalProperties: true
          nullable: true
        is_active:
          type: boolean
        webhook_secret:
          type: string
          nullable: true
          description: Masked webhook signing secret (first 4 + "****" + last 4 chars).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    App:
      type: object
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        name:
          type: string
        apple_team_id:
          type: string
          nullable: true
        pass_type_identifier:
          type: string
          nullable: true
        webhook_url:
          type: string
          format: uri
          nullable: true
          description: URL for async event webhook delivery.
        validation_webhook_url:
          type: string
          format: uri
          nullable: true
          description: URL for pre-generation validation webhooks.
        is_active:
          type: boolean
        webhook_secret:
          type: string
          nullable: true
          description: Masked webhook signing secret.
        signing_mode:
          type: string
          nullable: true
          enum:
            - managed
            - custom
          description: Apple signing mode. `managed` uses platform credentials; `custom` uses your own.
        google_signing_mode:
          type: string
          nullable: true
          enum:
            - managed
            - custom
          description: Google signing mode. `managed` uses platform credentials; `custom` uses your own.
        onboarding_completed:
          type: boolean
          description: Whether onboarding has been completed for this app.
        google_issuer_id:
          type: string
          nullable: true
          description: Google Wallet issuer ID from linked credentials.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ApiKey:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        key_type:
          $ref: "#/components/schemas/KeyType"
        key_prefix:
          type: string
          description: First characters of the key for identification (e.g., sk_live_abc1...).
        scopes:
          type: array
          items:
            type: string
          description: List of permission scopes granted to this key.
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
        is_active:
          type: boolean

    Member:
      type: object
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        role:
          $ref: "#/components/schemas/OrgRole"
        created_at:
          type: string
          format: date-time

    Invitation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        role:
          type: string
          enum:
            - admin
            - editor
            - viewer
        status:
          $ref: "#/components/schemas/InvitationStatus"
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time

    WebhookEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        event_type:
          $ref: "#/components/schemas/EventType"
        payload:
          type: object
          additionalProperties: true
          description: Full event payload that was (or will be) delivered.
        delivery_status:
          $ref: "#/components/schemas/DeliveryStatus"
        attempts:
          type: integer
          description: Number of delivery attempts made.
        last_error:
          type: string
          nullable: true
          description: Error message from the most recent failed delivery attempt.
        delivered_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of successful delivery.
        next_retry_at:
          type: string
          format: date-time
          nullable: true
          description: Scheduled time for the next delivery retry.
        created_at:
          type: string
          format: date-time

    ShareToken:
      type: object
      properties:
        share_token:
          type: string
          description: Share token (32 hex characters, UUID without dashes).
        share_url:
          type: string
          format: uri
          description: Full public URL for the share page.

    Error:
      type: object
      description: |
        Standard error envelope. Known `error.code` values:

        **Client (4xx)**
        - `bad_request` — malformed request body or parameters
        - `unauthorized` — missing or invalid API key / JWT
        - `forbidden` / `webhook_rejected` — permission denied or validation webhook rejected
        - `not_found` — template, pass, image, or member not found
        - `rate_limited` — too many requests (Apple Web Service)
        - `validation_error` — `data` payload fails the template's `field_schema`
        - `duplicate_serial` — pass with same `(app_id, serial_number, wallet_type)` exists
        - `duplicate_field_key` — two fields share a `key` within a template
        - `invalid_transit_type` — boardingPass missing/invalid `structure.transitType`
        - `invalid_barcode_format` — barcode format not in the 4 Apple enums
        - `invalid_datetime_format` — `relevant_date` / `expires_at` missing timezone
        - `missing_apple_certificate` — publish requires Apple cert that isn't configured
        - `missing_google_credentials` — publish requires Google service account that isn't configured
        - `cert_expired` / `cert_not_yet_valid` — Apple signer cert outside its validity window
        - `immutable` — template is published; must create a new one instead
        - `already_published` / `already_voided` / `pass_expired` / `archived` — state transitions
        - `free_limit_reached` / `subscription_canceled` / `payment_past_due` — billing (402)

        **Server (5xx)**
        - `internal_error` — unexpected failure
        - `credential_decrypt_failed` — credential ciphertext could not be decrypted
        - `webhook_error` — validation webhook unreachable
      required:
        - error
      properties:
        error:
          type: string
          description: Machine-readable error code.
        code:
          type: string
          description: Optional error code for programmatic handling.
        message:
          type: string
          description: Optional human-readable error message.
        details:
          type: object
          additionalProperties: true
          description: Optional additional error details.
