# Picverce AI Public API v1
#
# The single source of truth. Committed, and served at
# https://picverce.com/openapi-v1.yaml once the site is exported.
# Phase 8 kept a second copy under scratch/; Phase 9 removed it, because two
# files that must agree is a drift risk with no upside.
#
# Every path, field and status here is read from the gateway source, not
# aspirational: cloudflare-workers/api-gateway/src/{worker,jobs,me,errors}.js
# and src/catalog/{tools,models}.js.

openapi: 3.0.3

info:
  title: Picverce AI Public API
  version: 1.0.0
  description: |
    Run Picverce AI image tools and generation models from your own server.

    Jobs are asynchronous. You create a job, get an id back immediately, and
    poll until it finishes. Nothing holds a connection open while a model runs.

    Keys are server-side credentials. This API sends no CORS headers, so a key
    pasted into browser JavaScript will not work from a web page. That is
    deliberate.

    Jobs draw from the same credit balance as the Picverce AI website.

    If you would rather not poll, register a webhook endpoint in Account and
    we will POST a signed event when a job finishes. See the Webhooks tag.
  contact:
    name: Picverce AI
    url: https://picverce.com/contact/
  license:
    name: Proprietary

servers:
  - url: https://api.picverce.com
    description: Production

security:
  - bearerApiKey: []

tags:
  - name: Account
    description: Who you are, your balance, and your rate limit standing.
  - name: Tools
    description: Picverce AI product tools that run on an image you supply.
  - name: Models
    description: Generation models that turn a prompt into an image.
  - name: Jobs
    description: Create and poll asynchronous work.
  - name: Webhooks
    description: |
      Signed notifications sent to your server when a job finishes, so you do
      not have to poll.

      Configure endpoints in Account, API tab, Webhooks. There is no Public API
      route for managing them: an endpoint is a place we will send your data,
      and moving that behind the same key a compromised server holds would make
      one leaked key enough to redirect your results.

      **Events**

      `job.succeeded` and `job.failed`. Nothing is sent while a job is queued or
      processing.

      **Request**

      ```
      POST <your endpoint>
      Content-Type: application/json
      User-Agent: Picverce-Webhooks/1.0
      X-Picverce-Event: job.succeeded
      X-Picverce-Delivery: 0f2b1d94-6a3c-4b1e-9d77-2c9e5a1b3d84
      X-Picverce-Timestamp: 1756300000
      X-Picverce-Signature: v1=8f3c...
      ```

      The body is a `WebhookEvent`. Its `data.object` is exactly what
      `GET /v1/jobs/{id}` returns for that job, so one parser handles both.

      **Verifying the signature**

      Signing string is the timestamp, a full stop, then the raw request body,
      with no reformatting:

      ```
      signed = timestamp + "." + rawBody
      expected = "v1=" + hex(hmac_sha256(signing_secret, signed))
      ```

      Compare `expected` to `X-Picverce-Signature` with a constant-time
      comparison. Verify against the raw bytes you received, before any JSON
      parse and re-serialize: re-encoding changes key order and whitespace, and
      the signature will stop matching.

      Reject a delivery whose `X-Picverce-Timestamp` is more than 300 seconds
      old. The signature alone never expires, so that check is what makes a
      captured delivery unreplayable, and only your server can make it.

      **Retries and duplicates**

      A 2xx means delivered. Anything else is retried twice more, about 2 and 8
      seconds later. A 404 or 410 stops the retries, since an endpoint reporting
      those is not coming back.

      Treat delivery as at-least-once. `id` on the event is stable for one job
      reaching one state, so use it as an idempotency key rather than acting
      twice on the same result.

      Redirects are not followed. Register the final URL.

      **Rules for the endpoint**

      https only, publicly reachable, no credentials in the URL. Private and
      link-local addresses are refused when you register and again before every
      delivery.

