openapi: 3.1.0
info:
  title: img.pro API
  description: |
    Image sharing API. Upload an image, get a shareable link.

    ## Quick Start

    Sign up at [img.pro](https://img.pro), then create an API key from your
    dashboard. Authenticate every request with that key:

    ```bash
    curl -X POST https://api.img.pro/v1/images \
      -H "Authorization: Bearer img_live_…" \
      -F "file=@screenshot.png"
    ```

    ## Authentication

    ```
    Authorization: Bearer img_live_…
    ```

    Create keys from your [dashboard](https://img.pro/keys).

    ## Tiers

    | Tier | Uploads/mo | Storage | Retention | Price |
    |------|------------|---------|-----------|-------|
    | Anonymous | Rate-limited | Shared | 30 days | Free |
    | Free | 1,000 | 10 GB | Permanent | Free |
    | Pro | 10,000 | 100 GB | Permanent | $29/mo |
    | Scale | 50,000 | 500 GB | Permanent | $99/mo |
    | Max | 200,000 | 2 TB | Permanent | $199/mo |

    **Free → Pro → Scale → Max**: Follow `error.action.url` in any quota error response — an upgrade link for authenticated teams, a signup link for the anonymous pool.

    ## Quota Headers

    Every authenticated response includes:
    ```
    X-Monthly-Uploads-Used: 42
    X-Monthly-Uploads-Limit: 1000
    X-Monthly-Uploads-Remaining: 958
    X-Storage-Used: 52428800
    X-Storage-Limit: 10737418240
    X-Storage-Remaining: 10684989440
    ```

    Check these proactively. Don't wait for a 403.

    ## Error Responses

    Every error is one nested `error` object — branch on `error.type`
    (coarse category) or switch on `error.code` (specific):
    ```json
    {
      "error": {
        "type": "quota_error",
        "code": "quota_exceeded",
        "message": "Human-readable explanation",
        "action": { "type": "upgrade", "url": "...", "label": "..." }
      }
    }
    ```

    `error.type` is one of `invalid_request_error`, `authentication_error`,
    `permission_error`, `rate_limit_error`, `quota_error`,
    `idempotency_error`, `processing_error`, `api_error`. The optional
    `error.action` tells you exactly what to do next.

    ## Image Links & Transforms

    Every image has two URLs. `url` is the image itself: a direct, embeddable
    CDN URL you can transform on the fly (always present, every plan).
    `page_url` is the shareable viewer page on img.pro — the link you send to
    a person. `sizes` carries three ready-made responsive variants
    (small/medium/large); for a social/OG card, add `?size=social` to the
    `url`.

    Apply transforms to `url` (the `GET /{team_hash}/{filename}` operation
    below documents every parameter). Named sizes: `?size=s|m|l|social`
    (short side 320/640/1080px, or a 1200×630 OG card). Format override:
    `?format=jpg|webp|avif|png|gif` (overrides the path extension). Dimensions:
    `?w=`, `?h=` (1–4096), `?fit=scale-down|contain|cover|crop|pad|squeeze`,
    `?gravity=auto|face|...`, `?zoom=` (0–1). Quality: `?q=` (1–100, or
    `high|medium-high|medium-low|low`). Color/tone (decimal multipliers
    centered at 1, range 0–10, omit for no change): `?brightness=`,
    `?contrast=`, `?gamma=` (>1 darkens midtones, <1 lightens), `?saturation=`
    (0 = grayscale). Filters: `?blur=` (integer 0–250), `?sharp=` (decimal
    0–10). Orientation: `?rotate=90|180|270`, `?flip=h|v|hv`, `?trim=border`.
    Background: `?segment=foreground` (remove),
    `?background=white|%23hex|rgb(...)|rgba(...)` (fill transparent areas).
    Effects: `?fx=blur-bg|color-pop|darken-bg` (+ `?strength=`). Watermark:
    `?tile=<image id>` (a same-workspace image, tiled across the base).
    Privacy: `?metadata=copyright|none|keep` — `copyright` is the default
    (strips GPS/device, keeps copyright tags). See /api/transforms for the
    full reference with examples.

  version: 4.2.0
  contact:
    name: img.pro
    url: https://img.pro
    email: api@img.pro
  license:
    name: img.pro API Terms of Service
    url: https://img.pro/terms

servers:
  - url: https://api.img.pro
    description: img.pro API

tags:
  - name: Images
    description: Store and manage images
  - name: Usage
    description: Check quotas
  - name: Transforms
    description: |
      Resize, convert, crop, and edit images on the fly via query parameters on
      the CDN `url`. No auth, no extra API call — see GET /{team_hash}/{filename}.
  - name: App
    description: |
      App API — for apps that use img.pro as a branded identity + billing
      "wallet" for their own users. Authenticate with an app machine secret
      (`img_sk_…`) plus the `X-Img-User` header. See /api/app for the full
      guide.
  - name: Billing
    description: |
      App API billing — read a user's plan/usage and start a checkout for one
      of the app's users. Machine-secret + `X-Img-User` auth.

paths:
  /v1/usage:
    get:
      tags: [Usage]
      summary: Check Quota
      description: |
        Check current usage before uploading.

        **Recommended workflow:**
        1. Call `/v1/usage`
        2. Check `monthly.uploads_remaining > 0`
        3. If yes, upload. If no, handle the limit.

        This avoids failed uploads and wasted bandwidth.

        **App API:** also reachable on the machine-secret plane —
        authenticate with an app secret (`img_sk_…`) plus `X-Img-User` to
        read usage for one of the app's users (see /api/app).
      operationId: getUsage
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/XImgUser'
      responses:
        '200':
          description: Current usage
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/images:
    post:
      tags: [Images]
      summary: Create Image
      description: |
        Create an image from a file (multipart) or a URL (JSON) — one endpoint,
        two sources, negotiated by Content-Type. **Authentication is optional.**

        Without a Bearer token, creates go to a shared pool with a TTL of 30 days and a 20 MB per-file limit.
        Rate-limited. A `signup` hint rides the `X-Img-Action` response header.

        **File upload (multipart/form-data):**
        **Formats:** JPEG, PNG, GIF, WebP, AVIF, HEIC, SVG, BMP, ICO
        **Max size:** 70 MB (10 MB for SVG, 20 MB for anonymous)

        Web-safe formats (JPEG, PNG, GIF, WebP) are processed inline.
        Other formats are queued — check `status` field.

        **URL import (application/json):** supply a `url` and we fetch it
        server-side (30s timeout, follows redirects). Same response as a file
        upload.

        **Retry-safety:** send an `Idempotency-Key` header to make a create
        safe to retry. A replay with the same key + body returns the original
        response verbatim (`Idempotent-Replayed: true`); the same key with a
        different body → `409 idempotency_key_conflict`; the same key while the
        original is still in flight → `409 idempotency_key_in_progress` (back off
        per `Retry-After`). Keys are retained 24h. `metadata` is part of the
        hashed body — changing a custom field on a retry is a different body;
        metadata is attribution, not a dedup key, so `Idempotency-Key` is the
        only retry-safety channel (there is no external_id upsert).

        **App API:** also reachable on the machine-secret plane — authenticate
        with an app secret (`img_sk_…`) plus `X-Img-User` to create images on
        behalf of one of the app's users (see /api/app).
      operationId: createImage
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: Opt-in retry-safety key (≤ 24h). Same key + body replays the original; different body → 409 conflict; original still in flight → 409 in-progress.
        - $ref: '#/components/parameters/XImgUser'
      security:
        - bearerAuth: []
        - machineSecret: []
        - {}
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                ttl:
                  type: string
                  description: Auto-delete after duration (e.g., `24h`, `7d`). Omit for permanent storage.
                public:
                  $ref: '#/components/schemas/MultipartVisibilityInput'
                  description: |
                    Visibility. The four accepted multipart values are
                    `true`, `false`, `1`, and `0`; letter forms are
                    case-insensitive and surrounding whitespace is ignored.
                    Empty, null, and every other value are rejected with 422.

                    Omit to take the destination workspace's default: **public**
                    for ordinary img.pro workspaces, **private** for
                    app-provisioned workspaces (those created by the App API
                    connect flow). Keyless (anonymous) uploads are always public
                    and reject every false form with 422.

                    Visibility governs DISCOVERY only: a private image 404s on
                    the unauthenticated `GET /v1/images/{id}` and on its
                    `page_url` viewer page, and is excluded from the img.pro
                    sitemap. It still appears in your own authenticated
                    `GET /v1/images` list, and its `url` / `sizes` CDN links keep
                    serving normally.
                caption:
                  type: string
                  maxLength: 5000
                  description: Free-text caption / description for the image. Over-length values are rejected with 422.
                published_at:
                  type: string
                  description: |
                    Publish date — a unix timestamp (seconds) or ISO-8601 date
                    string (`2024-06-01`). Backdate to sort under the photo's
                    date (a 2024 photo uploaded in 2026 sorts under 2024). Omit
                    to default to the upload time.
                metadata:
                  type: string
                  description: |
                    Custom attribution fields as a JSON-encoded object, e.g.
                    `{"author":"Jane","license":"cc-by-4.0"}` — the same nested
                    shape the Image response returns. This is the only channel for
                    custom fields (a multipart form field can't hold an object, so
                    it's JSON-encoded here); a bare unknown form field is rejected
                    with 422.
                labels:
                  type: string
                  description: |
                    Selector labels as a JSON-encoded object, e.g.
                    `{"state":"pending","kind":"generated"}` — the queryable sibling
                    of `metadata` you can later filter a list on
                    (`GET /v1/images?label[k]=v`). Bounds: ≤ 20 keys, key
                    `^[a-z0-9_.-]{1,64}$`, value ≤ 128 chars (no commas, no surrounding whitespace).
              additionalProperties: false
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                ttl:
                  type: string
                  description: Auto-delete after duration (e.g., `24h`, `7d`). Omit for permanent storage.
                public:
                  $ref: '#/components/schemas/VisibilityInput'
                  description: |
                    Visibility. Accepted JSON values are booleans `true` /
                    `false`, numbers `1` / `0`, or strings `"true"` /
                    `"false"` / `"1"` / `"0"`; letter forms are
                    case-insensitive and strings may have surrounding
                    whitespace. Null, empty strings, and every other value
                    return 422.

                    Omit to take the destination workspace's default: **public**
                    for ordinary img.pro workspaces, **private** for
                    app-provisioned ones. Keyless (anonymous) imports are always
                    public and reject every false form with 422. Governs discovery (unauthenticated
                    `GET /v1/images/{id}`, the `page_url` viewer page, the
                    sitemap) — never the `url` / `sizes` CDN links, and never
                    your own authenticated list.
                caption:
                  type: string
                  maxLength: 5000
                  description: Free-text caption / description for the image. Over-length values are rejected with 422.
                published_at:
                  type: string
                  description: |
                    Publish date — a unix timestamp (seconds) or ISO-8601 date
                    string (`2024-06-01`). Omit to default to the upload time.
                metadata:
                  type: object
                  additionalProperties:
                    type: string
                  description: |
                    Custom attribution fields as a nested string→string map,
                    identical to what the Image response returns. This is the only
                    channel for custom fields — a bare unknown top-level field is
                    rejected with 422.
                labels:
                  type: object
                  additionalProperties:
                    type: string
                  description: |
                    Selector labels as a nested string→string map — the queryable
                    sibling of `metadata` you can later filter a list on
                    (`GET /v1/images?label[k]=v`). Bounds: ≤ 20 keys, key
                    `^[a-z0-9_.-]{1,64}$`, value ≤ 128 chars (no commas, no surrounding whitespace).
              additionalProperties: false
      responses:
        '201':
          description: |
            Image created. Authenticated and app-secret creates also return the
            quota headers below; anonymous creates return `X-RateLimit-*` +
            `X-Img-Action` instead.
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Image'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/QuotaExceeded'
        '409':
          $ref: '#/components/responses/CreateConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          description: Rate limited (anonymous creates). Back off per the `Retry-After` header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: rate_limit_error
                  code: rate_limited
                  message: "Hourly limit of 20 uploads reached. Resets in 42 minutes. Get an API key for higher limits."
                  action: { type: wait, retry_after: 2520, url: "https://img.pro/auth/register", label: "Create API Key" }
        '502':
          description: Fetch failed (URL import)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: processing_error
                  code: fetch_failed
                  message: "Could not fetch the URL"

    get:
      tags: [Images]
      summary: List Images
      description: |
        List images, ordered by `published_at` newest-first (backdate or
        re-stamp an image's `published_at` to re-sort it). Cursor-based
        pagination — follow `pagination.next_url` or pass the previous
        page's `next_cursor`. Lists carry only servable media (`status`
        `ready` or `processing`); blocked and failed images are excluded.

        **App API:** also reachable on the machine-secret plane — authenticate
        with an app secret (`img_sk_…`) plus `X-Img-User` to list images for
        one of the app's users (see /api/app).
      operationId: listImages
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/XImgUser'
        - name: ids
          in: query
          schema:
            type: string
          description: Comma-separated IDs to fetch specific items. Cannot be combined with `label[...]` filters.
        - name: label
          in: query
          style: deepObject
          explode: true
          schema:
            type: object
            additionalProperties:
              type: string
          description: |
            Filter by labels — `?label[state]=pending`. A CSV or repeated key is
            `IN` (`?label[state]=pending,liked`); distinct keys `AND`
            (`?label[state]=pending&label[kind]=generated`). A `!` prefix is
            does-not-exist (Kubernetes `!key`): `?label[!state]` matches media with
            NO `state` label — the only way to reach media the app never labeled;
            it takes no value. Equality + IN + does-not-exist in v1; at most
            50 value alternatives across all keys combined and
            20 keys total. Filtering a value on a key you never set returns
            an empty page (not an error). Cannot be combined with `ids`. Keep the same
            filter across every page of a walk — the cursor is a position in that
            filtered ordering.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque pagination cursor from the previous page's `next_cursor`
      responses:
        '200':
          description: Image list
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImageListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/images/{id}:
    get:
      tags: [Images]
      summary: Get Image
      description: |
        Get a single image. Public images can be fetched without auth.
        A blocked image answers `403 media_blocked` (the owner's error
        message includes the coarse reason); a failed image answers
        `422 media_failed` with a human-readable explanation.
      operationId: getImage
      security:
        - bearerAuth: []
        - machineSecret: []
        - {}
      parameters:
        - $ref: '#/components/parameters/MediaId'
        - $ref: '#/components/parameters/XImgUser'
      responses:
        '200':
          description: Image details
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Image'
        '403':
          description: Image blocked by moderation (code media_blocked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: permission_error
                  code: media_blocked
                  message: "This image was blocked (content_policy) and is no longer available."
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Image processing failed permanently (code media_failed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: processing_error
                  code: media_failed
                  message: "This HEIC image could not be read."

    patch:
      tags: [Images]
      summary: Update Image
      operationId: updateImage
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/MediaId'
        - $ref: '#/components/parameters/XImgUser'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaUpdate'
      responses:
        '200':
          description: Updated
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Image'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: A supplied if_labels precondition no longer matches (code state_conflict)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [Images]
      summary: Delete Image
      operationId: deleteImage
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/MediaId'
        - $ref: '#/components/parameters/XImgUser'
      responses:
        '200':
          description: Deleted — a tombstone confirming the deletion.
          content:
            application/json:
              schema:
                type: object
                required: [id, object, deleted]
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    enum: [image]
                  deleted:
                    type: boolean
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/images/batch:
    patch:
      tags: [Images]
      summary: Batch Update
      description: |
        Update up to 100 media items by ID. More than 100 ids returns 422
        validation_error. 200 when all succeed; 207 when some items fail (each
        failure appears in the `errors` array).
      operationId: batchUpdateImages
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/XImgUser'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: string
                  maxItems: 100
                caption:
                  type: string
                  maxLength: 5000
                public:
                  $ref: '#/components/schemas/VisibilityInput'
                  description: |
                    Apply one visibility value to every item. Accepted JSON
                    values are booleans `true` / `false`, numbers `1` /
                    `0`, or strings `"true"` / `"false"` / `"1"` /
                    `"0"`; letter forms are case-insensitive and strings may
                    have surrounding whitespace. Null, empty strings, and every
                    other value return 422. Omit to leave visibility unchanged.
                ttl:
                  type: string
                  description: Apply this TTL to every item in the batch (e.g., `7d`). `null` makes them permanent.
                published_at:
                  type: string
                  description: Publish date (unix seconds or ISO-8601, e.g. `2024-06-01`) applied to every item.
                metadata:
                  type: object
                  additionalProperties:
                    type: [string, 'null']
                  description: |
                    Attribution fields (nested) merged into EVERY item's existing
                    metadata. A `null` value deletes that key on each item. This is
                    the only channel for custom fields — a bare unknown top-level
                    field is rejected with 422.
                labels:
                  type: object
                  additionalProperties:
                    type: [string, 'null']
                  description: |
                    Selector labels (nested) merged into EVERY item's existing
                    labels. A `null` value deletes that key on each item. Same
                    bounds as the single-item PATCH.
                if_labels:
                  type: object
                  additionalProperties:
                    type: string
                  minProperties: 1
                  description: |
                    Atomic precondition applied to every item: all supplied
                    labels must still equal these values when each update
                    commits. A mismatch is reported per item as state_conflict.
              additionalProperties: false
      responses:
        '200':
          description: Updated
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [Images]
      summary: Batch Delete
      description: |
        Delete up to 100 media items by ID. More than 100 ids returns 422
        validation_error.

        `{"ids": ["a", "b", "c"]}`
      operationId: batchDeleteImages
      security:
        - bearerAuth: []
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/XImgUser'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: string
                  maxItems: 100
      responses:
        '200':
          description: Deleted
          headers:
            X-Monthly-Uploads-Used:
              $ref: '#/components/headers/X-Monthly-Uploads-Used'
            X-Monthly-Uploads-Limit:
              $ref: '#/components/headers/X-Monthly-Uploads-Limit'
            X-Monthly-Uploads-Remaining:
              $ref: '#/components/headers/X-Monthly-Uploads-Remaining'
            X-Storage-Used:
              $ref: '#/components/headers/X-Storage-Used'
            X-Storage-Limit:
              $ref: '#/components/headers/X-Storage-Limit'
            X-Storage-Remaining:
              $ref: '#/components/headers/X-Storage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

  /v1/auth/exchange:
    post:
      tags: [App]
      summary: Exchange Connect Code
      description: |
        Exchange a one-time `code` from the hosted `/connect` login flow for
        an `auth_context` describing the user that just connected.
        Backend-mediated: the app's server calls this with its machine secret;
        no token is ever handed to a browser. See /api/app for the full flow.
      operationId: exchangeConnectCode
      security:
        - machineSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: One-time code issued by the `/connect` consent screen.
      responses:
        '200':
          description: Connected user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthContext'
        '422':
          description: Invalid or expired code (code invalid_code)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: invalid_request_error
                  code: invalid_code
                  message: "Invalid or expired authorization code"

  /v1/billing/status:
    get:
      tags: [Billing]
      summary: Get Billing Status
      description: |
        Read the current plan, usage, and available upgrade targets for one of
        the app's users. Machine-secret + `X-Img-User`.
      operationId: getBillingStatus
      security:
        - machineSecret: []
      parameters:
        - $ref: '#/components/parameters/XImgUser'
      responses:
        '200':
          description: Billing status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: User is not connected to this app (code user_forbidden)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: permission_error
                  code: user_forbidden
                  message: "No access for this app and user"

  /{team_hash}/{filename}:
    get:
      tags: [Transforms]
      servers:
        - url: https://src.img.pro
          description: Image CDN
      summary: Transform an Image
      description: |
        The image CDN. Every API response's `url` points here; this operation
        is that URL. Append query parameters to transform the image on the fly —
        resize, convert, crop, color-correct, remove the background, or
        watermark — with no extra API call. Each distinct URL is cached
        independently at the edge worldwide.

        No authentication: the `url` is a public capability link. Invalid
        parameter values are ignored rather than erroring, so the image always
        loads. Order doesn't matter. Full guide with examples: /api/transforms.
      operationId: transformImage
      security: []
      parameters:
        - name: team_hash
          in: path
          required: true
          description: The team-hash segment of the `url` (e.g. `4j2`).
          schema: { type: string }
        - name: filename
          in: path
          required: true
          description: The image id plus an extension, e.g. `abc12345.jpg`. The `format` query param overrides the extension.
          schema: { type: string }
        - name: size
          in: query
          description: Named size preset — skips the manual params below. `s`/`m`/`l` constrain the short side to 320/640/1080px (aspect preserved); `social` is a fixed 1200×630 OpenGraph card.
          schema: { type: string, enum: [s, m, l, social] }
        - name: format
          in: query
          description: Output format. Overrides the extension in the path. `webp` is the best size/quality balance; `png` is lossless with transparency.
          schema: { type: string, enum: [jpg, png, webp, avif, gif] }
        - name: w
          in: query
          description: Target width in pixels. Set `w` or `h` alone to scale proportionally, or both with `fit`.
          schema: { type: integer, minimum: 1, maximum: 4096 }
        - name: h
          in: query
          description: Target height in pixels.
          schema: { type: integer, minimum: 1, maximum: 4096 }
        - name: fit
          in: query
          description: How the image fills `w`×`h`. `cover`/`crop` fill and trim; `contain`/`pad` letterbox; `scale-down` never enlarges.
          schema: { type: string, enum: [scale-down, contain, cover, crop, pad, squeeze], default: scale-down }
        - name: gravity
          in: query
          description: Which part to keep when cropping. `face` centers on detected faces.
          schema: { type: string, enum: [auto, face, left, right, top, bottom], default: auto }
        - name: zoom
          in: query
          description: Crop tightness around `gravity=face` (0 = most context, 1 = tight).
          schema: { type: number, minimum: 0, maximum: 1 }
        - name: q
          in: query
          description: Output quality for lossy formats (JPEG/WebP/AVIF). A number 1–100, or a named level (`high`≈90, `medium-high`≈75, `medium-low`≈60, `low`≈45). No effect on PNG (always lossless).
          schema: { type: string }
        - name: brightness
          in: query
          description: Multiplier centered at 1 (0 = black, 2 = twice as bright). Useful range ~0.5–2.
          schema: { type: number, minimum: 0, maximum: 10, default: 1 }
        - name: contrast
          in: query
          description: Multiplier centered at 1 (>1 = punchier). Useful range ~0.7–2.
          schema: { type: number, minimum: 0, maximum: 10, default: 1 }
        - name: gamma
          in: query
          description: Multiplier (<1 lightens midtones, >1 darkens; 0 and 1 are no-ops). Useful range ~0.5–2.5.
          schema: { type: number, minimum: 0, maximum: 10, default: 1 }
        - name: saturation
          in: query
          description: Multiplier centered at 1 (0 = grayscale, >1 = vivid). Useful range ~0.5–2.
          schema: { type: number, minimum: 0, maximum: 10, default: 1 }
        - name: blur
          in: query
          description: Gaussian blur radius in pixels.
          schema: { type: integer, minimum: 0, maximum: 250, default: 0 }
        - name: sharp
          in: query
          description: Unsharp-mask intensity (1–3 subtle, 4–6 noticeable, 7+ aggressive).
          schema: { type: number, minimum: 0, maximum: 10, default: 0 }
        - name: rotate
          in: query
          description: Clockwise rotation in degrees.
          schema: { type: integer, enum: [90, 180, 270] }
        - name: flip
          in: query
          description: Mirror horizontally, vertically, or both.
          schema: { type: string, enum: [h, v, hv] }
        - name: trim
          in: query
          description: Auto-crop a uniform border color around the image.
          schema: { type: string, enum: [border] }
        - name: metadata
          in: query
          description: EXIF retention. `copyright` (default) strips GPS/device but keeps copyright tags; `none` strips everything; `keep` retains all, including GPS.
          schema: { type: string, enum: [copyright, none, keep], default: copyright }
        - name: segment
          in: query
          description: Remove the background (edge segmentation model). Pair with `format=png` to keep transparency.
          schema: { type: string, enum: [foreground] }
        - name: background
          in: query
          description: Fill color for transparent areas — a CSS named color, hex (URL-encode `#` as `%23`), or `rgb()`/`rgba()`. After `segment=foreground` it swaps the background; on its own it fills transparent pixels. When omitted, alpha-capable formats stay transparent and others fill white (no img.pro-enforced default).
          schema: { type: string }
        - name: fx
          in: query
          description: One-param composite effect (subject kept sharp, effect applied to the background). `blur-bg` = portrait blur; `color-pop` = grayscale background; `darken-bg` = spotlight.
          schema: { type: string, enum: [blur-bg, color-pop, darken-bg] }
        - name: strength
          in: query
          description: Tunes `fx` (only with `fx`). blur-bg = blur radius 0–250 (default 60); darken-bg = background brightness 0.05–1 (default 0.5); color-pop has no strength. Out-of-range clamps.
          schema: { type: number, minimum: 0, maximum: 250 }
        - name: tile
          in: query
          description: Watermark — the `id` of another image in the same workspace, tiled across the base. A cross-workspace or unknown id is ignored.
          schema: { type: string }
      responses:
        '200':
          description: The transformed image bytes (content type follows the requested format).
          content:
            image/*:
              schema:
                type: string
                format: binary
        '404':
          description: |
            No image at this URL — unknown id, team hash, or a deleted/expired image.
            Note: `public` governs discovery surfaces — the unauthenticated
            `GET /v1/images/{id}`, the `page_url` viewer page, and the img.pro
            sitemap — not this CDN URL, which is a capability URL and keeps serving
            a private image's bytes to anyone holding the link. It does NOT filter
            your own authenticated `GET /v1/images` list: private images are
            returned there normally.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
    machineSecret:
      type: http
      scheme: bearer
      description: |
        App machine secret (`img_sk_…`) for the App API plane — server-side
        only, never exposed to a browser. Combine with the `X-Img-User` header
        to act on behalf of one of the app's users. See /api/app for the full
        app-builder guide.

  headers:
    X-Monthly-Uploads-Used:
      description: Uploads used this month
      schema:
        type: integer
    X-Monthly-Uploads-Limit:
      description: Monthly upload limit
      schema:
        type: integer
    X-Monthly-Uploads-Remaining:
      description: Uploads remaining this month
      schema:
        type: integer
    X-Storage-Used:
      description: Bytes stored
      schema:
        type: integer
    X-Storage-Limit:
      description: Storage limit in bytes
      schema:
        type: integer
    X-Storage-Remaining:
      description: Storage remaining in bytes
      schema:
        type: integer

  parameters:
    MediaId:
      name: id
      in: path
      required: true
      schema:
        type: string
    XImgUser:
      name: X-Img-User
      in: header
      required: false
      schema:
        type: string
      description: |
        App API only — the user uid to act on behalf of. Names which of the
        app's users the request targets; the user's app team is resolved
        internally. Ignored on the Bearer-key plane. (The team model is
        deferred to multi-team — see §9 of /api/app.)

  schemas:
    MultipartVisibilityInput:
      type: string
      pattern: '^\s*(?:[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[01])\s*$'
      examples: ["true", "false", "1", "0"]
      description: |
        Multipart visibility value. Letter forms are case-insensitive and
        surrounding whitespace is ignored.

    VisibilityInput:
      oneOf:
        - type: boolean
        - type: integer
          enum: [0, 1]
        - type: string
          pattern: '^\s*(?:[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[01])\s*$'
      examples: [true, false, 1, 0, "true", "false", "1", "0"]
      description: |
        JSON visibility value. Letter forms are case-insensitive and string
        values may have surrounding whitespace.

    UsageResponse:
      type: object
      properties:
        object:
          type: string
          enum: [usage]
          description: Always `"usage"`.
        monthly:
          type: object
          properties:
            uploads:
              type: integer
            uploads_limit:
              type: integer
            uploads_remaining:
              type: integer
            resets_at:
              type: string
              format: date-time
              description: ISO-8601 UTC timestamp of the next monthly quota reset
        totals:
          type: object
          properties:
            images_stored:
              type: integer
            storage_used_bytes:
              type: integer
            storage_limit_bytes:
              type: integer
            storage_remaining_bytes:
              type: integer
        plan:
          type: string
          enum: [free, pro, scale, max]

    AuthContext:
      type: object
      required: [object, app, user]
      description: The user connected by an exchanged `/connect` code.
      properties:
        object:
          type: string
          enum: [auth_context]
          description: Always `"auth_context"`.
        app:
          type: object
          required: [id, object, name]
          properties:
            id:
              type: string
            object:
              type: string
              enum: [app]
            name:
              type: string
        user:
          type: object
          required: [id, object, email, verified]
          properties:
            id:
              type: string
            object:
              type: string
              enum: [user]
            email:
              type: string
            verified:
              type: boolean

    BillingStatus:
      type: object
      required: [object, plan, billing_url, usage, available_plans]
      description: Current plan, usage, and upgrade targets for one of the app's users.
      properties:
        object:
          type: string
          enum: [billing_status]
          description: Always `"billing_status"`.
        plan:
          type: string
          enum: [free, pro, scale, max]
        billing_url:
          type: string
          format: uri
          description: |
            The co-branded img.pro billing page for this app (carries only `?app=<uid>`).
            Always present. Redirect the user here to subscribe, upgrade, downgrade, cancel,
            or open the Stripe portal — img.pro owns checkout and the post-payment return.
            You MUST append:
              - `redirect_uri` (REQUIRED) — where the user is returned; must EXACTLY match
                one of the app's registered redirect URIs (same allowlist as hosted login),
                else the page errors (never redirects).
            You MAY append:
              - `plan` (`pro`|`scale`|`max`) and `interval` (`monthly`|`annual`) — these
                PRE-SELECT the plan picker; the user still confirms (no auto-charge).
              - `state` (≤512 chars) — echoed back on the return URL.
            Prerequisite: the user must have connected the app first (else a "connect first"
            page). Read the outcome by re-polling GET /v1/billing/status; a completed
            subscribe/upgrade returns with `?billing=success`.
        usage:
          $ref: '#/components/schemas/UsageResponse'
        available_plans:
          type: array
          description: The plan catalog (ordered free < pro < scale < max), with prices and limits. `free` is listed for reference but is not checkout-able.
          items:
            type: object
            required: [object, id, name, prices, limits]
            properties:
              object:
                type: string
                enum: [plan]
              id:
                type: string
                enum: [free, pro, scale, max]
              name:
                type: string
              prices:
                type: object
                required: [monthly, annual]
                properties:
                  monthly:
                    type: object
                    required: [amount_cents, currency]
                    properties:
                      amount_cents:
                        type: integer
                      currency:
                        type: string
                  annual:
                    type: object
                    required: [amount_cents, currency]
                    properties:
                      amount_cents:
                        type: integer
                      currency:
                        type: string
              limits:
                type: object
                required: [monthly_uploads, storage_bytes]
                properties:
                  monthly_uploads:
                    type: integer
                  storage_bytes:
                    type: integer

    ImageSize:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: Direct CDN URL for this named variant.
        width:
          type: integer
          description: Variant width in pixels (omitted while `status` is `processing`).
        height:
          type: integer
          description: Variant height in pixels (omitted while `status` is `processing`).

    Image:
      type: object
      required:
        - id
        - object
        - url
        - page_url
        - sizes
        - filename
        - format
        - transformable
        - status
        - public
        - width
        - height
        - bytes
        - published_at
        - expires_at
        - created_at
        - caption
        - metadata
        - labels
        - nsfw
      properties:
        id:
          type: string
          description: Media UID (e.g. `abc12345`).
        object:
          type: string
          enum: [image]
          description: Object type discriminator. Always `"image"`.
        url:
          type: string
          format: uri
          description: |
            The image itself — a direct, embeddable CDN URL (e.g.
            `https://src.img.pro/4j2/abc12345.jpg`). Always present on every
            plan. Append or change query params (format, w, h, q, …) to get
            other variants on the fly; see /api/transforms.
        page_url:
          type: string
          format: uri
          description: |
            The shareable viewer page on img.pro (e.g. `https://img.pro/abc12345`) —
            the link you send to a person to view the image. Always present. A
            private image still has a `page_url`, but the page returns 404 for
            viewers without access.
        sizes:
          type: object
          description: |
            Responsive variants — `small`, `medium`, `large` — aspect-preserved,
            on every plan. For a social/OG card, use the `?size=social` transform
            (see /api/transforms). Empty `{}` for non-transformable sources
            (SVG/BMP/ICO).
          properties:
            small:
              $ref: '#/components/schemas/ImageSize'
            medium:
              $ref: '#/components/schemas/ImageSize'
            large:
              $ref: '#/components/schemas/ImageSize'
        filename:
          type: string
          description: |
            Original filename for download UX; falls back to
            `{id}.{format}` when the upload had none. Always present.
        format:
          type: string
          enum: [jpg, png, webp, avif, gif, svg, bmp, ico]
          description: |
            OUTPUT format, normalized — a stored HEIC serves as `jpg`, never
            `heic`. Non-transformable sources (SVG/BMP/ICO) serve as-is, so
            their format is the source extension. Useful for filenames or
            MIME-type guesses without an extra HEAD request.
        width:
          type: [integer, 'null']
          description: Source width in pixels. `null` while `status` is `processing`. Always present.
        height:
          type: [integer, 'null']
          description: Source height in pixels. `null` while `status` is `processing`. Always present.
        bytes:
          type: [integer, 'null']
          description: Stored file size in bytes. `null` while `status` is `processing`. Always present.
        transformable:
          type: boolean
          description: |
            `false` for sources that can't be CDN-transformed (SVG/BMP/ICO);
            `true` for every other supported format. Always present.
        status:
          type: string
          enum: [ready, processing]
          description: |
            `ready` once processing finishes; `processing` while a queued
            format is converting (`width`/`height`/`bytes` are `null` until
            ready). A terminal processing failure never serves as an object —
            a direct GET answers `422 media_failed` instead.
        public:
          type: boolean
          description: |
            Whether the viewer page is publicly accessible. Always present, and
            the read-back that confirms what a create resolved to — new images
            default to public in ordinary img.pro workspaces and private in
            app-provisioned ones unless the request set `public` explicitly.

            When `false`, `page_url` is STILL returned but 404s for anyone who
            isn't a member of the owning workspace, while `url` and every
            `sizes` variant keep serving normally.
        published_at:
          type: string
          format: date-time
          description: |
            Publish/display timestamp — ISO-8601 UTC (e.g. `2024-01-01T00:00:00Z`).
            Never `null`: defaults to the upload time when omitted. Drives list
            ordering — `GET /v1/images` sorts by it newest-first, so backdating
            or re-stamping re-sorts the item. Set on create/PATCH via the
            `published_at` input (a unix timestamp OR ISO-8601 date — inputs
            are lenient, output is always ISO-8601 UTC).
        expires_at:
          type: [string, 'null']
          format: date-time
          description: Auto-delete timestamp — ISO-8601 UTC. `null` = permanent. Always present.
        created_at:
          type: string
          format: date-time
          description: Upload timestamp — ISO-8601 UTC (e.g. `2024-01-01T00:00:00Z`). Always present.
        caption:
          type: [string, 'null']
          description: User-editable caption / description. `null` when unset. Always present.
        metadata:
          type: object
          additionalProperties: true
          description: |
            Open string→string map of your fields (author, license, source_url,
            tool, title, …) — NESTED, so it can never shadow a core
            field. Always present; may be `{}`. Limits: ≤ 50 keys, key ≤ 64
            chars, value ≤ 1024 chars (exceeding any → 422 validation_error).
        labels:
          type: object
          additionalProperties:
            type: string
          description: |
            Bounded selector map — the QUERYABLE sibling of `metadata`. A small
            string→string map you filter a list on (`GET /v1/images?label[k]=v`,
            equality + IN). Always present; may be `{}`. Limits: ≤ 20 keys, key
            `^[a-z0-9_.-]{1,64}$`, value ≤ 128 chars, no commas or
            surrounding whitespace (exceeding any → 422 validation_error). Written
            nested with merge / null-clear, exactly like `metadata`.
        nsfw:
          type: boolean
          description: Flagged as NSFW by the moderation pipeline (read-only). Always present; `true` when flagged, else `false`.

    MediaUpdate:
      type: object
      description: |
        PATCH accepts `caption`, `public`, `ttl`, `published_at`, your
        attribution `metadata`, and your selector `labels`. Optional
        `if_labels` makes the write conditional on current selector values. An
        omitted field is left unchanged. Custom fields are set ONLY through the nested `metadata`
        object; selectors ONLY through the nested `labels` object — both canonical,
        identical to the Image response. A `null` value inside either deletes that
        key. An unknown top-level field is rejected with 422, as are the retired
        upload-time fields `tool`, `defaults`, `filename`, and `nsfw`.
        (A literal whole-object GET→PATCH round-trip is NOT tolerated: `filename`
        and `nsfw` are always-present read-only response fields but are 422-rejected
        on write, so strip them first. The other read-only response fields — `id`,
        `object`, `url`, `sizes`, … — are ignored.)
      properties:
        caption:
          type: string
          maxLength: 5000
          description: Free-text caption / description. Send `null` or `""` to clear. Over-length values are rejected with 422.
        public:
          $ref: '#/components/schemas/VisibilityInput'
          description: |
            Flip the media's public/private state. Accepted JSON values are
            booleans `true` / `false`, numbers `1` / `0`, or strings
            `"true"` / `"false"` / `"1"` / `"0"`; letter forms are
            case-insensitive and strings may have surrounding whitespace.
            Null, empty strings, and every other value return 422. Omit to
            leave visibility unchanged. Also settable at create time — see
            `POST /v1/images`.
        ttl:
          type: string
          description: Auto-delete after duration (e.g., `24h`, `7d`). `null` makes the image permanent.
        published_at:
          type: string
          description: Publish date (unix seconds or ISO-8601, e.g. `2024-06-01`) — updates `published_at`.
        metadata:
          type: object
          additionalProperties:
            type: [string, 'null']
          description: |
            Your attribution fields as a nested string→string map — the only
            channel for custom fields. A `null` value deletes that key; keys you
            don't mention are left unchanged. Limits: ≤ 50 keys,
            key ≤ 64 chars, value ≤ 1024 chars (exceeding any → 422).
        labels:
          type: object
          additionalProperties:
            type: [string, 'null']
          description: |
            Your selector labels as a nested string→string map — the queryable
            sibling of `metadata`. Merge + null-clear semantics are identical. A
            `null` value deletes that key. Limits: ≤ 20 keys, key
            `^[a-z0-9_.-]{1,64}$`, value ≤ 128 chars, no commas or
            surrounding whitespace (exceeding any → 422).
        if_labels:
          type: object
          additionalProperties:
            type: string
          minProperties: 1
          description: |
            Atomic compare-and-set guard. Every supplied label must still equal
            the given string when the PATCH commits; otherwise no fields change
            and the request returns 409 state_conflict. Uses the same key/value
            bounds as labels; null and empty values are rejected.
      additionalProperties: false

    ImageListResponse:
      type: object
      required: [object, data, pagination]
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/Image'
        pagination:
          type: object
          required: [has_more, next_cursor, next_url]
          properties:
            has_more:
              type: boolean
            next_cursor:
              type: [string, 'null']
              description: 'Opaque cursor for the next page; null at end of list.'
            next_url:
              type: [string, 'null']
              description: 'Ready-to-fetch URL for the next page; null at end of list.'

    BatchResult:
      type: object
      required: [object, data, errors]
      description: |
        One envelope for batch update AND delete. `200` when every item
        succeeds, `207` on partial failure. Update → `data` holds the updated
        Image objects. Delete → `data` holds tombstones (idempotent: every
        requested id reports `deleted: true`, gone-now or already-gone alike —
        unlike a single DELETE of a missing id, which answers 404). Per-item
        `errors` use the same nested error object as every other error; empty
        when all succeed.
      properties:
        object:
          type: string
          enum: [batch_result]
        data:
          type: array
          items:
            oneOf:
              - $ref: '#/components/schemas/Image'
              - type: object
                required: [id, object, deleted]
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    enum: [image]
                  deleted:
                    type: boolean
        errors:
          type: array
          description: Per-item failures (e.g. an item moderation-locked on update). Empty array when every item succeeds.
          items:
            type: object
            required: [id, error]
            properties:
              id:
                type: string
              error:
                type: object
                required: [type, code, message]
                properties:
                  type:
                    type: string
                  code:
                    type: string
                  message:
                    type: string

    Error:
      type: object
      required: [error]
      description: |
        Every error — top-level and per batch item — is one nested `error`
        object. Branch on `error.type` (coarse category) or switch on
        `error.code` (specific). Success responses never carry an error.
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, permission_error, rate_limit_error, quota_error, idempotency_error, processing_error, api_error]
              description: Coarse category — branch on this.
            code:
              type: string
              description: Specific machine-readable code (frozen registry; additive-only).
            message:
              type: string
              description: Human-readable explanation.
            action:
              type: object
              description: What to do next (optional). Errors only — never on success responses.
              properties:
                type:
                  type: string
                  enum: [upgrade, signup, wait]
                url:
                  type: string
                  format: uri
                label:
                  type: string
                  description: Button text for humans
                message:
                  type: string
                  description: Longer description
                retry_after:
                  type: integer
                  description: Seconds to wait before retrying (type=wait; mirrors the Retry-After header)
            usage:
              type: object
              description: Current usage (quota errors)
              properties:
                plan:
                  type: string
                uploads_used:
                  type: integer
                uploads_limit:
                  type: integer
                storage_used_bytes:
                  type: integer
                storage_limit_bytes:
                  type: integer
            details:
              type: object
              additionalProperties:
                type: array
                items:
                  type: string
              description: Field-level error messages (validation only)

  responses:
    QuotaExceeded:
      description: Quota exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            upload_limit:
              summary: Upload quota reached
              value:
                error:
                  type: quota_error
                  code: quota_exceeded
                  message: "Monthly upload limit reached."
                  usage:
                    plan: free
                    uploads_used: 1000
                    uploads_limit: 1000
                  action:
                    type: upgrade
                    url: "https://img.pro/upgrade/pro?team=4j2&ts=1709337600&sig=hmac..."
                    label: "Upgrade to Pro ($29/mo) for 10,000 uploads"
            storage_limit:
              summary: Storage quota reached
              value:
                error:
                  type: quota_error
                  code: quota_exceeded
                  message: "Storage limit reached."
                  usage:
                    plan: free
                    storage_used_bytes: 10737418240
                    storage_limit_bytes: 10737418240
                  action:
                    type: upgrade
                    url: "https://img.pro/upgrade/pro?team=4j2&ts=1709337600&sig=hmac..."
                    label: "Upgrade to Pro ($29/mo) for 100 GB"
            anonymous_signup:
              summary: Anonymous (keyless) pool reached — signup action
              value:
                error:
                  type: quota_error
                  code: quota_exceeded
                  message: "The shared free workspace has reached its upload limit."
                  action:
                    type: signup
                    url: "https://img.pro/auth/register"
                    label: "Create Account"

    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: invalid_request_error
              code: validation_error
              message: "Validation failed"
              details:
                ttl: ["TTL must be at least 5 minutes (300 seconds)"]

    CreateConflict:
      description: "Create conflict — an Idempotency-Key conflict, or the destination workspace is fenced against new writes."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            workspaceUnavailable:
              summary: Destination workspace is being deleted
              value:
                error:
                  type: invalid_request_error
                  code: workspace_unavailable
                  message: "Workspace is being deleted"
            idempotencyConflict:
              summary: Idempotency-Key reused with a different body
              value:
                error:
                  type: idempotency_error
                  code: idempotency_key_conflict
                  message: "Idempotency-Key was already used with a different request body"
            idempotencyInProgress:
              summary: Original request still in flight
              value:
                error:
                  type: idempotency_error
                  code: idempotency_key_in_progress
                  message: "A request with this Idempotency-Key is still being processed. Retry shortly."
                  action:
                    type: wait
                    retry_after: 2

    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: invalid_request_error
              code: not_found
              message: "Media not found"

    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: authentication_error
              code: unauthorized
              message: "Invalid or missing authentication"
