# Screenshots

Capture a screenshot of any URL — a working request, the options that matter, the response, what it costs, and the errors to handle.

`POST /v1/screenshots` loads a URL in a real browser and returns an image of the page. Send a
URL and you get back a `jpeg` (the default) or `png`, plus the page title and the image
dimensions. Everything else — device, viewport, how long to wait, what to hide — is optional.

## Capture a screenshot

```bash
curl -X POST https://api.bytekit.com/v1/screenshots \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'
```

By default the call is **synchronous**: the request is held open for up to 28 seconds and
returns `200` with the finished screenshot. The response is JSON — `image_url` is a link to the
image, not the image bytes:

```json
{
  "id": "ss_01a1210c210477f6838a6256629087e6",
  "status": "completed",
  "url": "https://example.com",
  "image_url": "https://<storage-host>/.../screenshot.jpeg?X-Amz-Signature=...",
  "page_title": "Example Domain",
  "image_width": 1440,
  "image_height": 900,
  "file_size_bytes": 62635,
  "credits_charged": 1,
  "billing": {
    "raw_bytes": 334,
    "factors": [
      {
        "name": "phase1_render",
        "value": 1.5,
        "reason": "phase 1 proxy fetch + render overhead"
      }
    ],
    "multiplier": 1.5,
    "billed_bytes": 501
  },
  "error_code": null,
  "error_message": null,
  "created_at": "2026-10-09T14:23:25.417Z",
  "completed_at": "2026-10-09T14:23:33.047Z",
  "expires_at": "2027-10-09T14:23:25.407Z",
  "poll_url": "https://api.bytekit.com/v1/screenshots/ss_01a1210c210477f6838a6256629087e6"
}
```

> This response was returned by a real request. Two values are edited for the page: the signed `image_url` is shortened, and `poll_url` shows the production
> host.