paths:
  /health:
    get:
      tags: [Account]
      summary: Liveness check
      description: The only unauthenticated endpoint. Carries no account data.
      security: []
      responses:
        '200':
          description: The gateway is reachable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  version:
                    type: string
                    example: v1

  /v1/me:
    get:
      tags: [Account]
      summary: Account snapshot
      description: |
        The account behind the calling key, its credit balance, and the current
        rate limit standing. Reading this does not reset or extend your limit.

        `available` is what you can spend right now. On the free tier that is
        `daily + purchased`; on a paid plan it is `subscription + purchased`
        and `daily` reads 0.

        `reserved_open` is the total held by jobs that are queued or
        processing. Those credits have already left `available`, so the two are
        never double counted.
      responses:
        '200':
          description: The account snapshot.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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/Account' }
              example:
                object: account
                user_id: 8fJk2LmQ9pR4sT6uV8wX1yZ3aB5c
                key:
                  id: 0f5c1d2e-3a4b-4c5d-8e9f-0a1b2c3d4e5f
                  prefix: pk_live_EXAMPLE1
                  environment: live
                credits:
                  available: 42
                  daily: 7
                  subscription: 0
                  purchased: 35
                  reserved_open: 4
                  plan: Free
                rate_limit:
                  limit: 60
                  remaining: 58
                  reset_at: '2026-08-25T08:15:00.000Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/tools:
    get:
      tags: [Tools]
      summary: List tools
      description: |
        Every tool in the catalog. A key is required: an open catalog is a free
        structured export of the whole product surface and its pricing.

        Being listed means the tool exists in the product. See
        `POST /v1/jobs` for which tool ids accept jobs in v1.0.0.
      responses:
        '200':
          description: The full tool catalog.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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:
                type: object
                required: [object, data, has_more]
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Tool' }
                  has_more: { type: boolean, example: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/tools/{tool_id}:
    get:
      tags: [Tools]
      summary: Retrieve a tool
      parameters:
        - name: tool_id
          in: path
          required: true
          schema: { type: string, example: background_remover }
      responses:
        '200':
          description: One tool.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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/Tool' }
              example:
                id: upscale
                name: Image Upscaler
                description: Increase resolution by 2x, 4x or 8x while keeping edges clean.
                status: ga
                async: true
                credits:
                  type: variable
                  by: scale
                  values: { '2': 2, '4': 3, '8': 6 }
                  min: 2
                  max: 6
                input_schema:
                  image_url:
                    type: string
                    format: uri
                    required: true
                    description: Publicly reachable URL of the source image.
                  scale:
                    type: integer
                    enum: [2, 4, 8]
                    required: false
                    default: 2
                    description: How many times larger the result should be. Higher scales cost more credits.
                output: { type: image, delivery: url }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '404': { $ref: '#/components/responses/InvalidTool' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/models:
    get:
      tags: [Models]
      summary: List generation models
      description: |
        Every generation model available to `POST /v1/jobs` with
        `type: generate`. Model ids are stable names owned by Picverce AI and
        do not change when an underlying provider does.
      responses:
        '200':
          description: The full model catalog.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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:
                type: object
                required: [object, data, has_more]
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Model' }
                  has_more: { type: boolean, example: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/models/{model_id}:
    get:
      tags: [Models]
      summary: Retrieve a model
      parameters:
        - name: model_id
          in: path
          required: true
          schema: { type: string, example: flux-schnell }
      responses:
        '200':
          description: One model.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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/Model' }
              example:
                id: flux-schnell
                display: Flux Schnell
                description: The fastest and cheapest option. Good for drafts and iteration.
                status: ga
                async: true
                credits:
                  type: variable
                  by: resolution
                  values: { '1 MP': 3 }
                  min: 3
                  max: 3
                capabilities:
                  resolutions: ['1 MP']
                  ratios: ['1:1', '16:9', '9:16', '4:3', '3:4']
                  reference_image: unsupported
                  match_input_ratio: false
                  enhance_prompt: false
                  max_variations: 1
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '404': { $ref: '#/components/responses/InvalidModel' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/jobs:
    post:
      tags: [Jobs]
      summary: Create a job
      description: |
        Creates a job and returns immediately with `status: queued`. The work
        continues after the response closes, so a slow model never becomes a
        timeout on your side. Poll `GET /v1/jobs/{job_id}` for the result.

        Credits are reserved when the job is created and charged only when it
        succeeds. A failed job is refunded in full and reports
        `credits.charged: 0`.

        Input is validated against the catalog before anything runs. An
        unsupported ratio, resolution or variation count is refused with
        `422 validation_error` listing what is supported, rather than being
        quietly changed to something you did not ask for.

        **Job targets live in v1.0.0**: `type: tool` accepts
        `background_remover`, `enhance` and `upscale`. Every other catalog tool
        is refused with `404 invalid_tool` and a message saying so.
        `type: generate` accepts every model returned by `GET /v1/models`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/ToolJobRequest'
                - $ref: '#/components/schemas/GenerateJobRequest'
            examples:
              removeBackground:
                summary: Remove a background
                value:
                  type: tool
                  tool: background_remover
                  input:
                    image_url: https://example.com/product.jpg
              upscaleFourTimes:
                summary: Upscale 4x, which costs 3 credits
                value:
                  type: tool
                  tool: upscale
                  input:
                    image_url: https://example.com/photo.jpg
                    scale: 4
              generateImage:
                summary: Generate with the cheapest model
                value:
                  type: generate
                  model: flux-schnell
                  input:
                    prompt: a red bicycle against a white wall
                    ratio: '1:1'
      responses:
        '202':
          description: The job was accepted and queued.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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/Job' }
              example:
                object: job
                id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                type: tool
                tool: background_remover
                status: queued
                input: { image_url: 'https://example.com/product.jpg' }
                output: null
                error: null
                credits: { reserved: 2, charged: 0 }
                created_at: '2026-08-25T08:14:02.100Z'
                started_at: null
                completed_at: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '404':
          description: The requested tool or model does not exist, or is not a live job target in v1.0.0.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                unknownTool:
                  value:
                    error:
                      code: invalid_tool
                      message: 'No tool with id "teleport". Call GET /v1/tools for the full list.'
                      request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
                notYetRunnable:
                  value:
                    error:
                      code: invalid_tool
                      message: 'The tool "colorize" is in the catalog but not yet runnable through the API. Currently available: background_remover, enhance, upscale.'
                      request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/jobs/{job_id}:
    get:
      tags: [Jobs]
      summary: Retrieve a job
      description: |
        Poll until `status` is `succeeded` or `failed`. Two seconds between
        polls is a sensible starting point.

        For a generation still in flight, each poll also advances the job one
        step in the background, so the newer state appears on your next poll.
        Your polling is what drives a slow generation forward. Stop polling and
        the job stalls until it is reclaimed.

        A job belonging to another account returns 404, not 403.
      parameters:
        - name: job_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The job.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            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/Job' }
              examples:
                succeeded:
                  summary: A finished tool job
                  value:
                    object: job
                    id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                    type: tool
                    tool: background_remover
                    status: succeeded
                    input: { image_url: 'https://example.com/product.jpg' }
                    output:
                      images: ['https://media.picverce.com/history/result.png']
                      source_url: 'https://media.picverce.com/uploads/source.png'
                    error: null
                    credits: { reserved: 2, charged: 2 }
                    created_at: '2026-08-25T08:14:02.100Z'
                    started_at: '2026-08-25T08:14:02.400Z'
                    completed_at: '2026-08-25T08:14:11.900Z'
                failed:
                  summary: A failed job, fully refunded
                  value:
                    object: job
                    id: 3b1f8a52-6d47-4c0e-9a2b-5f7c8d9e0a1b
                    type: generate
                    model: flux-schnell
                    status: failed
                    input: { prompt: 'a red bicycle', ratio: '1:1', resolution: '1 MP', variations: 1, enhance_prompt: false }
                    output: null
                    error:
                      code: internal_error
                      message: The model returned no images.
                    credits: { reserved: 3, charged: 0 }
                    created_at: '2026-08-25T08:20:00.000Z'
                    started_at: '2026-08-25T08:20:00.300Z'
                    completed_at: '2026-08-25T08:20:14.700Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanUpgradeRequired' }
        '404': { $ref: '#/components/responses/JobNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      description: |
        Your API key, created at https://picverce.com/account/?tab=api

        Send it as `Authorization: Bearer pk_live_...`. Keys starting
        `pk_test_` behave identically and are for development.

        Public API access comes with an active Basic, Standard or Premium
        subscription, or with any credit pack you have bought. Creating a key,
        and using one, needs one of those. An account with neither receives
        `plan_upgrade_required`.

        Having access is separate from having credits. A job still needs enough
        credits to run, which answers `insufficient_credits`.

        Keep keys on your server. This API sends no CORS headers, so a key in
        browser JavaScript will not work from a web page.

  headers:
    XRequestId:
      description: Correlation id for this request, matching `request_id` in an error body.
      schema: { type: string, example: req_4f2a9c1b7e3d5a8c0b6e1f92 }
    RateLimitLimit:
      description: Requests allowed per rolling minute for this key.
      schema: { type: integer, example: 60 }
    RateLimitRemaining:
      description: Requests still available in the current window.
      schema: { type: integer, example: 58 }
    RateLimitReset:
      description: Unix time in seconds when the oldest counted request leaves the window.
      schema: { type: integer, example: 1787644920 }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, example: 43 }

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              enum:
                - invalid_api_key
                - insufficient_credits
                - plan_upgrade_required
                - invalid_tool
                - invalid_model
                - job_not_found
                - validation_error
                - rate_limited
                - internal_error
            message: { type: string }
            request_id: { type: string, example: req_4f2a9c1b7e3d5a8c0b6e1f92 }

    Credits:
      description: |
        A price. `fixed` is one number. `variable` names the input field that
        drives the price and lists every value it can take.
      oneOf:
        - type: object
          required: [type, value]
          properties:
            type: { type: string, enum: [fixed] }
            value: { type: integer, example: 2 }
        - type: object
          required: [type, by, values, min, max]
          properties:
            type: { type: string, enum: [variable] }
            by: { type: string, example: scale }
            values:
              type: object
              additionalProperties: { type: integer }
            min: { type: integer }
            max: { type: integer }

    Tool:
      type: object
      required: [id, name, description, status, async, credits, input_schema, output]
      properties:
        id: { type: string, example: background_remover }
        name: { type: string, example: Background Remover }
        description: { type: string }
        status: { type: string, enum: [ga, beta, coming_soon] }
        async: { type: boolean, enum: [true] }
        credits: { $ref: '#/components/schemas/Credits' }
        input_schema:
          type: object
          description: Accepted input fields. Anything not listed is refused by name.
          additionalProperties:
            type: object
            properties:
              type: { type: string }
              format: { type: string }
              required: { type: boolean }
              default: {}
              enum: { type: array, items: {} }
              minimum: { type: number }
              maximum: { type: number }
              description: { type: string }
        output:
          type: object
          properties:
            type: { type: string, example: image }
            delivery: { type: string, example: url }
            format: { type: string, example: png }
            transparency: { type: boolean }

    ModelCapabilities:
      type: object
      required: [resolutions, ratios, reference_image, match_input_ratio, enhance_prompt, max_variations]
      properties:
        resolutions:
          type: array
          items: { type: string }
          description: Every accepted `resolution`. Each one has a price in `credits.values`.
        ratios:
          type: array
          items: { type: string }
          description: Every accepted `ratio`. Values outside this list are refused.
        reference_image:
          type: string
          enum: [unsupported, optional, required]
        match_input_ratio:
          type: boolean
          description: Whether `ratio` may be `match_input_image`, which also needs a reference image.
        enhance_prompt: { type: boolean }
        max_variations:
          type: integer
          description: Most models are 1. Asking for more is refused rather than silently clamped.

    Model:
      type: object
      required: [id, display, description, status, async, credits, capabilities]
      properties:
        id: { type: string, example: flux-schnell }
        display: { type: string, example: Flux Schnell }
        description: { type: string }
        status: { type: string, enum: [ga, beta, coming_soon] }
        async: { type: boolean, enum: [true] }
        credits: { $ref: '#/components/schemas/Credits' }
        capabilities: { $ref: '#/components/schemas/ModelCapabilities' }

    ToolJobRequest:
      type: object
      required: [type, tool, input]
      properties:
        type: { type: string, enum: [tool] }
        tool:
          type: string
          enum: [background_remover, enhance, upscale]
          description: Tool ids that accept jobs in v1.0.0.
        input:
          type: object
          required: [image_url]
          properties:
            image_url:
              type: string
              format: uri
              description: |
                Publicly reachable http or https URL, 5 MB or smaller, of type
                jpeg, png, webp, gif or avif. Private and loopback addresses are
                refused.
            scale:
              type: integer
              enum: [2, 4, 8]
              description: '`upscale` only. Drives the price.'
            face_enhance:
              type: boolean
              description: '`enhance` only.'

    GenerateJobRequest:
      type: object
      required: [type, model, input]
      properties:
        type: { type: string, enum: [generate] }
        model:
          type: string
          description: Any id from `GET /v1/models`.
          example: flux-schnell
        input:
          type: object
          properties:
            prompt:
              type: string
              maxLength: 2000
              description: Required for every model except `qwen-layered`.
            ratio:
              type: string
              description: Must appear in the model's `capabilities.ratios`, or `match_input_image` where supported. Defaults to `1:1`.
            resolution:
              type: string
              description: Must appear in the model's `capabilities.resolutions`. Defaults to the first one. Drives the price.
            variations:
              type: integer
              minimum: 1
              description: Up to the model's `capabilities.max_variations`. The price is flat per job regardless.
            reference_image_url:
              type: string
              format: uri
              description: Required where `capabilities.reference_image` is `required`, refused where it is `unsupported`.
            enhance_prompt:
              type: boolean
              description: Only where `capabilities.enhance_prompt` is true.

    Job:
      type: object
      required: [object, id, type, status, input, output, error, credits, created_at]
      properties:
        object: { type: string, enum: [job] }
        id: { type: string, format: uuid }
        type: { type: string, enum: [tool, generate] }
        tool:
          type: string
          description: Present on a tool job.
        model:
          type: string
          description: Present on a generate job.
        status:
          type: string
          enum: [queued, processing, succeeded, failed, canceled]
        input:
          type: object
          description: The validated input, with any defaults applied.
        output:
          nullable: true
          type: object
          description: Null until the job succeeds.
          properties:
            images:
              type: array
              items: { type: string, format: uri }
            source_url:
              type: string
              format: uri
              nullable: true
        error:
          nullable: true
          type: object
          properties:
            code: { type: string }
            message: { type: string }
        credits:
          type: object
          required: [reserved, charged]
          properties:
            reserved:
              type: integer
              description: Held when the job was created.
            charged:
              type: integer
              description: Committed on success. Stays 0 for a failed job.
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }

    WebhookEvent:
      type: object
      description: |
        The body of a webhook delivery. Verify `X-Picverce-Signature` against
        the raw bytes before trusting anything in here.
      properties:
        id:
          type: string
          description: |
            Stable for one job reaching one state. The same value is sent to
            every endpoint subscribed to it, and a retry reuses it, so this is
            the right idempotency key.
          example: evt_11111111222233334444555555555555s
        object:
          type: string
          enum: [event]
        type:
          type: string
          enum: [job.succeeded, job.failed]
        created_at:
          type: string
          format: date-time
        data:
          type: object
          properties:
            object:
              allOf:
                - $ref: '#/components/schemas/Job'
              description: |
                Identical to the body of `GET /v1/jobs/{id}` for this job.
          required: [object]
      required: [id, object, type, created_at, data]
      example:
        id: evt_11111111222233334444555555555555s
        object: event
        type: job.succeeded
        created_at: '2026-08-27T10:00:09.412Z'
        data:
          object:
            object: job
            id: 11111111-2222-3333-4444-555555555555
            type: tool
            tool: background_remover
            status: succeeded
            input:
              image_url: https://example.com/product.jpg
            output:
              images:
                - https://r2.picverce.com/history/abc123.png
              source_url: null
            error: null
            credits:
              reserved: 2
              charged: 2
            created_at: '2026-08-27T10:00:00.000Z'
            started_at: '2026-08-27T10:00:01.000Z'
            completed_at: '2026-08-27T10:00:09.000Z'
    Account:
      type: object
      required: [object, user_id, key, credits, rate_limit]
      properties:
        object: { type: string, enum: [account] }
        user_id: { type: string }
        key:
          type: object
          required: [id, prefix, environment]
          properties:
            id: { type: string, format: uuid }
            prefix:
              type: string
              nullable: true
              description: The display half of the key. The secret is never returned.
            environment: { type: string, enum: [live, test] }
        credits:
          type: object
          required: [available, daily, subscription, purchased, reserved_open, plan]
          properties:
            available:
              type: integer
              description: Spendable now. Free tier is `daily + purchased`; paid is `subscription + purchased`.
            daily: { type: integer }
            subscription: { type: integer }
            purchased: { type: integer }
            reserved_open:
              type: integer
              description: Held by queued or processing jobs. Already excluded from `available`.
            plan: { type: string, example: Free }
        rate_limit:
          type: object
          required: [limit, remaining, reset_at]
          properties:
            limit: { type: integer }
            remaining: { type: integer }
            reset_at: { type: string, format: date-time }

  responses:
    Unauthorized:
      description: |
        Missing, malformed, unknown or revoked key. All four return the same
        response, so the API never confirms that a key once existed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: invalid_api_key
              message: The API key provided is not valid.
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    PlanUpgradeRequired:
      description: |
        The key is valid but the account does not include Public API access.
        Different from 402: the balance is not the problem, the entitlement is.
        Granted by an active Basic, Standard or Premium subscription, or by
        having bought a credit pack.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: plan_upgrade_required
              message: 'Public API access needs an active Basic, Standard or Premium plan, or a credit pack. See https://picverce.com/pricing/'
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    InsufficientCredits:
      description: |
        The key is valid, the balance is not enough. 402 rather than 403,
        because nothing is wrong with who you are.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: insufficient_credits
              message: This job costs 6 credits and your balance is 2.
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    InvalidTool:
      description: No tool with that id.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: invalid_tool
              message: 'No tool with id "teleport". Call GET /v1/tools for the full list.'
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    InvalidModel:
      description: No model with that id.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: invalid_model
              message: 'No model with id "gpt-9". Call GET /v1/models for the full list.'
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    JobNotFound:
      description: No job with that id under this account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: job_not_found
              message: No job with that id.
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    ValidationError:
      description: |
        The request was understood and refused. The message names what is
        wrong and lists the supported values.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            unsupportedRatio:
              value:
                error:
                  code: validation_error
                  message: 'flux-schnell does not support ratio "21:9". Supported: 1:1, 16:9, 9:16, 4:3, 3:4.'
                  request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
            tooManyVariations:
              value:
                error:
                  code: validation_error
                  message: flux-schnell supports at most 1 variation per job.
                  request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
            unknownField:
              value:
                error:
                  code: validation_error
                  message: 'Unknown input field: seed. Allowed: prompt, ratio, resolution, variations, reference_image_url, enhance_prompt.'
                  request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    RateLimited:
      description: Too many requests for this key in the current rolling minute.
      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' }
          example:
            error:
              code: rate_limited
              message: Rate limit of 60 requests per minute reached. Retry in 43 seconds.
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
    InternalError:
      description: Something failed on the Picverce AI side. Quote `request_id` when reporting it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: internal_error
              message: Something went wrong on our side.
              request_id: req_4f2a9c1b7e3d5a8c0b6e1f92
