openapi: "3.1.0"

info:
  title: Shotbot Screenshot API
  version: "2"
  description: |
    Capture any public URL as JPEG, PNG, WebP, AVIF, or PDF.

    **Authentication**: include your 12-character alphanumeric API key as `key` in every POST request body.
    No HTTP headers required.

    **Result CDN**: `https://static.shotbot.net/{token[0]}/{token[0..1]}/{token}.{format}`
    Example: token `4XyZaBd2…` → `https://static.shotbot.net/4/4X/4XyZaBd2….jpg`

    **Pro subscription** unlocks all formats (PNG / WebP / AVIF / PDF), custom viewport widths
    (280-3840 px), URLs with custom TCP ports, wait up to 30 s, and advanced options
    (`scroll_before_capture`, `block_ads`, `http_auth`, `crop_height`,
    `cookies`, `selector`, `autoplay_videos`, `omit_background`,
    `emulate_print_media`, non-default `render_region`).
    Subscribe at https://shotbot.net/pro/

    **Typical flow**
    1. `POST /capture` → get `token`
    2. Poll `GET /capture/{token}` every ~2 s until `status` is `done` or `failed`
    3. Download `image` URL from the CDN

    Or use `POST /capture/callback` and let Shotbot push the result to your webhook.

    **Private captures** (`private: true`): the result is **not** published on
    `static.shotbot.net`. The done response carries a `download` URL on
    `api.shotbot.net` that streams the file; you can fetch it as many times as
    you like until it auto-expires after a short retention window.
    Useful when the captured page is sensitive (intranet, account dashboards…).
  contact:
    url: https://shotbot.net/contact/
  termsOfService: https://shotbot.net/terms-of-sale/
  license:
    name: Proprietary
    url: https://shotbot.net/terms-of-sale/

servers:
  - url: https://api.shotbot.net

tags:
  - name: Single
    description: Submit and poll a single capture
  - name: Batch
    description: Submit up to 500 (Pro 5000) captures in one request
  - name: Account
    description: Account info (plan, credits, quota)