| Field                         | Description                                                                                                       |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `id`                          | Screenshot id, prefixed `ss_`. Use it with `GET /v1/screenshots/{id}`                                             |
| `status`                      | `pending`, `processing`, `completed`, or `failed`                                                                 |
| `image_url`                   | Signed link to the image. It stays valid for 10 minutes — fetch a fresh one by polling `GET /v1/screenshots/{id}` |
| `page_title`                  | The page's `<title>`                                                                                              |
| `image_width`, `image_height` | Image dimensions in pixels                                                                                        |
| `file_size_bytes`             | Size of the image file. Informational only — it is not what you are billed on (see [Cost](#cost))                 |
| `billing`                     | What the capture was billed: `raw_bytes`, the `factors` applied, the resulting `multiplier`, and `billed_bytes`   |
| `error_code`, `error_message` | Why a capture failed. `null` on success                                                                           |
| `expires_at`                  | When the stored image expires                                                                                     |
| `poll_url`                    | Where to poll this screenshot                                                                                     |

The `200` response also carries `X-Screenshot-*` headers (the same id, status and timestamps as
the JSON) and `X-Quota-*` headers reporting your quota.

If the capture is still running when the 28 seconds are up, the call returns `202` with
`status: "processing"` and the same `id`. The capture keeps running in the background — poll it
as described in [Async captures](#async-captures).

## Options

Send any of these alongside `url`. All are optional and the defaults suit most pages. Unknown
fields are rejected with `422 validation_error`.

```bash
curl -X POST https://api.bytekit.com/v1/screenshots \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "format": "png",
    "viewport": {"width": 1280, "height": 720},
    "dark_mode": true,
    "wait_until": "networkidle"
  }'
```

> This request was run for real and returned `200` with
> `"image_width": 1280` and `"image_height": 720`.

**Output**

| Option                | Default | Description                                                                                                          |
| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `format`              | `jpeg`  | Image format: `jpeg` or `png`                                                                                        |
| `quality`             | `80`    | Image quality, an integer from `1` to `100`                                                                          |
| `full_page`           | `false` | Capture the whole scrollable page instead of only the viewport                                                       |
| `clip`                | `null`  | Capture only a region: `{"x": 0, "y": 0, "width": 800, "height": 600}` in pixels (`width` and `height` at least `1`) |
| `device_scale_factor` | `null`  | Pixel density: `1` or `2` (`2` for a retina-sharp image)                                                             |

**Device**

| Option      | Default   | Description                                                                                   |
| ----------- | --------- | --------------------------------------------------------------------------------------------- |
| `device`    | `desktop` | `desktop` or `mobile`                                                                         |
| `viewport`  | `null`    | Browser window size, `{"width": 1280, "height": 720}` in pixels. Overrides the device default |
| `dark_mode` | `false`   | Render the page with a dark colour scheme preference                                          |

**Page readiness**

| Option              | Default | Description                                                                    |
| ------------------- | ------- | ------------------------------------------------------------------------------ |
| `wait_until`        | `load`  | Navigation event to wait for: `load`, `domcontentloaded`, or `networkidle`     |
| `wait_for_selector` | `null`  | CSS selector that must be visible before the capture is taken                  |
| `delay_ms`          | `0`     | Extra wait after the page is ready, in milliseconds (`0` to `10000`)           |
| `scroll`            | `true`  | Scroll the page to the bottom before capturing, so lazy-loaded content appears |

`wait_until` and `wait_for_selector` are Screenshot options. They are not `POST /v1/scrape`
options — sending either there returns `422`. See the [Scraping guide](https://bytekit.com/docs/guides/scraping).

**Clean-up**

| Option                 | Default | Description                 |
| ---------------------- | ------- | --------------------------- |
| `block_ads`            | `true`  | Block ads on the page       |
| `block_cookie_banners` | `true`  | Hide cookie-consent banners |

**Geo and language**

| Option     | Default | Description                                                                        |
| ---------- | ------- | ---------------------------------------------------------------------------------- |
| `country`  | `US`    | Two-letter, upper-case country code the page is requested from                     |
| `language` | `null`  | Browser language, for example `de-DE`. When `null`, the language follows `country` |

**Other**

| Option         | Default | Description                                                                                                 |
| -------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `include_html` | `false` | Also capture the page's rendered HTML alongside the image. The HTML is not returned in the response         |
| `metadata`     | `{}`    | Your own key-value pairs to attach to the capture. At most 4096 bytes as JSON; keys must not start with `_` |

`cookies` and `headers` are not request options on any capture endpoint. Sending either returns
`400 validation_error`.

## Async captures

For pages that take a while, add `?async=true` to the URL, or send `"async": true` in the request
body. Either one is enough, and `false` in one never cancels `true` in the other. The call returns
`202` immediately with an `ss_` id and `status: "pending"`, and the capture runs in the background.
`POST /v1/scrape` accepts the same two spellings.

```bash
# Start an async capture
curl -X POST "https://api.bytekit.com/v1/screenshots?async=true" \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

# Poll for the result
curl https://api.bytekit.com/v1/screenshots/ss_... \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE"
```

The first call returns:

```json
{
  "id": "ss_01a1210d0e3a719589e9c3d70c4791d2",
  "status": "pending",
  "url": "https://example.com",
  "image_url": null,
  "page_title": null,
  "image_width": null,
  "image_height": null,
  "file_size_bytes": null,
  "credits_charged": 1,
  "error_code": null,
  "error_message": null,
  "created_at": "2026-10-09T14:24:26.172Z",
  "completed_at": null,
  "expires_at": "2027-10-09T14:24:26.149Z",
  "poll_url": "https://api.bytekit.com/v1/screenshots/ss_01a1210d0e3a719589e9c3d70c4791d2"
}
```

> This response was returned by a real request; `poll_url` shows the production host.

`GET /v1/screenshots/{id}` answers by state:

| State                           | Status | Body                                                                   |
| ------------------------------- | ------ | ---------------------------------------------------------------------- |
| Still `pending` or `processing` | `202`  | The screenshot resource; poll again                                    |
| `completed`                     | `200`  | The full screenshot resource, with a fresh signed `image_url`          |
| `failed`                        | `200`  | The [error envelope](https://bytekit.com/docs/guides/errors), with the failure in `error.code` |

A failed capture is a successfully retrieved terminal state, so check `status` before reading
`image_url`.

## Cost

Screenshots are billed on bandwidth, not credits. A capture is billed on the bytes of the
initial page fetch, multiplied by 1.5 — the extra 0.5 covers rendering the page. The size of the
image file is not the basis. If that initial fetch is skipped or fails, the capture bills nothing.

In the example above, the page fetch transferred 334 bytes (`raw_bytes`), so the capture was
billed 501 bytes (`billed_bytes`). The `billing` object is the authoritative breakdown; its
`factors[].reason` text is descriptive and may change, so read `value` and `multiplier` rather
than parsing it. The same figures arrive in the `X-Raw-Bytes`, `X-Billing-Multiplier`, and
`X-Billable-Bytes` response headers.

The `credits_charged` field and the `X-Screenshot-Credits-Charged` header carry a legacy weight
(`1`, `2` or `4`, doubling for `full_page` and again for retina or mobile). It is recorded for
reporting only and is not what you are billed. For how capture bandwidth is billed,
see [Bandwidth billing](https://bytekit.com/docs/billing/bandwidth).

## Errors

Errors use the unified envelope described in the [Errors guide](https://bytekit.com/docs/guides/errors). These are the
ones you are most likely to meet on this endpoint:

| Status | Code                                                                                  | When                                                                                                                                 | Retry?                                                 |
| ------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| `400`  | `invalid_url`                                                                         | `url` is not a valid HTTP(S) URL. `error.details.reason` says why, for example `scheme_unsupported`                                  | No — fix the URL                                       |
| `400`  | `request_too_large`                                                                   | The request body is over 512 KB                                                                                                      | No — send a smaller body                               |
| `401`  | `unauthorized`, `invalid_api_key`                                                     | The API key is missing, malformed, or not valid                                                                                      | No — check your key                                    |
| `400`  | `validation_error`                                                                    | The request carries a removed field such as `cookies` or `headers`                                                                   | No — remove the field                                  |
| `402`  | `quota_exceeded`                                                                      | The balance cannot fund the capture. `error.details.required_bytes` and `available_bytes` give the numbers                           | No — add balance or upgrade                            |
| `402`  | `spending_cap_reached`, `billing_entitlement_impaired`, `billing_entitlement_expired` | The spending cap or the subscription state blocks the request — see the [Errors guide](https://bytekit.com/docs/guides/errors)                               | No — resolve the billing state first                   |
| `403`  | `host_forbidden`, `path_forbidden`                                                    | The URL's host or path is not allowed as a capture target                                                                            | No — use a different URL                               |
| `422`  | `validation_error`                                                                    | A field failed validation (`error.details` names the field), an unknown field was sent, or the body is not valid JSON (no `details`) | No — fix the request                                   |
| `422`  | `blocked`, `timeout`, …                                                               | The capture ran and failed inside the 28-second hold. The body is the screenshot resource, with `error_code` and `error_message` set | Possibly — a retry is a new capture                    |
| `429`  | `rate_limited`                                                                        | Too many requests per second                                                                                                         | Yes — wait for the `Retry-After` header, then back off |
| `429`  | `concurrency_limit`                                                                   | Too many captures in flight at once                                                                                                  | Yes — wait for the `Retry-After` header                |
| `404`  | `not_found`                                                                           | `GET /v1/screenshots/{id}` for an id that does not exist on your account                                                             | No                                                     |

A `422` has two shapes. A validation failure carries the error envelope, where `error` is an
object. A failed capture carries the screenshot resource instead — no `error` object, but flat
`error_code` and `error_message` fields and an `id` you can look up later. Tell them apart by
whether the top-level `error` key exists, not by `status`: both carry `"status": "failed"`.

A validation failure looks like this (returned by a real request with
`"quality": 150`):

```json
{
  "status": "failed",
  "error": {
    "code": "validation_error",
    "message": "Invalid request body.",
    "http_status": 422,
    "failed_at": "2026-10-09T14:24:39.662Z",
    "details": {
      "formErrors": [],
      "fieldErrors": { "quality": ["Number must be less than or equal to 100"] }
    }
  }
}
```

## Next steps

- [Scraping](https://bytekit.com/docs/guides/scraping) — get page content instead of an image
- [Monitors](https://bytekit.com/docs/guides/monitors) — capture a page on a schedule and be notified when it changes
- [Errors](https://bytekit.com/docs/guides/errors) — error codes and retry guidance
- [Rate Limits](https://bytekit.com/docs/guides/rate-limits) — usage counting and concurrency slots
- [API Reference: Screenshots](https://bytekit.com/docs/api/screenshots/createscreenshot) — full request and response schema