openapi: 3.1.0
info:
  title: Qamera AI Plugin Integration API
  version: 1.0.0
  description: |
    REST API consumed by e-commerce plugins (Saleor, Shoper, PrestaShop, Shopify,
    and partners). All endpoints are scoped to the merchant's installation via
    an `X-Api-Key` header bound to a `plugin_installations` row.

    ### Authentication
    Send `X-Api-Key: <secret>` on every request. Keys are scoped to a single
    installation and one or more permission scopes (`plugin.jobs:write`,
    `plugin.jobs:read`, `plugin.assets:write`, `plugin.installations.manage`,
    `plugin.webhooks:manage`, `plugin.presets:read`, `plugin.catalog:read`,
    `plugin.catalog:write`).

    `plugin.catalog:read` gates read-only reference data
    (`/models`, `/sceneries`, `/ai-models`, `/aspect-ratios`, `/pricing`, and
    `/presets`). The `/presets` endpoint also accepts the legacy
    `plugin.jobs:read` scope so existing installations keep working without
    re-issuing keys; a one-shot data migration auto-grants
    `plugin.catalog:read` to every active installation.

    `plugin.catalog:write` gates mutations on the product catalog
    (`POST /images`, `POST /packshots`, `DELETE /products/{idOrRef}`,
    `DELETE /packshots/{idOrRef}`). A one-shot data migration auto-grants
    `plugin.catalog:write` to every active installation and active API key
    (same rollout pattern as `plugin.catalog:read`), so existing integrations
    can adopt the catalog mutations without re-issuing keys.

    ### Idempotency
    Mutations support `Idempotency-Key: <client-token>`. Tokens are scoped per
    installation, hashed against the canonical request payload (SHA-256), and
    replayable for 24h. Re-using a token with a different payload returns
    `idempotency_conflict`.

    ### Rate limiting
    Each API key has an installation-level `rate_limit_per_min` (default 60).
    Exceeding it returns 429 with `X-RateLimit-Remaining` and `Retry-After`.

    ### Error envelope
    All errors share the shape:
    ```json
    { "error": { "code": "<code>", "message_i18n": {"en": "..."}, "retryable": false, "doc_url": "https://qamera.ai/docs/plugin-api/errors/<code>" } }
    ```

    ### Versioning
    The major version is encoded in the URL (`/api/v1/plugin/...`). Breaking
    changes ship behind `/api/v2/...`. Additive changes (new fields, new error
    codes) are released without a version bump and announced in the changelog.
servers:
  - url: https://qamera.ai
    description: Production
security:
  - apiKey: []
tags:
  - name: jobs
    description: Submit, poll, list, and cancel generation jobs.
  - name: batch
    description: Submit multiple jobs in a single request.
  - name: assets
    description: Upload source assets for generation jobs.
  - name: presets
    description: Browse curated style presets.
  - name: catalog
    description: Reference data for session configurators (models, sceneries, AI models, aspect ratios, pricing).
  - name: account
    description: Read installation, plan, and balance metadata.
  - name: webhooks
    description: Inspect and replay webhook deliveries.
  - name: installations
    description: Manage plugin installation HMAC secret.
  - name: orders
    description: Inspect and clone sessions (cg_orders).
  - name: products
    description: Manage the per-installation product catalog (e-commerce shop products).
  - name: images
    description: Register raw product photos as inputs for future packshot generation.
  - name: packshots
    description: Register generation-ready packshots used as `subjects[].packshot_asset_id` in `/jobs`.
webhooks:
  job-status:
    post:
      tags: [webhooks]
      summary: Job status notification (job.completed / job.failed / job.cancelled)
      operationId: jobStatusWebhook
      description: |
        Sent to the plugin installation's configured callback URL whenever a
        job reaches a terminal state. Verify `X-Qamera-Signature` before
        trusting the payload; respond with any 2xx within the timeout to
        acknowledge. Failed deliveries are retried and can be replayed via
        `POST /webhooks/{delivery_id}/replay`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
      responses:
        '200':
          description: Acknowledged — any 2xx status counts as delivered.