paths:

  /capture:
    post:
      summary: Submit a capture
      operationId: createCapture
      tags: [Single]
      description: |
        Submit a URL for screenshot capture. Returns immediately with a `token`.
        Poll `GET /capture/{token}` until `status` is `done` or `failed`.

        If the same URL was already submitted within the last 60 seconds by the same account,
        the existing `token` is returned (`deduplicated: true`) without consuming quota.

        If quota and credits are both exhausted, the request is queued in a wait list
        (`status: waitlisted`) and will be processed at the next monthly quota reset.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptureRequest'
            examples:
              minimal:
                summary: Minimal
                value:
                  key: "aB3kR7mZx9Lq"
                  url: "https://example.com"
              pro:
                summary: Pro - WebP, custom viewport, dismiss cookies
                value:
                  key: "aB3kR7mZx9Lq"
                  url: "https://example.com"
                  viewport_width: 1440
                  output_size: 1440
                  format: "webp"
                  ratio: "16:9"
                  wait_time: 30
                  dismiss_cookies: "accept"
      responses:
        "202":
          description: Accepted - queued, deduplicated, or waitlisted
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CaptureQueued'
                  - $ref: '#/components/schemas/CaptureDeduplicated'
                  - $ref: '#/components/schemas/CaptureWaitlisted'
              examples:
                queued:
                  summary: New job queued
                  value:
                    job_id: 1843221
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    status: "queued"
                    eta_seconds: 17
                deduplicated:
                  summary: Matched existing job
                  value:
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    status: "queued"
                    deduplicated: true
                waitlisted:
                  summary: Quota exhausted
                  value:
                    status: "waitlisted"
                    detail: "Monthly quota and credits exhausted. Request queued and will be processed at next quota reset."
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/ProRequired'
        "405":
          $ref: '#/components/responses/MethodNotAllowed'
        "429":
          $ref: '#/components/responses/ConcurrencyLimit'

  /capture/callback:
    post:
      summary: Submit a capture with webhook callback
      operationId: createCaptureCallback
      tags: [Single]
      description: |
        Submit a URL for capture with webhook delivery of the result.
        No polling required - Shotbot POSTs the result image (multipart) to your `callback_url`
        when the capture is complete.

        If `callback_secret` is set, the worker echoes it back in the callback POST.
        Verify authenticity on your side with `hash_equals($secret, $received_secret)`.

        On failure the worker POSTs `status=ERR` to your callback URL.

        The `callback_url` must use HTTPS and resolve to a public IP (SSRF protection).
        DNS rebinding is mitigated by re-validation on the worker side.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CallbackRequest'
            examples:
              basic:
                summary: With callback
                value:
                  key: "aB3kR7mZx9Lq"
                  url: "https://example.com"
                  callback_url: "https://myapp.example.com/webhooks/shotbot"
                  callback_secret: "s3cr3t"
      responses:
        "202":
          description: Queued - result will be POSTed to callback_url
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureQueued'
              example:
                job_id: 1843222
                token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                status: "queued"
                eta_seconds: 17
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/ProRequired'
        "405":
          $ref: '#/components/responses/MethodNotAllowed'
        "429":
          $ref: '#/components/responses/ConcurrencyLimit'

  /capture/{token}:
    get:
      summary: Poll capture status
      operationId: getCaptureStatus
      tags: [Single]
      description: |
        Poll the status of a submitted capture. No API key required - the 32-character token
        is a bearer: anyone who holds it can read the status and download the result.

        Poll every ~2 seconds. A `queued` response includes `eta_seconds` as a hint.

        Result image URLs follow the pattern:
        `https://static.shotbot.net/{token[0]}/{token[0..1]}/{token}.{format}`
      parameters:
        - name: token
          in: path
          required: true
          description: 32-character alphanumeric token returned by POST /capture
          schema:
            type: string
            pattern: '^[A-Za-z0-9]{32}$'
            example: "4XyZaBd2E1lk39PQ7m8r5wN0c6f4H2g1"
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/StatusQueued'
                  - $ref: '#/components/schemas/StatusProcessing'
                  - $ref: '#/components/schemas/StatusDone'
                  - $ref: '#/components/schemas/StatusFailed'
              examples:
                queued:
                  summary: Still in queue
                  value:
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    status: "queued"
                    url: "https://example.com"
                    format: "jpg"
                    viewport_width: 1280
                    output_size: 1280
                    output_height: 720
                    ratio: "16:9"
                    fullpage: false
                    eta_seconds: 18
                done:
                  summary: Ready - image URLs available
                  value:
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    status: "done"
                    url: "https://example.com"
                    format: "jpg"
                    viewport_width: 1280
                    output_size: 1280
                    output_height: 720
                    ratio: "16:9"
                    fullpage: false
                    image: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4.jpg"
                    preview: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_p.webp"
                    meta_http_status: 200
                    meta_final_url: "https://www.example.com/"
                    meta_page_title: "Example Domain"
                    file_hash: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
                    captured_at: 1746268800
                failed_partial:
                  summary: Timeout with partial screenshot
                  value:
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    status: "failed"
                    url: "https://slow-site.example.com"
                    format: "jpg"
                    viewport_width: 1280
                    output_size: 1280
                    output_height: 720
                    ratio: "16:9"
                    fullpage: false
                    error_code: "nav_timeout"
                    image: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4.jpg"
                    preview: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_p.webp"
                    partial: true
        "400":
          description: Invalid token format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "invalid_token"
        "404":
          description: Token not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "not_found"
        "405":
          $ref: '#/components/responses/MethodNotAllowed'

  /capture/{token}/file:
    get:
      summary: Download a private capture
      operationId: downloadPrivateCapture
      tags: [Single]
      description: |
        Download the binary result of a **private capture** (`private: true` on
        submission). The file is **not** deleted on read: you can fetch it as
        many times as you like until it auto-expires after a short retention
        window, after which you get `410 Gone`.

        This endpoint is the only way to retrieve a private capture: the bytes
        are not on `static.shotbot.net`, and there is no other URL.

        Authentication is by token: the 32-character alphanumeric token returned at
        submission acts as a bearer credential. Anyone holding it can read the file.
      parameters:
        - name: token
          in: path
          required: true
          description: "32-character alphanumeric token from POST /capture (with `private: true`)."
          schema:
            type: string
            pattern: '^[A-Za-z0-9]{32}$'
      responses:
        "200":
          description: "Streams the captured file. `Content-Disposition: attachment` is set."
          content:
            image/jpeg:  { schema: { type: string, format: binary } }
            image/png:   { schema: { type: string, format: binary } }
            image/webp:  { schema: { type: string, format: binary } }
            image/avif:  { schema: { type: string, format: binary } }
            application/pdf: { schema: { type: string, format: binary } }
        "404":
          description: Token not found, or token belongs to a non-private capture.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                not_found:
                  value: { error: "not_found" }
                not_private:
                  value:
                    error: "not_private"
                    detail: "this token is for a public capture, fetch via the `image` URL on /capture/{token}"
        "409":
          description: Capture is not ready yet (still queued or processing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                not_ready:
                  value: { error: "not_ready", status: "processing" }
        "410":
          description: |
            File is gone. The private capture has expired and been purged by
            background cleanup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "gone"
                detail: "private capture expired (files are kept for a limited time then purged)"
        "405":
          $ref: '#/components/responses/MethodNotAllowed'

  /account:
    get:
      summary: Get account status
      operationId: getAccount
      tags: [Account]
      description: |
        Returns the current plan, credit balance, monthly quota usage,
        and number of captures in flight for the authenticated account.

        Authentication: pass the 12-character alphanumeric API key as `?key=` query
        parameter or as an `Authorization: Bearer <key>` header.
      parameters:
        - name: key
          in: query
          required: false
          description: "12-character alphanumeric API key (required if no Bearer header)."
          schema:
            type: string
            pattern: '^[0-9A-Za-z]{12}$'
      security: []
      responses:
        "200":
          description: Account info
          content:
            application/json:
              schema:
                type: object
                required: [plan, credit, quota_used, quota_total, quota_remaining, inflight, inflight_cap]
                properties:
                  plan:
                    type: string
                    enum: [free, pro]
                    description: Current subscription plan.
                  pro_until:
                    type: [integer, "null"]
                    description: Unix timestamp of Pro expiry. `null` for free accounts.
                  credit:
                    type: integer
                    description: Extra credits available beyond the monthly quota.
                  quota_used:
                    type: integer
                    description: Captures consumed from the monthly quota today.
                  quota_total:
                    type: integer
                    description: Total monthly quota (may be higher than plan default if admin-overridden).
                  quota_remaining:
                    type: integer
                    description: Captures still available from the monthly quota.
                  inflight:
                    type: integer
                    description: Captures currently queued or processing for this account.
                  inflight_cap:
                    type: integer
                    description: Maximum concurrent captures allowed for this account tier.
              example:
                plan: "free"
                pro_until: null
                credit: 50
                quota_used: 12
                quota_total: 200
                quota_remaining: 188
                inflight: 1
                inflight_cap: 3
        "401":
          $ref: '#/components/responses/Unauthorized'
        "405":
          $ref: '#/components/responses/MethodNotAllowed'

  /capture/batch:
    post:
      summary: Submit a batch of captures
      operationId: createCaptureBatch
      tags: [Batch]
      description: |
        Submit up to 500 URLs (Pro accounts: up to 5000) in a single request.

        **Global options** (top-level) apply to all jobs. **Per-job options** override globals
        for that specific job.

        Quota is allocated atomically in a single transaction. If quota runs out mid-batch,
        remaining jobs go to the wait list. Deduplication (60 s window, same account + URL)
        is applied before quota consumption.

        The response contains a per-job `status` for each submitted URL:
        `queued`, `waitlisted`, or `error`.

        To receive results via webhook instead of polling, include a global `callback_url`.
        All jobs in the batch then become callback jobs.

        ### Idempotency

        Batch submission is expensive: large batches with slow DNS can exceed transport
        timeouts and produce 504 responses on the client side, while server-side processing
        continues. Naive retry then creates duplicate jobs.

        Send an `idempotency_key` (16-128 chars `[A-Za-z0-9_.-]`) to make retries safe:
        a second batch with the same key from the same account, within 24 h, returns the
        original response (`X-Idempotent-Replay: true` header) without creating new jobs
        or deducting quota.

        Recommended pattern: generate a UUID before the first call, retry with the same
        UUID on transport/parse errors, drop it on success.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchRequest'
            examples:
              simple:
                summary: Two URLs, shared format
                value:
                  key: "aB3kR7mZx9Lq"
                  format: "png"
                  viewport_width: 1280
                  jobs:
                    - url: "https://example.com"
                    - url: "https://example.org"
              with_override:
                summary: Global defaults + per-job override
                value:
                  key: "aB3kR7mZx9Lq"
                  format: "jpg"
                  viewport_width: 1280
                  jobs:
                    - url: "https://example.com"
                    - url: "https://example.org"
                      viewport_width: 390
                      format: "png"
              with_callback:
                summary: Batch with callback webhook
                value:
                  key: "aB3kR7mZx9Lq"
                  format: "jpg"
                  callback_url: "https://myapp.example.com/webhooks/shotbot"
                  callback_secret: "s3cr3t"
                  jobs:
                    - url: "https://example.com"
                    - url: "https://example.org"
      responses:
        "202":
          description: Batch processed - check per-job `status`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResponse'
              example:
                submitted: 2
                waitlisted: 0
                deduplicated: 1
                errors: 1
                eta_seconds: 25
                jobs:
                  - index: 0
                    url: "https://example.com"
                    token: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                    job_id: 1843221
                    status: "queued"
                  - index: 1
                    url: "https://example.org"
                    token: "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
                    status: "queued"
                    deduplicated: true
                  - index: 2
                    url: "https://example.net"
                    token: "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"
                    job_id: 1843222
                    status: "queued"
                  - index: 3
                    url: "http://192.168.1.1"
                    status: "error"
                    error: "invalid_url"
                    detail: "Only public http/https URLs are supported"
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/ProRequired'
        "405":
          $ref: '#/components/responses/MethodNotAllowed'

components:
  schemas:

    # ── Shared capture options ────────────────────────────────────────────────
    CaptureOptions:
      type: object
      properties:
        preset:
          type: string
          enum: ["", "og", "mobile", "youtube_thumbnail", "square", "reel", "pinterest", "tablet", "desktop", "desktop_hd", "twitter_header", "linkedin_banner", "hero_banner"]
          default: ""
          description: |
            Named output preset. When set, fills missing `viewport_width`,
            `output_size`, and `crop_height` with the preset's defaults.
            Explicit values still win over preset defaults.

            | Preset              | Output      | Tier |
            |---------------------|-------------|------|
            | `og`                | 1200×630    | free |
            | `mobile`            | 390×844     | free |
            | `youtube_thumbnail` | 1280×720    | free |
            | `square`            | 1080×1080   | free |
            | `reel`              | 1080×1920   | free |
            | `pinterest`         | 1000×1500   | free |
            | `tablet`            | 768×1024    | free |
            | `desktop`           | 1440×900    | Pro  |
            | `desktop_hd`        | 1920×1080   | Pro  |
            | `twitter_header`    | 1500×500    | Pro  |
            | `linkedin_banner`   | 1584×396    | Pro  |
            | `hero_banner`       | 1920×600    | Pro  |

            Omit `preset` (or pass `""`) for the default 1280×800 (16:10).
            `crop_height` from a preset is always accepted (otherwise Pro-only).
        viewport_width:
          type: integer
          minimum: 280
          maximum: 3840
          default: 1280
          description: |
            Browser viewport width in pixels. Default: 1280.
            Common labelled widths: 360, 375, 390, 414, 430, 768, 1024, 1200, 1280, 1440, 1920.
            Non-Pro accounts are limited to 390, 768, 1280.
            Shotbot Pro may also pass any custom width between 280 and 3840 px,
            useful for CSS / responsive testing at arbitrary breakpoints.
            A value outside the allowed set returns `400 invalid_viewport_width`.
            Filled automatically when `preset` is set and this field is omitted.
        output_size:
          type: integer
          enum: [120, 160, 240, 320, 390, 480, 640, 768, 1024, 1080, 1200, 1280, 1440, 1500, 1584, 1920]
          description: |
            Output image width in pixels. Defaults to `viewport_width` when omitted.
            Values not in the enum are replaced with `viewport_width`.
            Filled automatically when `preset` is set and this field is omitted.
        ratio:
          type: string
          enum: ["16:9", "4:3", "16:10", "3:2", "2:1", "1:1", "9:16", "3:4", "10:16", "2:3", "1:2"]
          default: "16:9"
          description: |
            Aspect ratio used to compute output height.
            Ignored when `fullpage` is true or `crop_height` is set.
        crop_height:
          type: integer
          minimum: 10
          maximum: 30000
          description: |
            Custom output height in pixels. Overrides `ratio`. Triggers a full-page
            capture; the result is cropped from the top to this height.
            Maximum: 20,000 px.
            **Pro only** when set explicitly. Free accounts can use it via the `preset`
            parameter (e.g. `preset=og` provides `crop_height=630` without Pro).
        fullpage:
          type: boolean
          default: false
          description: Capture the full page height (max 30,000 px). Ignores `ratio`.
        format:
          type: string
          enum: [jpg, png, webp, webp_lossless, avif, pdf]
          default: jpg
          description: |
            Output format. Non-Pro accounts are limited to `jpg`.
        hidpi:
          type: boolean
          default: false
          description: Render at 2× device pixel ratio (HiDPI / Retina).
        color_scheme:
          type: [string, "null"]
          enum: ["dark", "light", null]
          description: |
            Force a color scheme before capture.
            `"dark"` emits `prefers-color-scheme: dark`, `"light"` emits `prefers-color-scheme: light`.
            Omit (or pass `null`) to use the browser default.
        render_region:
          type: string
          enum: ["fr-paris", "ca-montreal", "sg-singapore", "au-sydney", "vn-hanoi"]
          default: fr-paris
          description: |
            Geographic region the rendering worker exits from. The browser is routed
            through a SOCKS5 proxy in the chosen region and its locale, timezone,
            and geolocation are emulated to match.

            Region key shape: `country-city` (ISO 3166 alpha-2 country code, then
            city name, dash-separated; multi-word cities use dashes too).

            | Value          | Egress              | Timezone            | Locale         |
            |----------------|---------------------|---------------------|----------------|
            | `fr-paris`     | Paris, France       | `Europe/Paris`      | `fr-FR`        |
            | `ca-montreal`  | Montreal, Canada    | `America/Montreal`  | `en-CA`        |
            | `sg-singapore` | Singapore           | `Asia/Singapore`    | `en-SG`        |
            | `au-sydney`    | Sydney, Australia   | `Australia/Sydney`  | `en-AU`        |
            | `vn-hanoi`     | Hanoi, Vietnam      | `Asia/Ho_Chi_Minh`  | `vi-VN`        |

            Use to capture geo-restricted content, regionally localised pages, currency
            or banner variants, or to verify what a page looks like from a given
            market. **Pro only** for non-`fr-paris` values; non-Pro accounts may omit
            the field or pass `"fr-paris"`.
        nojs:
          type: boolean
          default: false
          description: Disable JavaScript execution before capture.
        autoplay_videos:
          type: boolean
          default: false
          description: |
            Launch Chromium with `--autoplay-policy=no-user-gesture-required` so
            `<video autoplay>` elements start playing (including unmuted videos)
            without requiring a user gesture. Useful for capturing pages with
            hero videos that would otherwise show a static poster frame.
            Default Chromium policy blocks unmuted autoplay; this flag overrides it.

            No effect on PDF captures (no playback) or when the page itself
            has no `<video autoplay>`. **Pro only.**
        prefers_reduced_motion:
          type: boolean
          default: false
          description: |
            Emulate the `prefers-reduced-motion: reduce` CSS media feature.
            Well-built sites use this to skip or shorten their animations
            (`framer-motion`, GSAP, AOS, IntersectionObserver reveals),
            which makes captures cleaner | no half-played transitions, no
            mid-animation frames. No effect on sites that don't honour the
            media query. Available to all accounts.
        omit_background:
          type: boolean
          default: false
          description: |
            Render with a transparent background (`omitBackground: true` on
            the underlying Chromium screenshot). The page's own background
            colour is preserved; only the default white viewport backdrop
            becomes transparent. Useful when capturing components or pages
            designed to sit on a host page's background.

            Visible on `png` / `webp` / `avif`. `jpg` has no alpha channel so
            transparency flattens to black on conversion (silent no-op). Has
            no effect on PDF output. **Pro only.**
        emulate_print_media:
          type: boolean
          default: false
          description: |
            Call `page.emulateMediaType('print')` before capture so the page
            renders with its print stylesheet (`@media print` rules apply).
            Useful for capturing print-only layouts as an image. Has no
            additional effect on PDF output (PDFs already render in print
            mode). **Pro only.**
        wait_time:
          type: integer
          minimum: 0
          maximum: 30
          default: 5
          description: |
            Seconds to wait after page load before capturing
            (0 = capture immediately after page load).
            Free accounts: 0-5. **Shotbot Pro: 0-30** (longer waits help
            JavaScript-heavy / slow pages). A free request above 5 s is rejected
            with `pro_required` (or clamped to 5 s on the `wait_time` alias path).
            Alias: `wait` (accepted for backward compatibility).
        dismiss_cookies:
          type: string
          enum: ["", "accept", "reject"]
          default: ""
          description: |
            Auto-interact with cookie consent banners. **Pro only.**
            `""` (default) = leave the banner visible, `"accept"` = click accept,
            `"reject"` = click reject.
        frame:
          type: string
          enum: ["", "rounded", "shadow", "browser_chrome", "browser_chrome_dark", "mobile", "mobile_light", "tablet", "tablet_light", "laptop", "polaroid", "gradient", "shotbot_brand"]
          default: ""
          description: |
            Decorative frame composited around the rendered image (image formats only,
            silently ignored for PDF).

            | Variant               | Effect                                                                          |
            |-----------------------|---------------------------------------------------------------------------------|
            | `""` (default)        | No frame                                                                        |
            | `rounded`             | Rounded screenshot corners (~12 px radius), no padding, dimensions unchanged    |
            | `shadow`              | Drop shadow + rounded corners, edge-sampled padding                             |
            | `browser_chrome`      | Simulated browser window with address bar + traffic lights                      |
            | `browser_chrome_dark` | Same browser window, dark palette                                               |
            | `mobile`              | Generic smartphone mockup with dark bezel (pair with the `mobile` preset)       |
            | `mobile_light`        | Smartphone mockup with a light (silver) bezel                                   |
            | `tablet`              | Generic tablet mockup with dark bezel (pair with the `tablet` preset)           |
            | `tablet_light`        | Tablet mockup with a light (silver) bezel                                       |
            | `laptop`              | Laptop mockup (dark screen bezel + silver base)                                 |
            | `polaroid`            | White border, thick bottom, square corners, soft drop shadow                    |
            | `gradient`            | Rounded screenshot floated on a lime-accent gradient backdrop                   |
            | `shotbot_brand`       | Lime-green corner brackets (free tier integrates the `shotbot.fr|net` wordmark) |

            Most variants extend OUTSIDE the requested `output_size` (final
            image is larger by ~20-100 px depending on variant). `rounded` is
            the exception | dimensions stay the same, only the corner pixels
            are clipped. Padding around `shadow`/`shotbot_brand` is edge-sampled
            from the rendered image so dark themes get dark padding.

            **Free accounts** get a small `shotbot.fr` / `shotbot.net`
            wordmark on any non-empty frame (domain picked per the captured
            page's language). For `shotbot_brand` the wordmark is integrated
            into the bottom-right bracket itself rather than overlaid.
            **Pro accounts** get a clean frame on ALL variants, including
            `shotbot_brand` (brackets only, no wordmark). No Pro gating on the
            choice of variant itself | all values are available to everyone.

            **PNG/WebP/AVIF formats** render transparent pixels outside the
            mockup bezel (real see-through corners). JPG has no alpha channel
            so those areas flatten to black.
        scroll_before_capture:
          type: boolean
          default: false
          description: Scroll the page before capturing (triggers lazy-loaded images). **Pro only.**
        scroll_offset_px:
          type: integer
          minimum: 0
          maximum: 30000
          default: 0
          description: |
            Scroll the page down by this many pixels before capturing (no scroll back),
            so the viewport frame starts at Y=N. Runs after `scroll_before_capture`.
            Mutually exclusive with `scroll_to` (sending both returns `400 invalid_scroll_combo`).
            No-op for `fullpage`, PDF, and `selector` captures. Out of range returns
            `400 invalid_scroll_offset`. Not available on the web form. **Pro only.**
          example: 400
        scroll_to:
          type: string
          maxLength: 500
          description: |
            CSS selector to `scrollIntoView` before capturing, so the matched element sits
            at the top of the viewport frame. Unlike `selector` (which clips output to the
            element), `scroll_to` only positions the page, then captures the normal viewport.
            Runs after `scroll_before_capture`. Mutually exclusive with `scroll_offset_px`
            (`400 invalid_scroll_combo`). If the selector matches no element it is a no-op
            (the job still succeeds). No-op for `fullpage`, PDF, and `selector` captures.
            Not available on the web form. **Pro only.**
          example: "#pricing"
        block_ads:
          type: boolean
          default: false
          description: Block ad network requests during capture. **Pro only.**
        http_auth:
          type: object
          description: HTTP Basic Auth credentials for password-protected pages. **Pro only.**
          required: [user]
          properties:
            user:
              type: string
              maxLength: 255
            pass:
              type: string
              maxLength: 255
              default: ""
        pdf_page_size:
          type: string
          enum: [A4, A3, A5, Letter, Legal, Tabloid]
          default: A4
          description: PDF page size. Only used when `format` is `pdf`.
        pdf_margin_mm:
          type: integer
          minimum: 0
          maximum: 50
          default: 10
          description: PDF page margin in mm. Only used when `format` is `pdf`.
        pdf_scale:
          type: number
          minimum: 0.10
          maximum: 2.00
          default: 1.00
          description: PDF rendering scale factor. Only used when `format` is `pdf`.
        pdf_landscape:
          type: boolean
          default: false
          description: PDF landscape orientation. Only used when `format` is `pdf`.
        selector:
          type: string
          maxLength: 500
          description: |
            CSS selector of the DOM element to screenshot. Only the matched element is
            captured and resized to `output_size`. Ignored when `format` is `pdf`.
            The job fails with `nav_error` if the selector matches no element or if the
            matched element exceeds 30,000 px in height. Not available on the web form.
            **Pro only.**
          example: "#chart-container"
        private:
          type: boolean
          default: false
          description: |
            **Private capture.** When `true`, the result is **not** uploaded to
            `static.shotbot.net`. Instead, the response from `GET /capture/{token}`
            includes a `download` URL pointing at
            `https://api.shotbot.net/capture/{token}/file`, which streams the
            bytes. The file is **not** deleted on read: you can fetch it as many
            times as you like until it auto-expires after a short retention
            window. There is no public CDN URL.

            The web preview (`{token}_p.webp`) is also skipped - the response
            has no `preview` field for private captures.

            Deduplication is disabled for private captures (each call produces a
            fresh token with its own file).

            Available to all accounts (not Pro-gated).
        cookies:
          type: array
          maxItems: 50
          description: |
            Cookies to inject before navigation. Useful for capturing authenticated pages
            without HTTP Basic Auth. Each entry must include `name` and `value`.
            If `domain` is omitted, the cookie is scoped to the target URL's domain.
            Maximum 50 cookies. **Pro only.**
          items:
            type: object
            required: [name, value]
            properties:
              name:
                type: string
                maxLength: 255
              value:
                type: string
                maxLength: 4096
              domain:
                type: string
                maxLength: 255
                description: Cookie domain. Defaults to the target URL's domain when omitted.
              path:
                type: string
                maxLength: 255
                description: Cookie path. Defaults to `"/"` when omitted.

    # ── Single capture request ────────────────────────────────────────────────
    CaptureRequest:
      allOf:
        - $ref: '#/components/schemas/CaptureOptions'
        - type: object
          required: [key, url]
          properties:
            key:
              type: string
              pattern: '^[0-9A-Za-z]{12}$'
              description: Your 12-character alphanumeric API key.
              example: "aB3kR7mZx9Lq"
            url:
              type: string
              format: uri
              description: |
                Target URL. Must be a public http/https URL (no private IPs, no localhost).
                URLs with a non-standard TCP port (e.g. `http://staging.example.com:8080/`)
                require a Pro subscription; submitting one without Pro returns `403 pro_required`
                with `fields: ["url"]`.
              example: "https://example.com"

    # ── Callback request ──────────────────────────────────────────────────────
    CallbackRequest:
      allOf:
        - $ref: '#/components/schemas/CaptureRequest'
        - type: object
          required: [callback_url]
          properties:
            callback_url:
              type: string
              format: uri
              description: |
                HTTPS endpoint where Shotbot will POST the result (multipart image).
                Must use HTTPS and resolve to a public IP address.
              example: "https://myapp.example.com/webhooks/shotbot"
            callback_secret:
              type: string
              maxLength: 255
              description: |
                Optional shared secret. Shotbot echoes it back in the callback POST body.
                Compare with `hash_equals` to verify webhook authenticity.

    # ── Batch request ─────────────────────────────────────────────────────────
    BatchJobInput:
      allOf:
        - $ref: '#/components/schemas/CaptureOptions'
        - type: object
          required: [url]
          properties:
            url:
              type: string
              format: uri
              description: Target URL for this job.

    BatchRequest:
      allOf:
        - $ref: '#/components/schemas/CaptureOptions'
        - type: object
          required: [key, jobs]
          properties:
            key:
              type: string
              pattern: '^[0-9A-Za-z]{12}$'
              description: Your 12-character alphanumeric API key.
              example: "aB3kR7mZx9Lq"
            jobs:
              type: array
              minItems: 1
              maxItems: 5000
              description: |
                Array of capture jobs. Each entry must include a `url` and may override
                any global option. Non-Pro accounts are limited to their `batch_limit`
                (default 500); Pro accounts may submit up to 5000.
              items:
                $ref: '#/components/schemas/BatchJobInput'
            callback_url:
              type: string
              format: uri
              description: |
                Global webhook URL. When set, all jobs in the batch become callback jobs.
                Must use HTTPS and resolve to a public IP.
            callback_secret:
              type: string
              maxLength: 255
              description: Global HMAC secret echoed back in every callback POST.
            idempotency_key:
              type: string
              pattern: '^[A-Za-z0-9_.\-]{16,128}$'
              description: |
                Client-supplied retry-safety key. Replaying the same key within 24 h from
                the same account returns the original response (`X-Idempotent-Replay: true`)
                instead of creating new jobs or deducting quota. Generate a UUID before the
                first call, reuse on retries, drop on success.
              example: "8f9d4e0c-4f3b-4a7b-9a1c-2e5d3f6b8a90"

    # ── POST /capture responses ───────────────────────────────────────────────
    CaptureQueued:
      type: object
      required: [job_id, token, status, eta_seconds]
      properties:
        job_id:
          type: integer
          description: Auto-increment primary key of the created job. Useful for support, logs, and admin lookups.
          example: 1843221
        token:
          type: string
          pattern: '^[A-Za-z0-9]{32}$'
          description: 32-character alphanumeric bearer token. Use with GET /capture/{token}.
          example: "4XyZaBd2E1lk39PQ7m8r5wN0c6f4H2g1"
        status:
          type: string
          enum: [queued]
        eta_seconds:
          type: integer
          description: |
            Estimated seconds until the capture is ready.
            Computed as `max(5, queue_depth × avg_pure_render_time_last_100_jobs + effective_wait_time)`.
            Refreshed on each `GET /capture/{token}` poll.
          example: 17

    CaptureDeduplicated:
      type: object
      required: [token, status, deduplicated]
      properties:
        token:
          type: string
          pattern: '^[A-Za-z0-9]{32}$'
          description: Token of the existing in-flight job (same URL submitted within the last 60 s).
        status:
          type: string
          enum: [queued]
        deduplicated:
          type: boolean
          enum: [true]

    CaptureWaitlisted:
      type: object
      required: [status, detail]
      properties:
        status:
          type: string
          enum: [waitlisted]
        detail:
          type: string
          example: "Monthly quota and credits exhausted. Request queued and will be processed at next quota reset."

    # ── GET /capture/{token} responses ────────────────────────────────────────
    StatusBase:
      type: object
      required: [token, status, url, format, viewport_width, output_size, output_height, ratio, fullpage]
      properties:
        token:
          type: string
          pattern: '^[A-Za-z0-9]{32}$'
        status:
          type: string
          enum: [queued, processing, done, failed]
        url:
          type: string
          format: uri
          description: The captured URL.
        format:
          type: string
          enum: [jpg, png, webp, webp_lossless, avif, pdf]
        viewport_width:
          type: integer
          description: Browser viewport width used for the capture.
        output_size:
          type: integer
          description: Output image width in pixels.
        output_height:
          type: ["integer", "null"]
          description: Output image height in pixels. Null for fullpage captures and PDFs.
        ratio:
          type: ["string", "null"]
          description: |
            Ratio used. `"custom"` when `crop_height` was set.
            Null for fullpage captures and PDFs.
        fullpage:
          type: boolean

    StatusQueued:
      allOf:
        - $ref: '#/components/schemas/StatusBase'
        - type: object
          required: [eta_seconds]
          properties:
            status:
              type: string
              enum: [queued]
            eta_seconds:
              type: integer
              description: |
                Estimated seconds until done.
                Calculated as `max(5, queue_depth × average_render_time_of_last_100_jobs)`.
              example: 18
            step:
              type: string
              enum: [start, nav, screenshot, resize, upload]
              description: |
                Latest worker step emitted for this job, if any. Absent before the
                worker picks the job up. Lifecycle in order: `start` → `nav` →
                `screenshot` → `resize` → `upload`. Best-effort: may lag a fraction
                of a second behind the worker.
            step_at:
              type: integer
              description: Unix timestamp (seconds) of the last `step` transition.

    StatusProcessing:
      allOf:
        - $ref: '#/components/schemas/StatusBase'
        - type: object
          properties:
            status:
              type: string
              enum: [processing]
            step:
              type: string
              enum: [start, nav, screenshot, resize, upload]
              description: |
                Latest worker step emitted for this job. Lifecycle in order:
                `start` → `nav` → `screenshot` → `resize` → `upload`. Best-effort:
                may lag a fraction of a second behind the worker.
            step_at:
              type: integer
              description: Unix timestamp (seconds) of the last `step` transition.

    StatusDone:
      allOf:
        - $ref: '#/components/schemas/StatusBase'
        - type: object
          description: |
            Public captures expose `image` + `preview` (URLs on
            `static.shotbot.net`). **Private captures** (`private: true` on
            submission) expose a `download` URL on `api.shotbot.net` instead
            (fetchable repeatedly until it auto-expires) and include
            `"private": true` on the response.
          properties:
            status:
              type: string
              enum: [done]
            image:
              type: string
              format: uri
              description: |
                Full-size result on the CDN. Present for public captures only.
                Omitted for private captures - fetch `download` instead.
              example: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4.jpg"
            preview:
              type: string
              format: uri
              description: |
                1280 px WebP thumbnail on the CDN. Present for public captures
                only - private captures do not generate a preview.
              example: "https://static.shotbot.net/a/a1/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_p.webp"
            private:
              type: boolean
              description: |
                Present and `true` only when this is a private capture. Implies
                `image` and `preview` are absent and `download` is present.
              example: true
            download:
              type: string
              format: uri
              description: |
                Download URL for private captures. The file is streamed and is
                **not** deleted on read: fetch it as many times as you like until
                it auto-expires after a short retention window, after which the
                URL returns `410 Gone`. Present only when `private` is `true`.
              example: "https://api.shotbot.net/capture/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/file"
            meta_http_status:
              type: integer
              description: HTTP status code returned by the target page (e.g. 200, 404).
              example: 200
            meta_final_url:
              type: string
              format: uri
              description: |
                Final URL after all redirects. Differs from the submitted `url` when the
                page redirects (e.g. HTTP → HTTPS, www canonicalisation).
              example: "https://www.example.com/"
            meta_page_title:
              type: string
              description: Page title from the `<title>` element at capture time.
              example: "Example Domain"
            file_hash:
              type: string
              description: |
                SHA-256 hash of the captured file, prefixed with `sha256:`.
                Verify integrity: `sha256sum` of the downloaded file must match.
              example: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
            captured_at:
              type: integer
              description: |
                Unix timestamp (seconds) of when the screenshot was taken.
                For jobs completed before this field was introduced, falls back to job creation time.
              example: 1746268800

    StatusFailed:
      allOf:
        - $ref: '#/components/schemas/StatusBase'
        - type: object
          required: [error_code]
          properties:
            status:
              type: string
              enum: [failed]
            error_code:
              type: string
              enum: [nav_timeout, nav_error, crash, upload_failed, response_too_large, unknown, stuck]
              description: |
                `nav_timeout` - page did not load within the deadline; a partial
                screenshot may be available (see `partial`).
                `nav_error` - navigation failed (DNS, connection refused, HTTP error).
                `crash` - browser crashed during capture.
                `upload_failed` - capture succeeded but CDN upload failed.
                `response_too_large` - the page HTML exceeded the allowed size limit.
                `stuck` - job was interrupted (worker restart or OOM kill).
                `unknown` - unspecified error.
            image:
              type: string
              format: uri
              description: Partial screenshot URL. Present only when `error_code` is `nav_timeout`.
            preview:
              type: string
              format: uri
              description: Partial 1280 px thumbnail. Present only when `error_code` is `nav_timeout`.
            partial:
              type: boolean
              enum: [true]
              description: Present and `true` when a partial screenshot is available.

    # ── Batch response ────────────────────────────────────────────────────────
    BatchResponse:
      type: object
      required: [submitted, waitlisted, deduplicated, errors, jobs]
      properties:
        submitted:
          type: integer
          description: Number of new jobs created and queued.
        waitlisted:
          type: integer
          description: Number of jobs added to the wait list (quota exhausted).
        deduplicated:
          type: integer
          description: Number of jobs matched to an existing in-flight job.
        errors:
          type: integer
          description: Number of jobs rejected due to validation errors.
        eta_seconds:
          type: integer
          description: |
            Estimated seconds until the first queued job is ready.
            Present only when `submitted > 0`. Same formula as single capture ETA.
          example: 25
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/BatchJobResult'

    BatchJobResult:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
          description: Original zero-based position in the request `jobs` array.
        url:
          type: string
          format: uri
          description: Target URL. Present unless the job object itself was malformed.
        token:
          type: string
          pattern: '^[A-Za-z0-9]{32}$'
          description: Bearer token for polling. Present when `status` is `queued`.
        job_id:
          type: integer
          description: |
            Auto-increment primary key of the created job.
            Present when `status` is `queued` and the job was newly created (not deduplicated, not a callback batch).
        status:
          type: string
          enum: [queued, waitlisted, error]
        deduplicated:
          type: boolean
          description: Present and `true` when matched to an existing in-flight job.
        private:
          type: boolean
          description: Present and `true` when this is a private capture (no CDN upload). Fetch the file via `GET /capture/{token}/file`.
        error:
          type: string
          description: Error code. Present when `status` is `error`.
          enum: [invalid_url, missing_url, invalid_job, pro_required]
        detail:
          type: string
          description: Human-readable error description.
        fields:
          type: array
          items:
            type: string
          description: Names of Pro-gated fields. Present when `error` is `pro_required`.

    # ── Generic error ─────────────────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        detail:
          type: string
        fields:
          type: array
          items:
            type: string
        status:
          type: string
          description: Present on `409 not_ready` | current capture status (`queued` | `processing`).
        max:
          type: integer
          description: Present on `too_many_jobs` | the batch limit for this account.

  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalid_json:
              summary: Body is not valid JSON
              value:
                error: "invalid_json"
            missing_url:
              summary: url field absent or empty
              value:
                error: "missing_url"
            invalid_url:
              summary: URL is not a public http/https URL
              value:
                error: "invalid_url"
                detail: "Only public http/https URLs are supported"
            missing_callback_url:
              summary: callback_url required but absent
              value:
                error: "missing_callback_url"
            invalid_callback_url_scheme:
              summary: callback_url must use HTTPS
              value:
                error: "invalid_callback_url"
                detail: "callback_url must use HTTPS"
            invalid_callback_url_dns:
              summary: callback_url host does not resolve
              value:
                error: "invalid_callback_url"
                detail: "callback_url host could not be resolved"
            invalid_callback_url_private:
              summary: callback_url resolves to a private IP
              value:
                error: "invalid_callback_url"
                detail: "callback_url must not resolve to a private address"
            missing_jobs:
              summary: jobs field absent
              value:
                error: "missing_jobs"
            jobs_not_array:
              summary: jobs is not an array
              value:
                error: "jobs_not_array"
            invalid_crop_height:
              summary: crop_height out of range
              value:
                error: "invalid_crop_height"
                detail: "crop_height must be between 10 and 30000"
            invalid_viewport_width:
              summary: viewport_width not allowed for this account
              value:
                error: "invalid_viewport_width"
                detail: "viewport_width must be a listed value, or 280-3840 for Shotbot Pro"
            invalid_scroll_offset:
              summary: scroll_offset_px out of range
              value:
                error: "invalid_scroll_offset"
                detail: "scroll_offset_px must be between 0 and 30000"
            invalid_scroll_combo:
              summary: scroll_to and scroll_offset_px both sent
              value:
                error: "invalid_scroll_combo"
                detail: "scroll_to and scroll_offset_px are mutually exclusive"
            too_many_jobs:
              summary: Batch exceeds account limit
              value:
                error: "too_many_jobs"
                max: 500

    Unauthorized:
      description: Invalid or missing API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "invalid_key"

    ProRequired:
      description: One or more options require a Pro subscription
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            options:
              summary: Pro-gated capture options
              value:
                error: "pro_required"
                fields: ["format", "viewport_width"]
                detail: "These options require a Pro subscription. See https://shotbot.net/pro/"
            custom_port:
              summary: Custom TCP port in URL
              value:
                error: "pro_required"
                fields: ["url"]
                detail: "These options require a Pro subscription. See https://shotbot.net/pro/"

    MethodNotAllowed:
      description: Wrong HTTP method
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "method_not_allowed"

    ConcurrencyLimit:
      description: Too many concurrent captures for this account
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                required: [inflight, cap]
                properties:
                  inflight:
                    type: integer
                    description: Number of captures currently in progress.
                  cap:
                    type: integer
                    description: Maximum concurrent captures for this account tier.
          example:
            error: "concurrency_limit"
            inflight: 3
            cap: 3
            detail: "Free/standard tier limited to 3 concurrent jobs. Upgrade to Pro for 3 → 15."
