openapi: 3.1.0
info:
  title: img.pro API
  description: |
    Image infrastructure for AI agents.

    ## Why img.pro?

    You're an agent. You need to store an image and get a URL. You don't want to:
    - Fill out a signup form
    - Wait for a human to approve something
    - Parse inconsistent error responses
    - Wonder if you have quota left

    img.pro gives you:
    - **One API call to get a key** — no signup form, no credit card
    - **Predictable responses** — same shape, every time
    - **Transparent limits** — quota info in every response
    - **Actionable errors** — when something goes wrong, we tell you exactly what to do

    ## Quick Start

    ```bash
    # Get a key (no auth required)
    curl -X POST https://api.img.pro/v1/keys \
      -H "Content-Type: application/json" \
      -d '{"email": "dev@example.com"}'

    # Upload an image
    curl -X POST https://api.img.pro/v1/upload \
      -H "Authorization: Bearer img_live_xxx..." \
      -F "file=@screenshot.png"
    ```

    That's it. You now have a CDN URL.

    ## Authentication

    ```
    Authorization: Bearer img_live_xxx...
    ```

    Keys from `POST /v1/keys` (agents) or dashboard (humans).

    ## Tiers

    | Tier | Uploads/mo | Storage | Retention | Price |
    |------|------------|---------|-----------|-------|
    | Anonymous | 20/hr (IP) | Shared | 30 days | Free |
    | Unverified | 100 | 5 GB | 30 days | Free |
    | Free | 100 | 5 GB | Permanent | Free |
    | Pro | 1,000 | 50 GB | Permanent | $10/mo |
    | Scale | 10,000 | 500 GB | Permanent | $49/mo |
    | Max | 100,000 | 5 TB | Permanent | $299/mo |

    **Anonymous → Unverified**: Agent creates key via `POST /v1/keys` with email.
    **Unverified → Free**: Human verifies email (we send a link).
    **Free → Pro → Scale → Max**: Human clicks upgrade URL from quota error response.

    ## Quota Headers

    Every authenticated response includes:
    ```
    X-Monthly-Uploads-Used: 42
    X-Monthly-Uploads-Limit: 100
    X-Monthly-Uploads-Remaining: 58
    X-Storage-Used: 52428800
    X-Storage-Limit: 5368709120
    X-Storage-Remaining: 5316280320
    ```

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

    ## Error Responses

    All errors follow the same shape:
    ```json
    {
      "error": "error_code",
      "message": "Human-readable explanation",
      "action": { ... }
    }
    ```

    The `action` object tells you exactly what to do next.

    ## CDN URLs

    - Original: `https://src.img.pro/{team}/{uid}.{ext}`
    - Resized: `https://src.img.pro/{team}/{uid}.{ext}?size=m`

    Sizes: `s` (320px), `m` (640px), `l` (1080px, default)

  version: 2.0.0
  contact:
    name: img.pro
    url: https://img.pro
    email: api@img.pro

servers:
  - url: https://api.img.pro
    description: Production
  - url: https://test.api.img.pro
    description: Test (isolated data, same API)

tags:
  - name: Keys
    description: Get API keys
  - name: Upload
    description: Store images
  - name: Media
    description: Manage images
  - name: Usage
    description: Check quotas

