openapi: 3.1.0
info:
  title: Alva Digital Downloads External REST API
  version: 1.0.0
  summary: MVP /api/v1 contract for external integrations.
  description: >
    External REST API for per-shop asset ingestion, product mapping, order assignment,
    order-access controls, and status queries. The API is unreleased and remains gated
    by API_V1_ENABLED plus the per-shop Shop.apiAccessEnabled flag.
servers:
  - url: https://{appHost}/api/v1
    description: Heroku app host
    variables:
      appHost:
        default: app.example.com
security:
  - ApiKeyAuth: []
tags:
  - name: Health
  - name: Authentication
  - name: Assets
  - name: Categories
  - name: Mappings
  - name: Packs
  - name: License key tags
  - name: Order assignments
  - name: Order access
  - name: Status
paths:
  /health:
    get:
      tags: [Health]
      operationId: getApiHealth
      summary: API health check.
      security: []
      responses:
        "200":
          description: API route scaffold is reachable.
          content:
            application/json:
              schema:
                type: object
                required: [ok, api, time]
                properties:
                  ok:
                    type: boolean
                  api:
                    type: string
                    example: v1
                  time:
                    type: string
                    format: date-time
  /ping:
    get:
      tags: [Health]
      operationId: pingApi
      summary: Authenticated ping.
      responses:
        "200":
          description: Authenticated request succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ping"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
  /auth/keys:
    get:
      tags: [Authentication]
      operationId: listApiKeys
      summary: List API keys for the authenticated shop.
      responses:
        "200":
          description: Masked keys.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKey"
    post:
      tags: [Authentication]
      operationId: createApiKey
      summary: Create an API key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  example: PMT integration
                scopes:
                  description: >-
                    Optional per-key scopes (default []). NOTE: scopes are stored
                    but NOT yet enforced by any route — a restricted key currently
                    has full access.
                  type: array
                  items:
                    type: string
      responses:
        "201":
          description: >-
            Created key. The token is shown ONCE. An idempotent replay returns
            token:null (the secret is never re-revealed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyWriteResponse"
        "400":
          $ref: "#/components/responses/IdempotencyRequired"
        "422":
          description: key_limit_reached — the shop already holds 25 active keys.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /auth/keys/{id}:
    delete:
      tags: [Authentication]
      operationId: revokeApiKey
      summary: Revoke an API key.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          $ref: "#/components/responses/NotFound"
  /assets:
    get:
      tags: [Assets]
      operationId: listAssets
      summary: List assets.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/CategoryId"
      responses:
        "200":
          description: Paginated assets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Asset"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
    post:
      tags: [Assets]
      operationId: createAssetIngest
      summary: Create an asset by fetching a remote URL.
      description: SSRF-guarded remote ingest. The allowlist is default-deny when API_INGEST_ALLOWED_HOSTS is empty.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetIngestRequest"
      responses:
        "202":
          description: Ingest job queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetIngestResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
        "415":
          $ref: "#/components/responses/UnsupportedFileType"
        "422":
          $ref: "#/components/responses/StorageQuotaExceeded"
        "503":
          $ref: "#/components/responses/WorkerUnavailable"
  /assets/uploads:
    post:
      tags: [Assets]
      operationId: beginAssetUpload
      summary: Begin a direct upload.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UploadBeginRequest"
      responses:
        "201":
          description: Presigned direct upload or multipart upload metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadBeginResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
        "415":
          $ref: "#/components/responses/UnsupportedFileType"
        "422":
          $ref: "#/components/responses/StorageQuotaExceeded"
  /assets/uploads/{uploadId}/complete:
    post:
      tags: [Assets]
      operationId: completeAssetUpload
      summary: Finalize a direct upload.
      parameters:
        - $ref: "#/components/parameters/UploadId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UploadCompleteRequest"
      responses:
        "200":
          description: Asset finalized.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetWriteResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/AssetNotFound"
        "415":
          $ref: "#/components/responses/UnsupportedFileType"
        "422":
          $ref: "#/components/responses/StorageQuotaExceeded"
  /assets/{id}:
    get:
      tags: [Assets]
      operationId: getAsset
      summary: Get an asset with a fresh presigned download URL.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Asset detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "404":
          $ref: "#/components/responses/AssetNotFound"
    patch:
      tags: [Assets]
      operationId: updateAsset
      summary: Update asset metadata.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                category_id:
                  type:
                    - integer
                    - "null"
      responses:
        "200":
          description: Asset updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetWriteResponse"
    delete:
      tags: [Assets]
      operationId: deleteAsset
      summary: Delete an asset.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Asset deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
  /categories:
    get:
      tags: [Categories]
      operationId: listCategories
      summary: List flat categories.
      responses:
        "200":
          description: Category list.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Category"
    post:
      tags: [Categories]
      operationId: createCategory
      summary: Create a flat category.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
      responses:
        "201":
          description: Category created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryWriteResponse"
        "409":
          $ref: "#/components/responses/DuplicateCategory"
  /categories/{id}:
    get:
      tags: [Categories]
      operationId: getCategory
      summary: Get a category with its members.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Category detail (bare resource).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryDetail"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
    patch:
      tags: [Categories]
      operationId: updateCategory
      summary: Rename a category.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
      responses:
        "200":
          description: Category updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryWriteResponse"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
        "409":
          $ref: "#/components/responses/DuplicateCategory"
    delete:
      tags: [Categories]
      operationId: deleteCategory
      summary: Delete a category and detach its assets.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Category deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          $ref: "#/components/responses/CategoryNotFound"
  /mappings:
    get:
      tags: [Mappings]
      operationId: listMappings
      summary: List product mappings.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
        - name: shopify_product_id
          in: query
          required: false
          description: Filter to one Shopify product (bare numeric or GID).
          schema:
            type: string
      responses:
        "200":
          description: Paginated mappings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Mapping"
    post:
      tags: [Mappings]
      operationId: upsertMapping
      summary: Create or replace a mapping by Shopify product/variant lookup.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MappingRequest"
      responses:
        "200":
          description: Existing mapping updated (full replace).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MappingWriteResponse"
        "201":
          description: New mapping created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MappingWriteResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "409":
          $ref: "#/components/responses/MultipleMappingsMatch"
  /mappings/{id}:
    get:
      tags: [Mappings]
      operationId: getMapping
      summary: Get a mapping (or resolve one by Shopify id).
      description: >-
        Returns the mapping addressed by `{id}`. Alternatively, resolve by Shopify
        id with the `shopify_product_id` (and optional `shopify_variant_id`) query
        params — `{id}` is then ignored (use any placeholder, e.g. `by`).
      parameters:
        - $ref: "#/components/parameters/Id"
        - name: shopify_product_id
          in: query
          required: false
          description: Resolve the mapping by product id (bare numeric or GID) instead of by `{id}`.
          schema:
            type: string
        - name: shopify_variant_id
          in: query
          required: false
          description: Narrow the resolve-by lookup to a specific variant (bare numeric or GID).
          schema:
            type: string
      responses:
        "200":
          description: Mapping detail (bare resource).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MappingDetail"
        "404":
          $ref: "#/components/responses/MappingNotFound"
        "409":
          $ref: "#/components/responses/MultipleMappingsMatch"
    put:
      tags: [Mappings]
      operationId: replaceMapping
      summary: Full-replace the contents of a mapping (by id).
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MappingReplaceRequest"
      responses:
        "200":
          description: >-
            Mapping replaced. The body is `{ data: { id, replaced: true }, idempotent_replay }`
            (NOT the full mapping — re-GET the mapping for its contents).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/MappingNotFound"
    delete:
      tags: [Mappings]
      operationId: deleteMapping
      summary: Delete a mapping.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Mapping deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          $ref: "#/components/responses/MappingNotFound"
  /packs:
    get:
      tags: [Packs]
      operationId: listPacks
      summary: List packs (paginated).
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/CategoryId"
      responses:
        "200":
          description: Pack list (paginated envelope).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Pack"
    post:
      tags: [Packs]
      operationId: createPack
      summary: Create a pack.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
                file_ids:
                  type: array
                  items:
                    type: integer
      responses:
        "201":
          description: Pack created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PackWriteResponse"
  /packs/{id}:
    get:
      tags: [Packs]
      operationId: getPack
      summary: Get a pack with its member files.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Pack detail (bare resource).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PackDetail"
        "404":
          $ref: "#/components/responses/PackNotFound"
    delete:
      tags: [Packs]
      operationId: deletePack
      summary: Delete a pack.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Pack deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          $ref: "#/components/responses/PackNotFound"
  /license-key-tags:
    get:
      tags: [License key tags]
      operationId: listLicenseKeyTags
      summary: List license-key tags.
      responses:
        "200":
          description: License-key tag list.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/LicenseKeyTag"
    post:
      tags: [License key tags]
      operationId: createLicenseKeyTag
      summary: Create a license-key tag.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
              description: >-
                Only `name` is read. Product→tag linking is done via
                `license_key_tag_ids` on POST/PUT /mappings, NOT here.
      responses:
        "201":
          description: Tag created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LicenseKeyTagWriteResponse"
        "409":
          $ref: "#/components/responses/DuplicateTag"
  /orders/{shopify_order_id}:
    get:
      tags: [Status]
      operationId: getOrderStatus
      summary: Get order status and assigned purchases.
      description: Accepts a GID or bare-numeric order id (parity with the other order routes).
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
      responses:
        "200":
          description: Order status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatus"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/OrderNotFound"
  /orders/{shopify_order_id}/assignments:
    get:
      tags: [Order assignments]
      operationId: listOrderAssignments
      summary: List assets assigned to an order.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
      responses:
        "200":
          description: Assignment list.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Assignment"
        "404":
          $ref: "#/components/responses/OrderNotFound"
    post:
      tags: [Order assignments]
      operationId: createOrderAssignments
      summary: Assign product-level assets to an order for Customer Account visibility.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssignmentRequest"
      responses:
        "201":
          description: Assignments created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssignmentWriteResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/MappingNotFound"
        "409":
          $ref: "#/components/responses/MultipleMappingsMatch"
        "422":
          $ref: "#/components/responses/CustomerUnresolvable"
  /orders/{shopify_order_id}/assignments/{purchaseId}/download-urls:
    get:
      tags: [Order assignments]
      operationId: getAssignmentDownloadUrls
      summary: Mint fresh download URLs for one assigned purchase.
      description: >-
        Gated: the purchase must belong to the order in the path AND have
        access_enabled=true. A revoked purchase, or a purchaseId under a different
        order, returns 404 order_not_found and mints no URLs.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/PurchaseId"
      responses:
        "200":
          description: Fresh download URLs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [purchase_id, files]
                    properties:
                      purchase_id:
                        type: integer
                      files:
                        type: array
                        items:
                          $ref: "#/components/schemas/DownloadUrl"
        "404":
          $ref: "#/components/responses/OrderNotFound"
  /orders/{shopify_order_id}/revoke:
    post:
      tags: [Order access]
      operationId: revokeOrderAccess
      summary: Disable access for an order or product within an order.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderAccessRequest"
      responses:
        "200":
          description: Access revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          description: order_not_found (no matching purchases) or mapping_not_found (unknown scoped product).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /orders/{shopify_order_id}/restore:
    post:
      tags: [Order access]
      operationId: restoreOrderAccess
      summary: Restore access for an order or product within an order.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderAccessRequest"
      responses:
        "200":
          description: Access restored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          description: order_not_found (no matching purchases) or mapping_not_found (unknown scoped product).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /orders/{shopify_order_id}/grant-downloads:
    post:
      tags: [Order access]
      operationId: grantExtraDownloads
      summary: Grant extra downloads for an order or product within an order.
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [extra_downloads]
              properties:
                shopify_product_id:
                  type: string
                extra_downloads:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: Extra downloads granted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          description: order_not_found (no matching purchases) or mapping_not_found (unknown scoped product).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /orders/{shopify_order_id}/resend:
    post:
      tags: [Order access]
      operationId: resendOrderEmail
      summary: Resend the download email for an order.
      description: >-
        Optionally scope to one product via `shopify_product_id` (an unknown
        product → 404 mapping_not_found).
      parameters:
        - $ref: "#/components/parameters/ShopifyOrderId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderAccessRequest"
      responses:
        "200":
          description: Email resent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WriteResponse"
        "404":
          description: >-
            mapping_not_found (the scoped shopify_product_id has no mapping) OR
            order_not_found (no deliverable purchases for the order).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          $ref: "#/components/responses/StampingInProgress"
        "422":
          $ref: "#/components/responses/EmailDisabled"
        "429":
          $ref: "#/components/responses/RateLimited"
  /jobs/{jobId}:
    get:
      tags: [Status]
      operationId: getJobStatus
      summary: Get async job status.
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Job status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobStatus"
        "404":
          $ref: "#/components/responses/JobNotFound"
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: adk_live_<keyId>_<secret>
  parameters:
    Id:
      name: id
      in: path
      required: true
      schema:
        type: integer
    UploadId:
      name: uploadId
      in: path
      required: true
      schema:
        type: string
    PurchaseId:
      name: purchaseId
      in: path
      required: true
      schema:
        type: integer
    ShopifyOrderId:
      name: shopify_order_id
      in: path
      required: true
      schema:
        type: string
        example: "55012345"
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 1
      description: Required on every non-GET request.
    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        default: 1
        minimum: 1
    PageSize:
      name: pageSize
      in: query
      required: false
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
    Search:
      name: search
      in: query
      required: false
      schema:
        type: string
    CategoryId:
      name: category_id
      in: query
      required: false
      schema:
        type: integer
  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
    RateLimitLimit:
      description: Token bucket capacity.
      schema:
        type: integer
    RateLimitRemaining:
      description: Tokens remaining after this request.
      schema:
        type: integer
    RateLimitReset:
      description: Unix timestamp when more tokens are available.
      schema:
        type: integer
  responses:
    Unauthorized:
      description: Missing, malformed, unknown, or revoked API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            unauthorized:
              value:
                error:
                  code: unauthorized
                  message: Unauthorized
                  status: 401
                  request_id: req_123
    PlanRequired:
      description: Shop.apiAccessEnabled is off.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    IdempotencyRequired:
      description: Missing Idempotency-Key on a write.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    NotFound:
      description: Missing resource or resource belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    InvalidRequest:
      description: Malformed request, invalid field type/value, invalid Shopify id, or unknown/unowned reference id.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    MappingNotFound:
      description: Mapping (or scoped product) missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    CategoryNotFound:
      description: Category missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    PackNotFound:
      description: Pack missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    AssetNotFound:
      description: Asset missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    OrderNotFound:
      description: Order missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    JobNotFound:
      description: Job missing or belongs to another shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    DuplicateCategory:
      description: Category name already exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    DuplicateTag:
      description: License-key tag already exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    MultipleMappingsMatch:
      description: Shopify product/variant resolves to multiple mappings.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    CustomerUnresolvable:
      description: Order customer could not be created or resolved.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    StampingInProgress:
      description: Resend blocked while PDF stamping is deferred.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    EmailDisabled:
      description: Email sending is disabled for the shop.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    StorageQuotaExceeded:
      description: Upload would exceed the shop storage quota.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    UnsupportedFileType:
      description: Extension or MIME type is blocked.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    WorkerUnavailable:
      description: Async job could not be queued.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status, request_id]
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - invalid_shopify_id
                - idempotency_key_required
                - unauthorized
                - plan_required
                - forbidden
                - asset_not_found
                - mapping_not_found
                - order_not_found
                - category_not_found
                - job_not_found
                - pack_not_found
                - key_not_found
                - method_not_allowed
                - app_not_installed
                - idempotency_conflict
                - idempotency_conflict_in_progress
                - multiple_mappings_match
                - stamping_in_progress
                - duplicate_category
                - duplicate_tag
                - payload_too_large
                - unsupported_file_type
                - email_disabled
                - storage_quota_exceeded
                - customer_unresolvable
                - key_limit_reached
                - rate_limited
                - internal_error
                - worker_unavailable
            message:
              type: string
            status:
              type: integer
            request_id:
              type: string
    WriteResponse:
      type: object
      required: [data, idempotent_replay]
      properties:
        data:
          type: object
        idempotent_replay:
          type: boolean
    PaginatedResponse:
      type: object
      required: [data, totalCount, page, pageSize, totalPages]
      properties:
        data:
          type: array
          items:
            type: object
        totalCount:
          type: integer
        page:
          type: integer
        pageSize:
          type: integer
        totalPages:
          type: integer
    Ping:
      type: object
      required: [ok, shop, shop_id, key_id]
      properties:
        ok:
          type: boolean
        shop:
          type: string
        shop_id:
          type: integer
        key_id:
          type: string
    ApiKey:
      type: object
      required: [id, key_id, label, last_four, scopes, last_used_at, revoked_at, created_at]
      properties:
        id:
          type: integer
        key_id:
          type: string
        label:
          type:
            - string
            - "null"
        scopes:
          type: array
          items:
            type: string
        last_four:
          type:
            - string
            - "null"
        created_at:
          type: string
          format: date-time
        revoked_at:
          type:
            - string
            - "null"
          format: date-time
        last_used_at:
          type:
            - string
            - "null"
          format: date-time
    ApiKeyCreated:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [token]
          properties:
            token:
              description: The full one-time token; null on an idempotent replay.
              type:
                - string
                - "null"
              example: adk_live_0123456789abcdef01234567_abcdef
    ApiKeyWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/ApiKeyCreated"
    Asset:
      type: object
      required: [id, title, filename]
      properties:
        id:
          type: integer
        title:
          type:
            - string
            - "null"
        filename:
          type:
            - string
            - "null"
        file_size_mb:
          type:
            - number
            - "null"
        mime_type:
          type:
            - string
            - "null"
        file_extension:
          type:
            - string
            - "null"
        file_type:
          type:
            - string
            - "null"
        version:
          type:
            - integer
            - "null"
        storage_provider:
          type:
            - string
            - "null"
        category:
          type:
            - object
            - "null"
          properties:
            id:
              type: integer
            name:
              type: string
        download_url:
          description: Present on GET /assets/{id} (null if the R2 object is missing).
          type:
            - string
            - "null"
          format: uri
        storage_missing:
          type: boolean
        created_at:
          type:
            - string
            - "null"
          format: date-time
        updated_at:
          type:
            - string
            - "null"
          format: date-time
    AssetWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/Asset"
    AssetIngestRequest:
      type: object
      required: [source_url, filename]
      properties:
        source_url:
          type: string
          format: uri
        filename:
          type: string
        title:
          type: string
        category_id:
          type: integer
        content_type:
          type: string
    AssetIngestResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              type: object
              required: [id, status, job_id]
              properties:
                id:
                  type: integer
                status:
                  type: string
                  enum: [ingesting]
                job_id:
                  type: string
    UploadBeginRequest:
      type: object
      required: [filename]
      properties:
        filename:
          type: string
        file_size:
          description: Bytes. Files larger than the multipart threshold (100 MB) return a multipart init.
          type: integer
        content_type:
          type: string
        title:
          type: string
        category_id:
          type: integer
        existing_file_id:
          type: integer
    UploadBeginResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              type: object
              required: [mode, upload_id, file_id, file_key]
              properties:
                mode:
                  type: string
                  enum: [presigned, multipart]
                upload_id:
                  type: string
                file_id:
                  type: integer
                file_key:
                  type: string
                upload_url:
                  description: Present when mode=presigned (single PUT).
                  type: string
                  format: uri
                part_urls:
                  description: Present when mode=multipart.
                  type: array
                  items:
                    type: object
                    properties:
                      part_number:
                        type: integer
                      upload_url:
                        type: string
                        format: uri
                part_size:
                  type: integer
                total_parts:
                  type: integer
    UploadCompleteRequest:
      type: object
      required: [file_id, file_key, filename]
      properties:
        file_id:
          type: integer
        file_key:
          type: string
        filename:
          type: string
        size:
          type: integer
        content_type:
          type: string
        title:
          type: string
        mode:
          type: string
          enum: [presigned, multipart]
        upload_id:
          type: string
        parts:
          description: Required for multipart. Each part accepts PartNumber/ETag (or part_number/etag).
          type: array
          items:
            type: object
            properties:
              PartNumber:
                type: integer
              ETag:
                type: string
    Category:
      description: >-
        The list (GET /categories) returns id, name, file_count, pack_count, created_at, updated_at.
        Create (POST /categories) returns just { id, name }.
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string
        file_count:
          type: integer
        pack_count:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CategoryWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/Category"
    CategoryDetail:
      description: GET /categories/{id} — a bare snake_case resource with its member files + packs.
      type: object
      required: [id, name, created_at, updated_at, files, packs]
      properties:
        id:
          type: integer
        name:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        files:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              title:
                type: string
              filename:
                type: string
              file_size_mb:
                type: number
              updated_at:
                type: string
                format: date-time
        packs:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              title:
                type: string
              description:
                type: [string, "null"]
              file_count:
                type: integer
              updated_at:
                type: string
                format: date-time
    MappingRequest:
      type: object
      required: [shopify_product_id]
      properties:
        shopify_product_id:
          type: string
          example: gid://shopify/Product/123
        shopify_variant_ids:
          type: array
          items:
            type: string
        file_ids:
          type: array
          items:
            type: integer
        pack_ids:
          type: array
          items:
            type: integer
        link_ids:
          type: array
          items:
            type: integer
        license_key_tag_ids:
          type: array
          items:
            type: integer
        download_type:
          type: string
          enum: [ZIP, INDIVIDUAL]
        is_active:
          type: boolean
        zip_title:
          type: string
          description: Title for the generated ZIP (ZIP download_type only).
    Mapping:
      type: object
      required: [id, shopify_product_id, is_active]
      properties:
        id:
          type: string
          description: ProductMapping UUID.
        shopify_product_id:
          type: string
          example: gid://shopify/Product/123
        shopify_variant_ids:
          type: array
          items:
            type: string
        product_title:
          type:
            - string
            - "null"
        is_active:
          type: boolean
        is_default_variant:
          type: boolean
        download_type:
          type: string
          enum: [ZIP, INDIVIDUAL]
        is_personalized:
          type: boolean
        created_at:
          type: string
          format: date-time
    MappingWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/Mapping"
    MappingReplaceRequest:
      description: >-
        PUT /mappings/{id} body. Same fields as MappingRequest EXCEPT
        shopify_product_id, which is addressed by the path `{id}` and is ignored if
        sent.
      type: object
      properties:
        shopify_variant_ids:
          type: array
          items:
            type: string
        file_ids:
          type: array
          items:
            type: integer
        pack_ids:
          type: array
          items:
            type: integer
        link_ids:
          type: array
          items:
            type: integer
        license_key_tag_ids:
          type: array
          items:
            type: integer
        download_type:
          type: string
          enum: [ZIP, INDIVIDUAL]
        is_active:
          type: boolean
        zip_title:
          type: string
    MappingDetail:
      description: >-
        GET /mappings/{id} — a bare snake_case resource. Extends the summary Mapping
        with the attached resource ids, zip_title, and an `orphaned` flag (Shopify
        product deleted).
      allOf:
        - $ref: "#/components/schemas/Mapping"
        - type: object
          required: [file_ids, pack_ids, link_ids, license_key_tag_ids, orphaned]
          properties:
            zip_title:
              type: [string, "null"]
            orphaned:
              type: boolean
            file_ids:
              type: array
              items:
                type: integer
            pack_ids:
              type: array
              items:
                type: integer
            link_ids:
              type: array
              items:
                type: integer
            license_key_tag_ids:
              type: array
              items:
                type: integer
    Pack:
      description: >-
        The list (GET /packs) returns id, title, description, file_count, category, created_at,
        updated_at. POST /packs accepts { title, description?, file_ids? } and returns
        { id, title, description, file_count }. GET /packs/{id} additionally returns files[].
      type: object
      required: [id, title]
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type:
            - string
            - "null"
        file_count:
          type: integer
        category:
          type:
            - object
            - "null"
          properties:
            id:
              type: integer
            name:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PackWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/Pack"
    PackDetail:
      description: >-
        GET /packs/{id} — a bare snake_case resource with its member files. Note it
        does NOT include file_count/category (those are list-only).
      type: object
      required: [id, title, description, created_at, updated_at, files]
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        files:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              title:
                type: [string, "null"]
              filename:
                type: string
              sort_order:
                type: integer
    LicenseKeyTag:
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string
        # The GET list returns inventory counts; the POST write response returns
        # only { id, name }. None of these carry shopify_product_id.
        available_count:
          type: integer
        assigned_count:
          type: integer
        total_count:
          type: integer
        product_count:
          type: integer
    LicenseKeyTagWriteResponse:
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/LicenseKeyTag"
    AssignmentRequest:
      type: object
      required: [shopify_customer_id, items]
      properties:
        shopify_customer_id:
          type: string
          example: "987654321"
        items:
          type: array
          items:
            type: object
            required: [shopify_product_id]
            properties:
              shopify_product_id:
                type: string
              shopify_variant_id:
                type: string
        send_email:
          type: boolean
          default: false
        fraud_check:
          type: boolean
          default: false
    Assignment:
      description: One row from GET /orders/{shopify_order_id}/assignments.
      type: object
      required: [purchase_id, shopify_order_id, access_enabled]
      properties:
        purchase_id:
          type: integer
        shopify_order_id:
          type: string
        order_number:
          type: integer
        shopify_product_id:
          type:
            - string
            - "null"
        is_active:
          description: The product mapping's active state; false when deactivated or deleted (orphaned).
          type: boolean
        access_enabled:
          type: boolean
        purchase_date:
          type:
            - string
            - "null"
          format: date-time
    AssignmentWriteResponse:
      description: POST /orders/{shopify_order_id}/assignments response.
      allOf:
        - $ref: "#/components/schemas/WriteResponse"
        - type: object
          properties:
            data:
              type: object
              required: [order_id, purchases]
              properties:
                order_id:
                  type: string
                purchases:
                  type: array
                  items:
                    type: object
                    properties:
                      id:
                        type: integer
                      shopify_product_id:
                        type: string
                      access_enabled:
                        type: boolean
                email_sent:
                  description: Present only when send_email=true.
                  type: boolean
                email_error:
                  description: >-
                    Present only when send_email=true AND the email was not sent
                    (e.g. email_disabled, rate_limited, send_failed). Omitted on a
                    successful send.
                  type: string
    DownloadUrl:
      type: object
      required: [file_id, filename, url, expires_at]
      properties:
        file_id:
          type: integer
        filename:
          type: string
        url:
          type: string
          format: uri
        expires_at:
          type: string
          format: date-time
    OrderAccessRequest:
      type: object
      properties:
        shopify_product_id:
          type: string
    JobStatus:
      description: >-
        GET /jobs/{jobId} returns a bare object (not wrapped in `data`). download_url is minted
        fresh on completed zip/ingest jobs; stamp jobs never return one.
      type: object
      required: [id, type, status, created_at, updated_at]
      properties:
        id:
          type: string
        type:
          type: string
          enum: [ingest, zip, stamp]
        status:
          type: string
          enum: [queued, processing, completed, failed, cancelled]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        download_url:
          type:
            - string
            - "null"
          format: uri
        url_error:
          type: string
        error:
          type: string
    OrderStatus:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [shopify_order_id, purchases]
          properties:
            shopify_order_id:
              type: string
            purchases:
              type: array
              items:
                $ref: "#/components/schemas/OrderStatusPurchase"
    OrderStatusPurchase:
      type: object
      required: [id, shopify_order_id, order_number, access_enabled, fraud, email, stamp, assigned_files]
      properties:
        id:
          type: integer
        shopify_order_id:
          type: string
        order_number:
          type: integer
        shopify_customer_id:
          type: [string, "null"]
        shopify_product_id:
          type: [string, "null"]
        shopify_variant_id:
          type: [string, "null"]
        access_enabled:
          type: boolean
        fraud:
          type: object
          required: [enabled, passed, status]
          properties:
            enabled:
              type: boolean
            passed:
              type: [boolean, "null"]
            status:
              type: string
              enum: [not_required, passed, pending, rejected]
        email:
          type: object
          required: [deferred, dispatched, notified]
          properties:
            deferred:
              type: boolean
            dispatched:
              type: boolean
            notified:
              type: boolean
        stamp:
          type: object
          required: [status, stamps_pending]
          properties:
            status:
              type: string
              enum: [queued, processing, completed, failed, cancelled, none]
            stamps_pending:
              type: boolean
        assigned_files:
          type: array
          items:
            type: object
            properties:
              asset_id:
                type: integer
              filename:
                type: string
              file_type:
                type: string