paths:
  /api/v1/plugin/jobs:
    post:
      tags: [jobs]
      summary: Submit a single generation job
      operationId: submitJob
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitJobRequest'
      responses:
        '201':
          description: Session accepted — order + per-subject job ids
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubmitJobResponse' }
        '200':
          description: Idempotent replay of an existing session
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubmitJobResponse' }
        '400': { $ref: '#/components/responses/InvalidInput' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [jobs]
      summary: List jobs
      operationId: listJobs
      parameters:
        - in: query
          name: status
          description: Return only jobs in this status.
          schema: { $ref: '#/components/schemas/JobStatus' }
        - in: query
          name: created_after
          description: Return only jobs created at or after this timestamp (ISO 8601).
          schema: { type: string, format: date-time }
        - in: query
          name: created_before
          description: Return only jobs created before this timestamp (ISO 8601).
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          description: Page size (1–200).
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          description: Opaque pagination token from the previous page's `next_cursor`. Omit for the first page.
          schema: { type: string }
      responses:
        '200':
          description: Paginated job list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobsListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/jobs/{id}:
    parameters:
      - $ref: '#/components/parameters/JobId'
    get:
      tags: [jobs]
      summary: Get job by id
      operationId: getJob
      responses:
        '200':
          description: Job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [jobs]
      summary: Cancel a pending or in-progress job
      operationId: cancelJob
      responses:
        '200':
          description: Job after cancellation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/JobNotCancelable' }
  /api/v1/plugin/jobs/{id}/refresh-url:
    parameters:
      - $ref: '#/components/parameters/JobId'
    post:
      tags: [jobs]
      summary: Re-issue signed download URLs for completed job outputs
      operationId: refreshJobUrls
      responses:
        '200':
          description: Outputs with fresh signed URLs
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RefreshUrlResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/JobNotCompleted' }
        '410':
          description: Outputs purged from storage
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  /api/v1/plugin/jobs/{id}/accept:
    parameters:
      - $ref: '#/components/parameters/JobId'
    post:
      tags: [jobs]
      summary: Record an accept vote on a completed job
      description: |
        Pure metadata (D4). Voting requires the job to be in `completed`
        state. Cross-installation jobs return 404 (not 403) to avoid
        leaking existence.
      operationId: acceptJob
      responses:
        '204':
          description: Vote recorded
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/JobNotCompleted' }
  /api/v1/plugin/jobs/{id}/reject:
    parameters:
      - $ref: '#/components/parameters/JobId'
    post:
      tags: [jobs]
      summary: Record a reject vote on a completed job
      operationId: rejectJob
      responses:
        '204':
          description: Vote recorded
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/JobNotCompleted' }
  /api/v1/plugin/orders/{id}:
    parameters:
      - in: path
        name: id
        required: true
        description: Session (order) UUID — the `order_id` from the submit response.
        schema: { type: string, format: uuid }
    get:
      tags: [orders]
      summary: Get session (order) by id
      description: |
        Returns the full session envelope: `session_config`, per-subject
        job aggregates and outputs, and credit summary.
      operationId: getOrder
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderDto' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/plugin/orders/{id}/clone:
    parameters:
      - in: path
        name: id
        required: true
        description: Session (order) UUID to clone — the `order_id` of the source session.
        schema: { type: string, format: uuid }
      # Clone is the one endpoint that demands Idempotency-Key; the
      # parameter is inlined here with required: true rather than $ref-ing
      # the shared optional IdempotencyKey component used by the other
      # endpoints.
      - in: header
        name: Idempotency-Key
        required: true
        description: Required for clone — protects against accidental double-charge on retried POSTs.
        schema: { type: string, minLength: 1, maxLength: 200 }
    post:
      tags: [orders]
      summary: Clone a session into a new order
      description: |
        Empty body ⇒ reuse source subjects 1:1. Non-empty `subjects` ⇒
        full override keyed by `product_ref`. Idempotency-Key required.
        Voting is not carried over (clone is a fresh start).
      operationId: cloneOrder
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CloneOrderRequest' }
      responses:
        '201':
          description: New order created from clone
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderDto' }
        '200':
          description: Idempotent replay
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderDto' }
        '400': { $ref: '#/components/responses/InvalidInput' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
  /api/v1/plugin/jobs/batch:
    post:
      tags: [batch]
      summary: Submit multiple generation jobs in one request
      description: |
        Batch-level `Idempotency-Key` is **not** supported in v1; submit each
        job via `POST /jobs` with its own `Idempotency-Key` if you need
        replay-safe per-item retries.
      operationId: submitJobsBatch
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubmitJobsBatchRequest' }
      responses:
        '207':
          description: Multi-status — per-item accepted/failed result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchResponse' }
        '400': { $ref: '#/components/responses/InvalidInput' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413':
          description: Batch size exceeds limit
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/plugin/assets/upload:
    post:
      tags: [assets]
      summary: Upload an asset (multipart) or request a pre-signed upload URL
      operationId: uploadAsset
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PresignedAssetUploadRequest' }
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: Asset registered. For presigned mode, PUT bytes to upload_url before referring to the asset.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AssetUploadResponse' }
        '400': { $ref: '#/components/responses/InvalidInput' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413':
          description: File exceeds 50MB limit
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  /api/v1/plugin/presets:
    get:
      tags: [presets]
      summary: List published public presets
      description: |
        Accepts either `plugin.catalog:read` or `plugin.jobs:read` for backward
        compatibility with installations created before `plugin.catalog:read`
        was defined.
      operationId: listPresets
      responses:
        '200':
          description: Preset list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PresetsListResponse' }
              examples:
                default:
                  value:
                    presets:
                      - id: rec123
                        slug: fashion-flatlay
                        name: Fashion flatlay
                        description_i18n: { en: 'Flatlay product on neutral background' }
                        credit_cost: 10
                        output_type: packshot
                        is_free: false
                        cover_url: 'https://…/reference_assets/presets/rec123/cover.jpg'
                        quantity_guidelines: 'Provide 3-5 photos per product.'
                        quality_guidelines: 'Use natural daylight; avoid reflective surfaces.'
                        gallery:
                          - 'https://…/reference_assets/presets/rec123/g1.jpg'
                          - 'https://…/reference_assets/presets/rec123/g2.jpg'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/models:
    get:
      tags: [catalog]
      summary: List account and marketplace mannequin models
      description: |
        Unified list of account-owned and marketplace models filtered to
        approved + non-archived entries. Requires `plugin.catalog:read`.
      operationId: listModels
      responses:
        '200':
          description: Model list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelsListResponse' }
              examples:
                default:
                  value:
                    models:
                      - id: recAcct1
                        name: Brand Mannequin A
                        thumbnail: 'https://…/reference_assets/mannequin/recAcct1/large.jpg'
                        voting: APPROVED
                        status: DONE
                        source: account
                        created_at: '2026-05-10T12:00:00.000Z'
                      - id: recMkt1
                        name: Studio Female Pose
                        thumbnail: 'https://…/reference_assets/mannequin/recMkt1/large.jpg'
                        voting: APPROVED
                        status: DONE
                        source: marketplace
                        created_at: '2026-04-22T09:30:00.000Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/sceneries:
    get:
      tags: [catalog]
      summary: List account and marketplace sceneries
      description: |
        Unified list of account-owned and marketplace sceneries filtered to
        approved + non-archived entries. Requires `plugin.catalog:read`.
      operationId: listSceneries
      responses:
        '200':
          description: Scenery list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SceneriesListResponse' }
              examples:
                default:
                  value:
                    sceneries:
                      - id: recScn1
                        name: Marble Tabletop
                        thumbnail: 'https://…/reference_assets/scenery/recScn1/large.jpg'
                        voting: APPROVED
                        status: DONE
                        source: marketplace
                        created_at: '2026-04-15T11:10:00.000Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/ai-models:
    get:
      tags: [catalog]
      summary: List AI provider+model combinations available to the account
      description: |
        Filtered server-side by the calling account's subscription plan. The
        response intentionally omits any `min_plan` field — every entry is by
        definition usable by the caller. Cacheable for 5 minutes in the
        caller's private cache only (response is `Cache-Control: private`
        with `Vary: X-Api-Key`) so plan-gated catalogs never leak across
        accounts via shared/CDN/proxy caches.
      operationId: listAiModels
      responses:
        '200':
          description: AI model list
          headers:
            Cache-Control:
              schema: { type: string }
              description: '`private, max-age=300`'
            Vary:
              schema: { type: string }
              description: '`X-Api-Key`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AiModelsListResponse' }
              examples:
                default:
                  value:
                    ai_models:
                      - id: byteplus/seedream-4.5
                        provider: byteplus
                        model: seedream-4.5
                        output_type: image
                        supported_aspect_ratios: ['9:16','16:9','1:1','4:3','3:4','2:3','3:2','21:9','match_input']
                        base_credit_cost: 10
                      - id: pollo/kling-2.6
                        provider: pollo
                        model: kling-2.6
                        output_type: video
                        supported_aspect_ratios: ['9:16','16:9','1:1']
                        base_credit_cost: 128
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/aspect-ratios:
    get:
      tags: [catalog]
      summary: List supported aspect ratios with default flag
      description: |
        Static enum of aspect ratios supported across AI models, with exactly
        one entry flagged `default: true`. Aggressively cacheable.
      operationId: listAspectRatios
      responses:
        '200':
          description: Aspect ratio list
          headers:
            Cache-Control:
              schema: { type: string }
              description: '`public, max-age=3600`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AspectRatiosListResponse' }
              examples:
                default:
                  value:
                    aspect_ratios:
                      - { value: '1:1', label: Square, default: false }
                      - { value: '4:5', label: Portrait, default: true }
                      - { value: '9:16', label: 'Story/Reel', default: false }
                      - { value: '16:9', label: Landscape, default: false }
                      - { value: '3:4', label: 'Classic Portrait', default: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/pricing:
    get:
      tags: [catalog]
      summary: Credit cost per (job_type, provider, model)
      description: |
        Flat table the plugin uses to quote total session cost client-side.
        `currency` is always the literal `credits`. Cacheable for 5 minutes.
      operationId: listPricing
      responses:
        '200':
          description: Pricing list
          headers:
            Cache-Control:
              schema: { type: string }
              description: '`public, max-age=300`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PricingListResponse' }
              examples:
                default:
                  value:
                    currency: credits
                    pricing:
                      - { job_type: photo_shoot, provider: byteplus, model: seedream-4.5, credit_cost: 10 }
                      - { job_type: video, provider: pollo, model: '*', credit_cost: 100 }
                      - { job_type: video, provider: pollo, model: 'kling-2.6', credit_cost: 128 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/plugin/me:
    get:
      tags: [account]
      summary: Account, plan, balance, installation, and sub-processor disclosure
      operationId: getMe
      responses:
        '200':
          description: Account context
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /api/v1/plugin/webhooks/{delivery_id}/replay:
    parameters:
      - in: path
        name: delivery_id
        required: true
        description: Webhook delivery UUID — sent as the `X-Qamera-Request-Id` header on the original delivery.
        schema: { type: string, format: uuid }
    post:
      tags: [webhooks]
      summary: Replay a failed or abandoned webhook delivery
      operationId: replayWebhook
      responses:
        '202':
          description: Replay queued
          content:
            application/json:
              schema:
                type: object
                required: [new_delivery_id, replay_of_id, status]
                properties:
                  new_delivery_id:
                    type: string
                    format: uuid
                    description: Id of the queued re-delivery (it will arrive with this value in `X-Qamera-Request-Id`).
                  replay_of_id:
                    type: string
                    format: uuid
                    description: The failed/abandoned delivery being replayed.
                  status:
                    type: string
                    enum: [pending]
                    description: The new delivery starts queued; it is dispatched asynchronously.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Source delivery is not in a replayable state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  /api/v1/plugin/installations/{id}/rotate-hmac:
    parameters:
      - in: path
        name: id
        required: true
        description: Plugin installation UUID — `installation.id` from `GET /me`. Must be the caller's own installation (anything else returns 404).
        schema: { type: string, format: uuid }
    post:
      tags: [installations]
      summary: Rotate the installation webhook HMAC secret (48h dual-sign window)
      operationId: rotateHmac
      responses:
        '200':
          description: New secret revealed once
          content:
            application/json:
              schema:
                type: object
                required: [installation_id, webhook_hmac_secret, rotated_at, grace_window_hours]
                properties:
                  installation_id: { type: string, format: uuid }
                  webhook_hmac_secret: { type: string }
                  rotated_at: { type: string, format: date-time }
                  grace_window_hours:
                    type: integer
                    description: |
                      Duration of the dual-sign window during which
                      webhooks are signed with both the old and the new
                      secret. Currently always 48.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/plugin/images:
    post:
      tags: [images]
      summary: Register raw product photos
      description: |
        Bulk-register source product photos. Idempotent per
        `(installation, external_ref)` — a repeat call with the same
        `external_ref` returns `status: "existing"`. Auto-creates the
        product when `product_metadata` is provided and the
        `product_ref` is new. Requires `plugin.catalog:write`.

        The `asset_id` must reference a `plugin_processed_assets` row in
        `status: ready` for the calling installation; the route reads
        sha256/byte_size/width/height from that ledger.
      operationId: registerImages
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterImagesBody' }
            examples:
              default:
                value:
                  images:
                    - external_ref: 'shop_1:image_42'
                      product_ref: 'shop_1:product_7'
                      product_metadata:
                        display_name: 'Linen Shirt — Beige'
                        sku: 'LIN-BG-M'
                      asset_id: 9a3b8f4c-2e7a-4c1b-8d0a-1f6c2e9b3d51
      responses:
        '200':
          description: Per-item results
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RegisterImagesResponse' }
              examples:
                default:
                  value:
                    results:
                      - external_ref: 'shop_1:image_42'
                        product_id: 5e1f8a2c-9b4d-4a3e-8f7c-0d2a6e8b1c34
                        image_id: a1b2c3d4-5e6f-7890-abcd-ef0123456789
                        status: created
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '400': { $ref: '#/components/responses/InvalidInput' }
  /api/v1/plugin/packshots:
    post:
      tags: [packshots]
      summary: Register generation-ready packshots
      description: |
        Bulk-register packshots. Same idempotency + cascading
        product-creation semantics as `POST /images`. Optional
        `source_image_ref` links the packshot back to the source
        `product_images.external_ref` it was derived from. Requires
        `plugin.catalog:write`.

        When `source_image_ref` is omitted, the service auto-creates a
        backing `product_images` row that references the same processed
        asset and enqueues it for image analysis. Identical content
        uploaded twice on a single installation is deduplicated by
        SHA-256 and reuses the existing image row without re-analyzing.
        The synthetic image rows carry an `external_ref` suffixed with
        `__autoimage` so they remain queryable.
      operationId: registerPackshots
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterPackshotsBody' }
            examples:
              default:
                value:
                  packshots:
                    - external_ref: 'shop_1:packshot_001'
                      product_ref: 'shop_1:product_7'
                      source_image_ref: 'shop_1:image_42'
                      asset_id: f1d2e3a4-5b6c-7d8e-9f01-23456789abcd
      responses:
        '200':
          description: Per-item results
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RegisterPackshotsResponse' }
              examples:
                default:
                  value:
                    results:
                      - external_ref: 'shop_1:packshot_001'
                        product_id: 5e1f8a2c-9b4d-4a3e-8f7c-0d2a6e8b1c34
                        packshot_id: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d
                        status: created
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '400': { $ref: '#/components/responses/InvalidInput' }
    get:
      tags: [packshots]
      summary: List packshots
      description: |
        Cursor-paginated listing of packshots for the calling
        installation. Filter by `product_ref` to fetch packshots for a
        single product. Requires `plugin.catalog:read`.
      operationId: listPackshots
      parameters:
        - in: query
          name: product_ref
          description: Return only packshots of the product with this `external_ref`.
          schema: { type: string, minLength: 1, maxLength: 200 }
        - in: query
          name: cursor
          description: Opaque pagination token from the previous page's `next_cursor`. Omit for the first page.
          schema: { type: string }
        - in: query
          name: limit
          description: Page size (1–200).
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Packshot list page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PackshotListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '400': { $ref: '#/components/responses/InvalidInput' }
  /api/v1/plugin/packshots/{idOrRef}:
    delete:
      tags: [packshots]
      summary: Hard-delete a packshot
      description: |
        Removes the packshot row. The underlying `plugin_assets` bucket
        file is left in place — the same content may still be referenced
        by other catalog rows or historical `cg_jobs` outputs. Requires
        `plugin.catalog:write`.
      operationId: deletePackshot
      parameters:
        - $ref: '#/components/parameters/IdOrRef'
      responses:
        '204':
          description: Packshot deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/plugin/products:
    get:
      tags: [products]
      summary: List products
      description: |
        Cursor-paginated listing of the installation's products with
        per-product `image_count` and `packshot_count`. Soft-deleted
        products are excluded unless `include_deleted=true`. Requires
        `plugin.catalog:read`.
      operationId: listProducts
      parameters:
        - in: query
          name: cursor
          description: Opaque pagination token from the previous page's `next_cursor`. Omit for the first page.
          schema: { type: string }
        - in: query
          name: limit
          description: Page size (1–200).
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: ref
          description: Exact `external_ref` filter.
          schema: { type: string, maxLength: 200 }
        - in: query
          name: include_deleted
          description: When `true`, soft-deleted products are included in the listing (their `deleted_at` is non-null).
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Product list page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '400': { $ref: '#/components/responses/InvalidInput' }
  /api/v1/plugin/products/{idOrRef}:
    get:
      tags: [products]
      summary: Get product with embedded images and packshots
      description: |
        Returns the product plus up to 100 child images and 100 child
        packshots inline. When the child set exceeds 100 rows, the
        `images_truncated` / `packshots_truncated` flags signal that the
        caller should fall back to the dedicated `GET /packshots` listing
        (or future `GET /images`). Requires `plugin.catalog:read`.
      operationId: getProduct
      parameters:
        - $ref: '#/components/parameters/IdOrRef'
      responses:
        '200':
          description: Product detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductDetailResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [products]
      summary: Soft-delete a product
      description: |
        Marks the product as deleted by setting `deleted_at = now()`.
        Child `product_images` and `product_packshots` rows are NOT
        cascaded — they remain queryable. A repeat call on an
        already-deleted product returns 404. Requires
        `plugin.catalog:write`.
      operationId: deleteProduct
      parameters:
        - $ref: '#/components/parameters/IdOrRef'
      responses:
        '204':
          description: Product soft-deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        Installation-scoped secret. Treat as a password. Effective scopes for
        a request are `api_keys.scopes` intersected with
        `plugin_installations.scopes`. Known scopes:
        `plugin.jobs:read`, `plugin.jobs:create`, `plugin.jobs:cancel`,
        `plugin.assets:upload`, `plugin.installations.manage`,
        `plugin.webhooks:manage`, `plugin.catalog:read`,
        `plugin.catalog:write`.
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      description: Client-supplied token for safe retry. Replayable for 24h; payload-hash validated.
      schema: { type: string, minLength: 1, maxLength: 200 }
    JobId:
      in: path
      name: id
      required: true
      description: Job UUID — from `subjects[].job_ids` in the submit response, `GET /jobs` listing, or the webhook payload's `job.id`.
      schema: { type: string, format: uuid }
    IdOrRef:
      in: path
      name: idOrRef
      required: true
      description: |
        UUID of the resource OR the client-supplied `external_ref` that
        was used at registration. Detection is automatic: anything
        matching the standard UUID format resolves by id, otherwise by
        `(installation_id, external_ref)`.
      schema: { type: string, minLength: 1, maxLength: 200 }
  responses:
    InvalidInput:
      description: Validation error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid X-Api-Key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: API key lacks required scope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource not found or out of installation scope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    QuotaExceeded:
      description: Account credits exhausted
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    IdempotencyConflict:
      description: Same Idempotency-Key reused with a different payload
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    JobNotCancelable:
      description: Job is in a terminal state and cannot be cancelled
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    JobNotCompleted:
      description: Job is not in `completed` state
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Per-installation rate limit exceeded
      headers:
        X-RateLimit-Remaining:
          schema: { type: integer }
        Retry-After:
          schema: { type: integer, description: Seconds }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  schemas:
    PluginPlatform:
      type: string
      enum: [saleor, shoper, prestashop, shopify, erli, wix, shopware, mirakl, other]
      description: E-commerce platform this plugin installation integrates with.
    JobStatus:
      type: string
      enum: [pending, in_progress, completed, failed, retry_pending, cancelled, expired]
      description: |
        Job lifecycle. `pending` — queued, not started; `in_progress` —
        generation running; `retry_pending` — an attempt failed and will be
        retried automatically. Terminal states: `completed` (outputs ready
        for download), `failed` (no more retries, reserved credits
        refunded), `cancelled` (stopped via `DELETE /jobs/{id}`),
        `expired` (aged out before processing).
    Voting:
      type: string
      enum: [accepted, rejected]
      description: |
        Merchant rating recorded via `POST /jobs/{id}/accept` /
        `/reject`. For photo-shoot jobs it is feedback metadata only; for
        packshot jobs, accepting also approves the generated packshot for
        the product (unblocking photo sessions). `null` until the first
        vote.
    PackshotVoting:
      type: string
      enum: [pending, accepted, rejected]
      description: |
        Packshot acceptance state. Only packshots with `accepted` satisfy
        the photo-shoot acceptance gate on `POST /jobs` (a photo-shoot
        subject without an accepted packshot fails with HTTP 422
        `packshot_not_approved`). Packshots registered directly via
        `POST /packshots` or auto-registered from a packshot job are
        auto-accepted on creation. Distinct from the job-level `Voting`
        enum, which has no `pending` state.
    SessionConfig:
      type: object
      description: |
        Shared configuration applied to every subject in the session. One
        session = one shared model/scenery/preset/aspect-ratio/suggestions.
      properties:
        model_id:
          type: ['string', 'null']
          format: uuid
          description: Mannequin model to feature in the photos. Pick an `id` from `GET /models`; omit or `null` for no model.
        scenery_id:
          type: ['string', 'null']
          format: uuid
          description: Background scenery for the photos. Pick an `id` from `GET /sceneries`; omit or `null` for a neutral setting.
        preset_id:
          type: ['string', 'null']
          description: Curated style preset applied to the session. Pick an `id` from `GET /presets`; omit or `null` for no preset.
        aspect_ratio:
          type: string
          enum: ['1:1', '4:5', '9:16', '16:9', '3:4']
          default: '4:5'
          description: Output image proportions. Allowed values mirror `GET /aspect-ratios`; defaults to `4:5` when omitted. Must be supported by each subject's `ai_model` (see `supported_aspect_ratios` in `GET /ai-models`).
        suggestions:
          type: string
          maxLength: 2000
          description: Free-text creative guidance for the whole session (e.g. "soft natural light, warm tones"). Fed into prompt construction; plain language, max 2000 characters.
    Subject:
      type: object
      required: [product_label, product_ref, images_count, ai_model]
      properties:
        packshot_asset_id:
          type: string
          format: uuid
          description: |
            For `photo_shoot`: optional. If omitted, the backend resolves
            the latest accepted packshot for the subject's `product_ref`.
            If provided, it must reference an accepted `product_packshots`
            row for the SAME product. Either way, a photo-shoot subject
            with no accepted packshot returns HTTP 422 `packshot_not_approved`.
            For `packshot`: always required (identifies the raw input asset).
        product_label:
          type: string
          minLength: 1
          maxLength: 200
          description: Human-readable product name shown in the dashboard and echoed in job DTOs and webhook payloads. Display-only — has no matching semantics.
        product_ref:
          type: string
          minLength: 1
          maxLength: 200
          description: Your stable product identifier (the catalog product's `external_ref`). Used to resolve the accepted packshot for photo-shoot sessions, to group jobs back into subjects, and to key clone overrides. Must be unique across `subjects[]` within one session — duplicates are rejected with `invalid_input`.
        images_count:
          type: integer
          minimum: 1
          maximum: 50
          description: How many images to generate for this product. The server expands the subject into this many jobs; credits are reserved for all of them at submission time.
        ai_model:
          type: string
          pattern: '^[a-z0-9_-]+/[a-z0-9._-]+$'
          description: '`"provider/model"` pair from `GET /ai-models` (the `id` field) — split server-side; pricing is resolved at submission time.'
        reference_asset_ids:
          type: array
          items: { type: string, format: uuid }
          description: Optional supporting images (`asset_id`s from `POST /assets/upload`) passed to the AI provider alongside the main packshot, e.g. extra angles of the same product.
        provider_settings:
          type: object
          additionalProperties: true
          description: Optional free-form options forwarded verbatim to the AI provider. Keys depend on the chosen `ai_model`; leave empty unless instructed otherwise.
        product_name:
          type: string
          deprecated: true
          description: 'DEPRECATED: product metadata is now derived automatically from catalog image analysis. Still accepted, but omit it in new integrations.'
        product_specific_category:
          type: string
          deprecated: true
          description: 'DEPRECATED: derived automatically from catalog image analysis. Still accepted, but omit it in new integrations.'
        product_side:
          type: string
          deprecated: true
          description: 'DEPRECATED: derived automatically from catalog image analysis. Still accepted, but omit it in new integrations.'
        product_general_category:
          type: string
          deprecated: true
          description: 'DEPRECATED: derived automatically from catalog image analysis. Still accepted, but omit it in new integrations.'
        auto_register_packshot:
          type: boolean
          description: |
            Catalog write-back opt-in: when `true` AND `product_ref` resolves
            to a catalog product, the generated packshot is inserted into the
            product catalog on success. REQUIRED (`true`) on every subject
            when the session's `job_type` is `packshot` — the generated
            packshot must land in the catalog so the photo-shoot acceptance
            gate can reference it. Default `false`.
        packshot_external_ref:
          type: string
          maxLength: 200
          description: |
            Stable client-side identifier for the auto-registered packshot
            row. Recommended whenever `auto_register_packshot=true`; when
            omitted the row is created with `external_ref=null` (still
            queryable by uuid).
    SubmitJobRequest:
      type: object
      required: [session_config, subjects]
      description: |
        Submit a session (one shared `session_config` applied to N subjects).
        **BREAKING (2026-05-22)**: replaces the legacy
        `{job_type, provider, model, unit_cost}` shape.
        **add-plugin-packshot-acceptance-gate (2026-05-28)**: optional
        top-level `job_type` selects between `photo_shoot` (default) and
        `packshot`. For `packshot`, every subject MUST set
        `auto_register_packshot=true` and `packshot_asset_id`. For
        `photo_shoot`, `packshot_asset_id` is optional — the backend
        resolves the most recent accepted packshot from `product_ref` when
        the field is omitted.
      properties:
        job_type:
          type: string
          enum: [photo_shoot, scenery, model, packshot, video, reel]
          description: |
            Optional. Defaults to `photo_shoot` when omitted. Plugin v1
            implements differentiated behavior only for `photo_shoot`
            (acceptance gate) and `packshot` (catalog write-back); other
            values are accepted for forward-compat but currently behave
            like `photo_shoot`.
        session_config: { $ref: '#/components/schemas/SessionConfig' }
        subjects:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/Subject' }
        callback_url:
          type: ['string', 'null']
          format: uri
          description: |
            Informational only — echoed back in the webhook payload's
            `callback_url` field so your handler can correlate the session.
            It does NOT change where webhooks are delivered: deliveries
            always go to the callback URL configured on the plugin
            installation.
        external_metadata:
          type: ['object', 'null']
          additionalProperties: true
          description: |
            Opaque integrator payload round-tripped byte-for-byte on job
            DTOs and webhook deliveries. The serialized JSON must not
            exceed 4096 bytes — larger payloads are rejected with
            HTTP 400 `invalid_input`.
        priority:
          type: integer
          minimum: -100
          maximum: 100
          default: 0
          description: Scheduling hint recorded on the order. Accepted and persisted, but NOT enforced by the v1 queue — all jobs are currently processed with equal priority.
    SubmitJobResponseSubject:
      type: object
      required: [product_ref, job_ids]
      properties:
        product_ref:
          type: string
          description: Echo of the subject's `product_ref` — match it against your request to pair ids with products.
        job_ids:
          type: array
          minItems: 1
          items: { type: string, format: uuid }
          description: Ids of the jobs created for this product — one per requested image (`images_count` entries). Use them with `GET /jobs/{id}` and the vote endpoints.
    SubmitJobResponse:
      type: object
      required: [order_id, status, subjects]
      properties:
        order_id:
          type: string
          format: uuid
          description: Id of the created session (order). Use it with `GET /orders/{id}` and `POST /orders/{id}/clone`.
        status:
          type: string
          description: Session status — `pending` on a fresh submission; on an idempotent replay (HTTP 200) it reflects the session's current `OrderStatus`.
        subjects:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/SubmitJobResponseSubject' }
          description: Per-product job ids, in no guaranteed order — pair by `product_ref`.
    SubmitJobsBatchRequest:
      type: object
      required: [batches]
      description: |
        Submit multiple sessions in one request. Ceiling on batch size
        (`PLUGIN_API_BATCH_LIMIT`, default 100) and on the total number of
        expanded jobs (`PLUGIN_API_TOTAL_JOBS_LIMIT`, default 5000).
      properties:
        batches:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/SubmitJobRequest' }
    JobOutput:
      type: object
      required: [url, type]
      properties:
        url:
          type: string
          format: uri
          description: Signed download URL for the generated file, valid for at least 7 days. Get a fresh one with `POST /jobs/{id}/refresh-url`.
        type:
          type: string
          description: MIME type of the file derived from its extension, e.g. `image/jpeg`, `image/png`, `image/webp`, `video/mp4`.
        width:
          type: integer
          minimum: 1
          description: Pixel width, when known.
        height:
          type: integer
          minimum: 1
          description: Pixel height, when known.
        size_bytes:
          type: integer
          minimum: 0
          description: File size in bytes, when known.
    Job:
      type: object
      required:
        [id, order_id, status, job_type, provider, model, unit_cost, attempt_count, outputs, error, external_metadata, packshot_asset_id, product_label, product_ref, voting, voting_at, created_at, updated_at, completed_at]
      properties:
        id:
          type: string
          format: uuid
          description: Job id — the same value returned in `subjects[].job_ids` at submission.
        order_id:
          type: ['string', 'null']
          format: uuid
          description: Id of the session (order) this job belongs to. `null` only for legacy pre-session jobs.
        status: { $ref: '#/components/schemas/JobStatus' }
        job_type:
          type: string
          description: The session's `job_type` this job was created under, e.g. `photo_shoot` or `packshot`.
        provider:
          type: string
          description: AI provider half of the subject's `ai_model` (`"provider/model"`), split server-side.
        model:
          type: string
          description: Model half of the subject's `ai_model` (`"provider/model"`), split server-side.
        unit_cost:
          type: integer
          description: Credits charged for this single job (one output image), resolved from pricing at submission time.
        attempt_count:
          type: integer
          minimum: 0
          description: How many generation attempts have run, including automatic retries.
        outputs:
          type: array
          items: { $ref: '#/components/schemas/JobOutput' }
          description: Download links for the generated files. Empty until the job is `completed` (and may be empty transiently if URL signing fails — retry the GET).
        error:
          description: 'Last generation error, or `null` when none occurred. `retryable: true` means an automatic retry is scheduled (`retry_pending`).'
          oneOf:
            - $ref: '#/components/schemas/Error'
            - type: 'null'
        external_metadata:
          type: ['object', 'null']
          additionalProperties: true
          description: Your correlation payload from the submission, echoed verbatim (shared by all jobs of the session).
        # Session-lifecycle (2026-05-22) — subject context mirrored from
        # cg_jobs.settings for client-side grouping.
        packshot_asset_id:
          type: ['string', 'null']
          format: uuid
          description: The packshot this job generated from (explicit or server-resolved). Subject context — `null` on legacy jobs.
        product_label:
          type: ['string', 'null']
          description: Subject's display name, echoed for client-side grouping.
        product_ref:
          type: ['string', 'null']
          description: Subject's product identifier — group jobs of one product by this value.
        voting:
          description: Merchant rating of this job (`null` until voted). See the `Voting` schema for packshot-approval side effects.
          oneOf:
            - $ref: '#/components/schemas/Voting'
            - type: 'null'
        voting_at:
          type: ['string', 'null']
          format: date-time
          description: When the rating was recorded; `null` until voted.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        completed_at:
          type: ['string', 'null']
          format: date-time
          description: When generation finished; `null` while the job is still active.
    OrderSubjectOutput:
      allOf:
        - $ref: '#/components/schemas/JobOutput'
        - type: object
          required: [job_id]
          properties:
            job_id:
              type: string
              format: uuid
              description: Which of the subject's jobs produced this file.
    OrderSubject:
      type: object
      description: Per-product aggregate within the session, grouped by `product_ref`.
      required: [packshot_asset_id, product_label, product_ref, jobs_total, jobs_completed, jobs_failed, outputs]
      properties:
        packshot_asset_id:
          type: ['string', 'null']
          format: uuid
          description: The packshot the subject's jobs generated from; `null` on legacy orders.
        product_label:
          type: ['string', 'null']
          description: Subject's display name, as submitted.
        product_ref:
          type: ['string', 'null']
          description: Subject's product identifier, as submitted.
        jobs_total:
          type: integer
          minimum: 0
          description: Jobs created for this product (= requested `images_count`).
        jobs_completed:
          type: integer
          minimum: 0
          description: Jobs finished successfully so far.
        jobs_failed:
          type: integer
          minimum: 0
          description: Jobs that ended in failure (their credits are refunded).
        outputs:
          type: array
          items: { $ref: '#/components/schemas/OrderSubjectOutput' }
          description: Download links for all of the subject's finished images, each tagged with the producing `job_id`.
    OrderSummary:
      type: object
      description: Credit accounting for the whole session.
      required: [credits_consumed, credits_refunded]
      properties:
        credits_consumed:
          type: integer
          minimum: 0
          description: Credits charged for the session's jobs.
        credits_refunded:
          type: integer
          minimum: 0
          description: Credits returned for failed or cancelled jobs.
    OrderStatus:
      type: string
      enum: [pending, in_progress, completed, cancelled, failed]
      description: |
        Session (order) lifecycle, aggregated from its jobs: `completed`
        when every job completed; `cancelled` when every job was
        cancelled; `failed` when no jobs remain active but outcomes are
        mixed or failed.
    OrderDto:
      type: object
      required: [order_id, status, session_config, subjects, summary, external_metadata, created_at, updated_at]
      properties:
        order_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        session_config: { $ref: '#/components/schemas/SessionConfig' }
        subjects:
          type: array
          items: { $ref: '#/components/schemas/OrderSubject' }
        summary: { $ref: '#/components/schemas/OrderSummary' }
        external_metadata:
          type: ['object', 'null']
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CloneOrderRequest:
      type: object
      description: |
        Empty body ⇒ reuse source subjects. Non-empty `subjects` ⇒ full
        override keyed by `product_ref`.
      properties:
        subjects:
          type: array
          items:
            type: object
            required: [product_ref, images_count]
            properties:
              product_ref:
                type: string
                minLength: 1
                maxLength: 200
                description: Must match a `product_ref` present in the source order — identifies which source subject this override applies to.
              images_count:
                type: integer
                minimum: 1
                maximum: 50
                description: Overrides the number of images to generate for that product in the cloned session.
    JobsListResponse:
      type: object
      required: [jobs, next_cursor]
      properties:
        jobs:
          type: array
          items: { $ref: '#/components/schemas/Job' }
        next_cursor:
          type: ['string', 'null']
          description: Opaque cursor for the next page; `null` when no more rows.
    BatchItemResult:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
          minimum: 0
          description: Zero-based position of this result in your `batches[]` array — items are processed and reported independently.
        status:
          type: string
          enum: [accepted, failed]
          description: '`accepted` — the session was created (`result` present); `failed` — it was rejected (`error` present); other items are unaffected.'
        result:
          $ref: '#/components/schemas/SubmitJobResponse'
          description: Present only when `status` is `accepted`.
        error:
          $ref: '#/components/schemas/Error'
          description: Present only when `status` is `failed`.
    BatchResponse:
      type: object
      required: [results, accepted_count, failed_count]
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/BatchItemResult' }
          description: One entry per submitted batch item, in the same order as your `batches[]`.
        accepted_count:
          type: integer
          minimum: 0
          description: Number of sessions created.
        failed_count:
          type: integer
          minimum: 0
          description: Number of rejected items — inspect their `error` and resubmit only those.
    PresignedAssetUploadRequest:
      type: object
      required: [mode, filename, content_type, size_bytes]
      properties:
        mode:
          type: string
          enum: [presigned]
          description: Selects the pre-signed flow (the JSON body's only mode). For direct upload, send `multipart/form-data` instead of JSON.
        filename:
          type: string
          minLength: 1
          maxLength: 256
          description: Original file name (used to derive the storage path and extension), e.g. `mug-photo.jpg`.
        content_type:
          type: string
          minLength: 1
          description: MIME type of the file you will PUT to `upload_url`, e.g. `image/jpeg`.
        size_bytes:
          type: integer
          minimum: 1
          maximum: 52428800
          description: Declared file size in bytes; max 50 MB (52428800).
    AssetUploadResponse:
      type: object
      required: [asset_id, bucket, storage_path, upload_url, upload_token, expires_at]
      properties:
        asset_id:
          type: string
          description: Identifier of the uploaded file. Reference it later as `asset_id` in `POST /images` / `POST /packshots` and as `packshot_asset_id` / `reference_asset_ids` in `POST /jobs`.
        bucket:
          type: string
          description: Storage bucket the file lives in (informational).
        storage_path:
          type: string
          description: Path of the file within the bucket (informational).
        upload_url:
          type: ['string', 'null']
          format: uri
          description: Pre-signed target — `PUT` the raw file bytes here (with your declared `Content-Type`) before the URL expires. `null` in multipart mode, where the bytes were already received.
        upload_token:
          type: ['string', 'null']
          description: Token bound to `upload_url`; already embedded in the URL. `null` in multipart mode.
        expires_at:
          type: ['string', 'null']
          format: date-time
          description: Deadline for completing the `PUT` (2 hours from issuing). `null` in multipart mode.
    Preset:
      type: object
      required:
        [id, slug, name, description_i18n, credit_cost, output_type, is_free, cover_url, quantity_guidelines, quality_guidelines, gallery]
      properties:
        id:
          type: string
          description: Preset identifier — pass it as `session_config.preset_id` in `POST /jobs`.
        slug:
          type: ['string', 'null']
          description: URL-friendly handle of the preset, when defined.
        name:
          type: string
          description: Display name of the style.
        description_i18n:
          type: object
          additionalProperties: { type: string }
          description: Style description keyed by language code (at least `en`).
        credit_cost:
          type: integer
          minimum: 0
          description: Credits per image generated with this preset.
        output_type:
          type: ['string', 'null']
          description: What the preset produces (e.g. `packshot`), when categorized.
        is_free:
          type: boolean
          description: '`true` when the preset does not consume credits.'
        cover_url:
          type: ['string', 'null']
          format: uri
          description: Public cover image of the style, when available.
        quantity_guidelines:
          type: string
          description: Plain-text guidance on recommended quantity of source assets. May be empty.
        quality_guidelines:
          type: string
          description: Plain-text guidance on source-asset quality. May be empty.
        gallery:
          type: array
          description: Public preview image URLs. May be empty.
          items: { type: string, format: uri }
    PresetsListResponse:
      type: object
      required: [presets]
      properties:
        presets:
          type: array
          items: { $ref: '#/components/schemas/Preset' }
    CatalogSource:
      type: string
      enum: [account, marketplace]
      description: '`account` — created by/for this account; `marketplace` — shared catalog entry available to all accounts.'
    Model:
      type: object
      required: [id, name, thumbnail, voting, status, source, created_at]
      properties:
        id:
          type: string
          description: Model identifier — pass it as `session_config.model_id` in `POST /jobs`.
        name:
          type: string
          description: Display name of the mannequin model.
        thumbnail:
          type: ['string', 'null']
          format: uri
          description: Public preview image, when available.
        voting:
          type: ['string', 'null']
          description: Internal review state (e.g. `APPROVED`); the listing only returns entries usable for generation.
        status:
          type: ['string', 'null']
          description: Internal processing state (e.g. `DONE`); informational.
        source: { $ref: '#/components/schemas/CatalogSource' }
        created_at: { type: string }
    ModelsListResponse:
      type: object
      required: [models]
      properties:
        models:
          type: array
          items: { $ref: '#/components/schemas/Model' }
    Scenery:
      type: object
      required: [id, name, thumbnail, voting, status, source, created_at]
      properties:
        id:
          type: string
          description: Scenery identifier — pass it as `session_config.scenery_id` in `POST /jobs`.
        name:
          type: string
          description: Display name of the scenery.
        thumbnail:
          type: ['string', 'null']
          format: uri
          description: Public preview image, when available.
        voting:
          type: ['string', 'null']
          description: Internal review state (e.g. `APPROVED`); the listing only returns entries usable for generation.
        status:
          type: ['string', 'null']
          description: Internal processing state (e.g. `DONE`); informational.
        source: { $ref: '#/components/schemas/CatalogSource' }
        created_at: { type: string }
    SceneriesListResponse:
      type: object
      required: [sceneries]
      properties:
        sceneries:
          type: array
          items: { $ref: '#/components/schemas/Scenery' }
    AiModelOutputType:
      type: string
      enum: [image, video]
    AiModel:
      type: object
      required: [id, provider, model, output_type, supported_aspect_ratios, base_credit_cost]
      properties:
        id:
          type: string
          description: "Composite identifier `<provider>/<model>` matching POST /jobs."
        provider:
          type: string
          description: Provider half of the composite `id`.
        model:
          type: string
          description: Model half of the composite `id`.
        output_type: { $ref: '#/components/schemas/AiModelOutputType' }
        supported_aspect_ratios:
          type: array
          items: { type: string }
          description: Aspect ratios this model can produce — `session_config.aspect_ratio` must be one of them (or `match_input` where listed).
        base_credit_cost:
          type: integer
          minimum: 0
          description: Credits per image/video generated with this model; consistent with `GET /pricing`.
    AiModelsListResponse:
      type: object
      required: [ai_models]
      properties:
        ai_models:
          type: array
          items: { $ref: '#/components/schemas/AiModel' }
    AspectRatio:
      type: object
      required: [value, label, default]
      properties:
        value: { type: string }
        label: { type: string }
        default: { type: boolean }
    AspectRatiosListResponse:
      type: object
      required: [aspect_ratios]
      properties:
        aspect_ratios:
          type: array
          items: { $ref: '#/components/schemas/AspectRatio' }
    PricingEntry:
      type: object
      required: [job_type, provider, model, credit_cost]
      properties:
        job_type:
          type: string
          description: Generation type the price applies to, e.g. `photo_shoot`, `packshot`, `video`.
        provider:
          type: string
          description: AI provider the price applies to.
        model:
          type: string
          description: "Specific model id, or `*` for provider-wide fallback price."
        credit_cost:
          type: integer
          minimum: 0
          description: Credits per single output (one image/video). Multiply by `images_count` to quote a subject.
    PricingListResponse:
      type: object
      required: [pricing, currency]
      properties:
        pricing:
          type: array
          items: { $ref: '#/components/schemas/PricingEntry' }
        currency:
          type: string
          enum: [credits]
    DataProcessor:
      type: object
      description: Third party that processes merchant data on Qamera AI's behalf (GDPR sub-processor disclosure).
      required: [name, purpose, region, transfer_mechanism]
      properties:
        name:
          type: string
          description: Sub-processor's name.
        purpose:
          type: string
          description: What the sub-processor is used for.
        region:
          type: string
          description: Where the data is processed.
        transfer_mechanism:
          type: ['string', 'null']
          description: Legal basis for data transfer outside the EEA (e.g. SCC); `null` when not applicable.
    Installation:
      type: object
      required: [id, platform, status, scopes]
      properties:
        id:
          type: string
          format: uuid
          description: Installation id — use it in `POST /installations/{id}/rotate-hmac`.
        platform: { $ref: '#/components/schemas/PluginPlatform' }
        status:
          type: string
          enum: [active, suspended, uninstalled]
          description: Only `active` installations can call the API.
        scopes:
          type: array
          items: { type: string }
          description: Permissions granted to the installation. Effective request permissions are these intersected with the API key's own scopes.
    MeResponse:
      type: object
      required:
        [account_id, account_name, account_slug, credits_balance, subscription_plan, rate_limit_per_min, data_processors, installation]
      properties:
        account_id:
          type: string
          format: uuid
          description: Id of the merchant account this installation belongs to.
        account_name:
          type: string
          description: Merchant account's display name.
        account_slug:
          type: ['string', 'null']
          description: Account's URL slug, when set.
        credits_balance:
          type: integer
          minimum: 0
          description: Credits currently available for generation. Check before large submissions to avoid `402 quota_exceeded`.
        subscription_plan:
          type: ['string', 'null']
          description: Current subscription status/plan indicator; `null` when the account has no active subscription.
        rate_limit_per_min:
          type: integer
          minimum: 1
          description: Requests per minute allowed for this API key (default 60). Exceeding it returns HTTP 429.
        data_processors:
          type: array
          items: { $ref: '#/components/schemas/DataProcessor' }
          description: Sub-processor disclosure — surface this list to the merchant where required.
        installation: { $ref: '#/components/schemas/Installation' }
    RefreshUrlResponse:
      type: object
      required: [job_id, outputs, expires_at]
      properties:
        job_id:
          type: string
          format: uuid
          description: The job whose download URLs were re-issued.
        outputs:
          type: array
          items: { $ref: '#/components/schemas/JobOutput' }
          description: The job's outputs with freshly signed URLs (valid for at least 7 days from now).
        expires_at:
          type: string
          format: date-time
          description: When the re-issued URLs stop working — call this endpoint again before then if needed.
    Error:
      type: object
      required: [code, message_i18n, retryable]
      properties:
        code:
          type: string
          enum:
            - invalid_input
            - unauthorized
            - forbidden
            - not_found
            - idempotency_conflict
            - job_not_cancelable
            - job_not_completed
            - batch_limit_exceeded
            - rate_limit_exceeded
            - quota_exceeded
            - concurrency_limit_exceeded
            - content_policy_violation
            - source_asset_unavailable
            - low_quality_input
            - output_unavailable
            - packshot_not_approved
            - generation_failed
            - internal_error
        message_i18n:
          type: object
          additionalProperties: { type: string }
          description: Human-readable message keyed by language code; `en` is always present.
        retryable:
          type: boolean
          description: '`true` — the same request may succeed if retried (transient failure); `false` — fix the request or state first.'
        doc_url:
          type: string
          format: uri
          description: Link to this error code's reference page in the docs.
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/Error' }
    WebhookPayload:
      description: |
        Posted to the installation's configured callback URL for each delivery.
        Headers: `X-Qamera-Signature: t=<unix>,v1=<hex_hmac_sha256_of_raw_body>`
        (during the 48h post-rotation grace window, two `v1=` segments are
        sent) and `X-Qamera-Request-Id: <delivery_id>` (use it with
        `POST /webhooks/{delivery_id}/replay`).
      type: object
      required: [event, delivered_at, job, outputs]
      properties:
        event:
          type: string
          enum: [job.completed, job.failed, job.cancelled]
          description: What happened to the job. One delivery is sent per job, not per session.
        delivered_at:
          type: string
          format: date-time
          description: When the delivery payload was generated.
        job:
          type: object
          required: [id, status, job_type, order_id]
          properties:
            id:
              type: string
              format: uuid
              description: The job this delivery is about.
            status: { $ref: '#/components/schemas/JobStatus' }
            job_type:
              type: string
              description: 'Branch on this: a completed `packshot` job needs merchant review (`POST /jobs/{id}/accept`), a completed `photo_shoot` job is final.'
            order_id:
              type: ['string', 'null']
              format: uuid
              description: Session the job belongs to.
            completed_at: { type: ['string', 'null'], format: date-time }
            error:
              description: 'Last generation error as the same structured `Error` envelope returned by `GET /jobs/{id}`; `null` on success.'
              oneOf:
                - $ref: '#/components/schemas/Error'
                - type: 'null'
            packshot_asset_id:
              type: ['string', 'null']
              format: uuid
              description: Subject context — the packshot the job generated from.
            product_label:
              type: ['string', 'null']
              description: Subject context — display name, as submitted.
            product_ref:
              type: ['string', 'null']
              description: Subject context — re-attach the delivery to your product without an extra GET.
            voting:
              description: Merchant rating at delivery time; `null` until voted.
              oneOf:
                - $ref: '#/components/schemas/Voting'
                - type: 'null'
            voting_at: { type: ['string', 'null'], format: date-time }
        outputs:
          type: array
          items: { $ref: '#/components/schemas/JobOutput' }
          description: Download links signed for at least 7 days. Entries may carry additional `_`-prefixed bookkeeping fields — ignore them.
        external_metadata:
          type: ['object', 'null']
          additionalProperties: true
          description: Your correlation payload from the submission, echoed verbatim.
        callback_url:
          type: ['string', 'null']
          format: uri
          description: Echo of the `callback_url` you sent with the session (informational; not the delivery target).
    ProductMetadata:
      type: object
      description: Product details used to auto-create the catalog product when `product_ref` is new. Required on first registration of a product; ignored when the product already exists.
      required: [display_name]
      properties:
        display_name:
          type: string
          minLength: 1
          maxLength: 500
          description: Product name shown in the dashboard and product listings.
        sku:
          type: ['string', 'null']
          maxLength: 100
          description: Merchant SKU, stored for reference and returned in product DTOs.
        description:
          type: ['string', 'null']
          maxLength: 5000
          description: Optional product description, stored for reference.
        extra:
          type: object
          additionalProperties: true
          description: Free-form metadata cached on `products.source_metadata`.
    RegisterImageItem:
      type: object
      required: [external_ref, product_ref, asset_id]
      properties:
        external_ref:
          type: string
          minLength: 1
          maxLength: 200
          description: Stable client-side identifier; unique per installation.
        product_ref:
          type: string
          minLength: 1
          maxLength: 200
          description: Parent product's `external_ref`; cascade-created when `product_metadata` is provided.
        product_metadata:
          $ref: '#/components/schemas/ProductMetadata'
        asset_id:
          type: string
          format: uuid
          description: UUID returned by `POST /assets/upload`; the uploaded file must be fully processed before registration (`source_asset_unavailable` otherwise).
    RegisterImagesBody:
      type: object
      required: [images]
      properties:
        images:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/RegisterImageItem' }
    RegisterResultStatus:
      type: string
      enum: [created, existing]
      description: '`created` — a new row was inserted; `existing` — the same `external_ref` (or identical file content) was already registered and the existing row is returned (idempotent replay).'
    RegisterImageResult:
      type: object
      required: [external_ref, product_id, image_id, status]
      properties:
        external_ref: { type: string }
        product_id: { type: string, format: uuid }
        image_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/RegisterResultStatus' }
    RegisterImagesResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/RegisterImageResult' }
    RegisterPackshotItem:
      type: object
      required: [external_ref, product_ref, asset_id]
      properties:
        external_ref:
          type: string
          minLength: 1
          maxLength: 200
          description: 'Stable client-side identifier for this packshot; unique per installation. Re-registering the same value returns the existing row (`status: "existing"`).'
        product_ref:
          type: string
          minLength: 1
          maxLength: 200
          description: Parent product's `external_ref`; cascade-created when `product_metadata` is provided.
        product_metadata:
          $ref: '#/components/schemas/ProductMetadata'
        asset_id:
          type: string
          format: uuid
          description: UUID returned by `POST /assets/upload`; the uploaded file must be fully processed before registration.
        source_image_ref:
          type: string
          maxLength: 200
          description: Optional link to the `product_images.external_ref` this packshot was derived from.
    RegisterPackshotsBody:
      type: object
      required: [packshots]
      properties:
        packshots:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/RegisterPackshotItem' }
    RegisterPackshotResult:
      type: object
      required: [external_ref, product_id, packshot_id, status]
      properties:
        external_ref: { type: string }
        product_id: { type: string, format: uuid }
        packshot_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/RegisterResultStatus' }
    RegisterPackshotsResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/RegisterPackshotResult' }
    ProductImage:
      type: object
      required:
        - id
        - external_ref
        - product_id
        - asset_id
        - byte_size
        - content_type
        - width
        - height
        - sha256
        - analysis_status
        - analyzed_at
        - created_at
      properties:
        id: { type: string, format: uuid }
        external_ref: { type: ['string', 'null'] }
        product_id: { type: string, format: uuid }
        asset_id: { type: string, format: uuid }
        byte_size: { type: integer }
        content_type: { type: string }
        width: { type: ['integer', 'null'] }
        height: { type: ['integer', 'null'] }
        sha256: { type: string, minLength: 64, maxLength: 64 }
        analysis_status:
          type: string
          enum: [pending, processing, described, error]
          description: |
            Lifecycle of the Gemini vision analysis performed by the
            `analyze_images` worker on this image. Plugin clients SHOULD
            wait for `'described'` before submitting `POST /jobs` against
            the image's asset_id — otherwise the worker may reject the
            job with `PREPARE_PHOTOS_TIMEOUT`. Terminal failure state is
            `'error'`.
        analyzed_at:
          type: ['string', 'null']
          format: date-time
          description: Timestamp the row reached `analysis_status='described'`. NULL while pending, processing, or in error.
        created_at: { type: string, format: date-time }
    ProductPackshot:
      type: object
      required:
        - id
        - external_ref
        - product_id
        - source_image_id
        - asset_id
        - byte_size
        - content_type
        - width
        - height
        - sha256
        - generated_by_job_id
        - voting
        - voting_at
        - created_at
      properties:
        id: { type: string, format: uuid }
        external_ref: { type: ['string', 'null'] }
        product_id: { type: string, format: uuid }
        source_image_id: { type: ['string', 'null'], format: uuid }
        asset_id: { type: string, format: uuid }
        byte_size: { type: integer }
        content_type: { type: string }
        width: { type: ['integer', 'null'] }
        height: { type: ['integer', 'null'] }
        sha256: { type: string, minLength: 64, maxLength: 64 }
        generated_by_job_id: { type: ['string', 'null'], format: uuid }
        voting:
          allOf:
            - $ref: '#/components/schemas/PackshotVoting'
          description: |
            Acceptance state evaluated by the photo-shoot gate. Use this
            to determine which packshot unblocks `POST /jobs` photo-shoot
            sessions for a product instead of probing for HTTP 422
            `packshot_not_approved`. Packshots registered via
            `POST /packshots` (or auto-registered from packshot jobs) are
            `accepted` immediately.
        voting_at:
          type: ['string', 'null']
          format: date-time
          description: |
            Timestamp of the accept/reject decision. NULL while `voting`
            is `pending`; set on creation for auto-accepted registrations.
        created_at: { type: string, format: date-time }
    ProductListItem:
      type: object
      required:
        - id
        - external_ref
        - display_name
        - sku
        - description
        - source_metadata
        - image_count
        - packshot_count
        - deleted_at
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        external_ref: { type: ['string', 'null'] }
        display_name: { type: string }
        sku: { type: ['string', 'null'] }
        description: { type: ['string', 'null'] }
        source_metadata:
          type: object
          additionalProperties: true
        image_count: { type: integer }
        packshot_count: { type: integer }
        deleted_at: { type: ['string', 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProductListResponse:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProductListItem' }
        next_cursor:
          type: ['string', 'null']
          description: Opaque cursor for the next page; `null` when no more rows.
    ProductDetailResponse:
      type: object
      required:
        - id
        - external_ref
        - display_name
        - sku
        - description
        - source_metadata
        - deleted_at
        - created_at
        - updated_at
        - images
        - images_truncated
        - packshots
        - packshots_truncated
      properties:
        id: { type: string, format: uuid }
        external_ref: { type: ['string', 'null'] }
        display_name: { type: string }
        sku: { type: ['string', 'null'] }
        description: { type: ['string', 'null'] }
        source_metadata:
          type: object
          additionalProperties: true
        deleted_at: { type: ['string', 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        images:
          type: array
          items: { $ref: '#/components/schemas/ProductImage' }
        images_truncated: { type: boolean }
        packshots:
          type: array
          items: { $ref: '#/components/schemas/ProductPackshot' }
        packshots_truncated: { type: boolean }
    PackshotListResponse:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProductPackshot' }
        next_cursor:
          type: ['string', 'null']