paths:
  /v1/keys:
    post:
      tags: [Keys]
      summary: Get API Key
      description: |
        Get an API key. No authentication required.

        **This is how agents onboard.** One call, instant key.

        The key works immediately with unverified limits (100 uploads, 30-day retention).
        When the human verifies their email, limits upgrade automatically.

        **Existing accounts:**
        - Unverified: New key added (max 3 active)
        - Verified + open policy: New key created, owner notified
        - Verified + approval policy: Key created as `pending`, owner must approve
        - Verified + closed policy: 403 with dashboard URL

        **Rate limits:** 5/email/hour, 20/IP/hour
      operationId: createKey
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: Email for the account
                name:
                  type: string
                  maxLength: 100
                  default: "Default"
                  description: Label for this key
      responses:
        '201':
          description: Key created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyResponse'
        '403':
          description: Registration blocked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                closed:
                  value:
                    error: registration_closed
                    message: "This account does not accept new API key registrations."
                    action:
                      type: redirect
                      url: "https://img.pro/keys"
                      label: "Request key from dashboard"
                key_limit:
                  value:
                    error: key_limit_reached
                    message: "Unverified accounts can have 3 active keys. Verify your email to create more."
                    action:
                      type: verify
                      url: "https://img.pro/verify?t=abc&exp=1709337600&sig=hmac..."
                      label: "Verify email to remove limit"
        '422':
          description: Invalid email
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: validation_error
                message: "A valid email address is required"
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: rate_limited
                message: "Too many key creation requests. Try again later."
                action:
                  type: wait
                  seconds: 3600

  /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.
      operationId: getUsage
      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'

  /v1/upload:
    post:
      tags: [Upload]
      summary: Upload Image
      description: |
        Upload an image file. **Authentication is optional.**

        Without a Bearer token, uploads go to a shared pool with a 30-day TTL and 20MB limit.
        Rate limited to 20/hour and 100/day per IP. Response includes an `upgrade` hint.

        **Formats:** JPEG, PNG, GIF, WebP, HEIC, AVIF, 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.
      operationId: uploadImage
      security:
        - bearerAuth: []
        - {}
      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`)
                date:
                  type: string
                  description: Editorial display date (e.g., `1969-07-20`)
                tags:
                  type: string
                  description: Comma-separated (e.g., `screenshot,ui`)
                public:
                  type: string
                  enum: ["true", "false"]
                  default: "true"
                  description: Show public viewer page (url field). CDN src is always accessible to the owner.
                namespace:
                  type: string
                  maxLength: 64
                  pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$'
                  description: Group images by project (e.g., `chat-session-123`)
                external_id:
                  type: string
                  description: External dedup key (e.g., `apod:1995-06-16`). Prevents duplicate uploads.
              additionalProperties:
                type: string
                description: Any additional fields are stored as metadata (e.g., `description`, `author`, `license`)
      responses:
        '201':
          description: Upload accepted
          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/MediaResponse'
        '403':
          $ref: '#/components/responses/QuotaExceeded'
        '409':
          $ref: '#/components/responses/DuplicateExternalId'
        '422':
          $ref: '#/components/responses/ValidationError'

  /v1/import:
    post:
      tags: [Upload]
      summary: Import from URL
      description: |
        Import an image from a URL. **Authentication is optional.**

        Without a Bearer token, imports go to a shared pool with a 30-day TTL and 20MB limit.
        We fetch it server-side (30s timeout, follows redirects).
        Same response as upload.
      operationId: importImage
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                ttl:
                  type: string
                name:
                  type: string
                  description: Override filename
                date:
                  type: string
                  description: Editorial display date (e.g., `1969-07-20`)
                tags:
                  type: string
                public:
                  type: boolean
                  default: true
                  description: Show public viewer page (url field). CDN src is always accessible to the owner.
                namespace:
                  type: string
                external_id:
                  type: string
                  description: External dedup key (e.g., `apod:1995-06-16`). Prevents duplicate imports.
              additionalProperties:
                type: string
                description: Any additional fields are stored as metadata (e.g., `description`, `author`, `license`)
      responses:
        '201':
          description: Import accepted
          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/MediaResponse'
        '403':
          $ref: '#/components/responses/QuotaExceeded'
        '409':
          $ref: '#/components/responses/DuplicateExternalId'
        '502':
          description: Fetch failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: fetch_failed
                message: "Could not fetch URL (404)"

  /v1/media:
    get:
      tags: [Media]
      summary: List Images
      description: |
        List images with filtering.

        Use `namespace` to scope to a project.
        Use `cursor` for pagination (more reliable than offset for large sets).
      operationId: listMedia
      parameters:
        - name: namespace
          in: query
          schema:
            type: string
        - name: tags
          in: query
          schema:
            type: string
          description: Comma-separated
        - name: tag_mode
          in: query
          schema:
            type: string
            enum: [any, all]
            default: any
        - name: ids
          in: query
          schema:
            type: string
          description: Comma-separated IDs to fetch specific items
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          schema:
            type: string
          description: Pagination cursor (preferred over offset)
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
          description: Skip N items (for backward compatibility)
      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/MediaListResponse'

  /v1/media/{id}:
    get:
      tags: [Media]
      summary: Get Image
      operationId: getMedia
      security:
        - bearerAuth: []
        - {}
      parameters:
        - $ref: '#/components/parameters/MediaId'
      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/MediaResponse'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Media]
      summary: Update Image
      operationId: updateMedia
      parameters:
        - $ref: '#/components/parameters/MediaId'
      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/MediaResponse'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Media]
      summary: Delete Image
      operationId: deleteMedia
      parameters:
        - $ref: '#/components/parameters/MediaId'
      responses:
        '204':
          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'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/media/batch:
    patch:
      tags: [Media]
      summary: Batch Update
      operationId: batchUpdateMedia
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: string
                  maxItems: 100
                name:
                  type: string
                date:
                  type: string
                tags:
                  type: string
                tag_mode:
                  type: string
                  enum: [replace, add, remove]
                  default: replace
                public:
                  type: boolean
                namespace:
                  type: string
                external_id:
                  type: string
              additionalProperties:
                type: string
                description: Any additional fields are merged into each item's existing metadata
      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/BatchUpdateResponse'

    delete:
      tags: [Media]
      summary: Batch Delete
      description: |
        Delete by IDs or by namespace.

        **By IDs:** `{"ids": ["a", "b", "c"]}`
        **By namespace:** `{"namespace": "old-project"}`

        Namespace deletion is paginated (1000/call). Check `has_more`.
      operationId: batchDeleteMedia
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: string
                  maxItems: 100
                namespace:
                  type: string
      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/BatchDeleteResponse'

  /v1/namespaces:
    get:
      tags: [Usage]
      summary: List Namespaces
      description: See all namespaces with counts and storage.
      operationId: listNamespaces
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
            maximum: 1000
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Namespace 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/NamespaceListResponse'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

  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

  schemas:
    KeyResponse:
      type: object
      required: [key, key_id, status, verified]
      properties:
        key:
          type: string
          description: "API key (shown once). Format: `img_live_xxx` or `img_test_xxx`"
        key_id:
          type: string
          description: "Public identifier (e.g., `key_7x9k2m`)"
        status:
          type: string
          enum: [active, pending]
        verified:
          type: boolean
          description: Whether email is verified
        limits:
          type: object
          description: Current limits (shown for unverified)
          properties:
            uploads:
              type: integer
            storage_mb:
              type: integer
            retention_days:
              type: integer
        message:
          type: string

    UsageResponse:
      type: object
      properties:
        monthly:
          type: object
          properties:
            uploads:
              type: integer
            uploads_limit:
              type: integer
            uploads_remaining:
              type: integer
            resets_at:
              type: integer
              description: Unix timestamp
        totals:
          type: object
          properties:
            media_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]
        verified:
          type: boolean
          description: Whether account is verified

    MediaResponse:
      type: object
      required: [id, name, created_at]
      properties:
        id:
          type: string
        name:
          type: string
        src:
          type: string
          format: uri
          description: CDN URL (omitted if failed)
        url:
          type: string
          format: uri
          description: Public short URL (only if public)
        width:
          type: integer
        height:
          type: integer
        filesize:
          type: integer
        status:
          type: string
          enum: [processing, failed]
          description: Omitted when ready
        editable:
          type: boolean
          description: Whether image supports transforms (only present if true)
        namespace:
          type: string
        tags:
          type: array
          items:
            type: string
        external_id:
          type: string
          description: External dedup key (only if set)
        date:
          type: string
          description: Editorial display date (only if set)
        expires_at:
          type: integer
          description: Unix timestamp (only if ephemeral)
        created_at:
          type: integer
          description: Unix timestamp
        sizes:
          type: object
          properties:
            s:
              $ref: '#/components/schemas/SizeInfo'
            m:
              $ref: '#/components/schemas/SizeInfo'
            l:
              $ref: '#/components/schemas/SizeInfo'
      additionalProperties:
        type: string
        description: Metadata fields (description, author, license, etc.) are included at the top level when present

    SizeInfo:
      type: object
      properties:
        src:
          type: string
        width:
          type: integer
        height:
          type: integer
        filesize:
          type: integer

    MediaUpdate:
      type: object
      properties:
        name:
          type: string
        date:
          type: string
        tags:
          type: string
        public:
          type: boolean
        namespace:
          type: string
        external_id:
          type: string
      additionalProperties:
        type: string
        description: Any additional fields are merged with existing metadata. Set to null to remove.

    MediaListResponse:
      type: object
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/MediaResponse'
        next_cursor:
          type: string
          description: Present when has_more is true
        has_more:
          type: boolean

    BatchUpdateResponse:
      type: object
      properties:
        updated:
          type: integer
        media:
          type: array
          items:
            $ref: '#/components/schemas/MediaResponse'

    BatchDeleteResponse:
      type: object
      properties:
        deleted:
          type: integer
        not_found:
          type: integer
        namespace:
          type: string
          description: Present for namespace deletions
        has_more:
          type: boolean
          description: Present for namespace deletions
        ids:
          type: object
          properties:
            deleted:
              type: array
              items:
                type: string
            not_found:
              type: array
              items:
                type: string

    NamespaceListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            type: object
            properties:
              namespace:
                type: string
              count:
                type: integer
              storage_bytes:
                type: integer
        next_offset:
          type: integer
          description: Present when more results available

    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable code
        message:
          type: string
          description: Human-readable explanation
        action:
          type: object
          description: What to do next
          properties:
            type:
              type: string
              enum: [upgrade, verify, redirect, wait]
            url:
              type: string
              format: uri
            label:
              type: string
              description: Button text for humans
            seconds:
              type: integer
              description: For type=wait
        usage:
          type: object
          description: Current usage (for quota errors)
          properties:
            plan:
              type: string
            uploads_used:
              type: integer
            uploads_limit:
              type: integer
            storage_used_bytes:
              type: integer
            storage_limit_bytes:
              type: integer
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          description: Field errors (for validation)

  responses:
    QuotaExceeded:
      description: Quota exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            verified_upload_limit:
              summary: Verified user hits upload limit
              value:
                error: quota_exceeded
                message: "Monthly upload limit reached."
                usage:
                  plan: free
                  uploads_used: 100
                  uploads_limit: 100
                action:
                  type: upgrade
                  url: "https://img.pro/upgrade?t=abc123&exp=1709337600&sig=hmac..."
                  label: "Upgrade to Pro ($10/mo) for 1,000 uploads"
            verified_storage_limit:
              summary: Verified user hits storage limit
              value:
                error: quota_exceeded
                message: "Storage limit reached."
                usage:
                  plan: free
                  storage_used_bytes: 5368709120
                  storage_limit_bytes: 5368709120
                action:
                  type: upgrade
                  url: "https://img.pro/upgrade?t=abc123&exp=1709337600&sig=hmac..."
                  label: "Upgrade to Pro ($10/mo) for 50 GB"
            unverified_upload_limit:
              summary: Unverified user hits limit
              value:
                error: quota_exceeded
                message: "Unverified upload limit reached. Verify your email to unlock permanent storage."
                usage:
                  plan: free
                  uploads_used: 100
                  uploads_limit: 100
                action:
                  type: verify
                  url: "https://img.pro/verify?t=abc&exp=1709337600&sig=hmac..."
                  label: "Verify email for permanent storage"
            unverified_storage_limit:
              summary: Unverified user hits storage
              value:
                error: quota_exceeded
                message: "Unverified storage limit reached. Verify your email to unlock permanent storage."
                usage:
                  plan: free
                  storage_used_bytes: 5368709120
                  storage_limit_bytes: 5368709120
                action:
                  type: verify
                  url: "https://img.pro/verify?t=abc&exp=1709337600&sig=hmac..."
                  label: "Verify email for permanent storage"

    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: validation_error
            message: "Invalid namespace"
            errors:
              namespace: ["Must be lowercase alphanumeric with hyphens"]

    DuplicateExternalId:
      description: Duplicate external ID
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: external_id_duplicate
            message: "external_id already exists"

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

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