# Capture a screenshot

Sync by default (holds up to 28s). Pass `async: true` in the body or `?async=true` in the
query string to return 202 immediately; either one is enough, and `false` in one never
cancels `true` in the other.
Poll via GET /v1/screenshots/{id}.

**Limits:** request bodies are capped at 512 KB. An oversized body
returns a `400` with error code `request_too_large`.

**POST** `/v1/screenshots`

## Parameters

- `async` (query, boolean) — Query-string spelling of the body `async` field. `true` returns the 202 immediately; any other value is ignored.

## Request Body

- `url` (required)
- `device`
- `viewport`
- `viewport.width`
- `viewport.height`
- `async`
- `full_page`
- `format`
- `quality`
- `block_ads`
- `block_cookie_banners`
- `wait_until`
- `wait_for_selector`
- `delay_ms`
- `scroll`
- `clip`
- `clip.x`
- `clip.y`
- `clip.width`
- `clip.height`
- `dark_mode`
- `device_scale_factor`
- `include_html`
- `country`
- `language`
- `metadata`

## Responses

### 200 — Screenshot completed (sync mode).
- `id`
- `status`
- `url`
- `image_url`
- `page_title`
- `image_width`
- `image_height`
- `file_size_bytes`
- `credits_charged`
- `billing`
- `billing.raw_bytes`
- `billing.factors`
- `billing.factors.name`
- `billing.factors.value`
- `billing.factors.reason`
- `billing.multiplier`
- `billing.billed_bytes`
- `error_code`
- `error_message`
- `created_at`
- `completed_at`
- `expires_at`
- `poll_url`

### 202 — Screenshot accepted (async mode or sync timeout).
- `id`
- `status`
- `url`
- `image_url`
- `page_title`
- `image_width`
- `image_height`
- `file_size_bytes`
- `credits_charged`
- `billing`
- `billing.raw_bytes`
- `billing.factors`
- `billing.factors.name`
- `billing.factors.value`
- `billing.factors.reason`
- `billing.multiplier`
- `billing.billed_bytes`
- `error_code`
- `error_message`
- `created_at`
- `completed_at`
- `expires_at`
- `poll_url`

### 400 — Bad request. When the `url` field fails strict validation the error
code is `invalid_url` and the `details` object contains:
- `field` — the request field that failed (e.g. `"url"`)
- `url` — the rejected value
- `reason` — one of: `scheme_missing`, `scheme_unsupported`,
  `scheme_separator_typo`, `host_empty`, `host_invalid`,
  `host_blocked_private`, `host_blocked_loopback`, `host_too_long`,
  `host_unicode_not_punycode`, `userinfo_present`, `url_too_long`,
  `control_chars`, `not_a_url`
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`

### 401 — Unauthorized. The request carried no valid credential, and no work was
performed. The unified `Error` envelope carries one of the codes below
in `error.code`; match on the code, never on the prose.

- `invalid_api_key` — a bearer API key was presented and rejected:
  unknown, revoked, expired or malformed. The bearer-only operations
  (`/v1/webhook-deliveries*` among them) also return it when no
  `Authorization` header is sent. `GET /v1/account` is the exception
  to "malformed": it returns `invalid_api_key` only for a key that
  starts `sk_test_` or `sk_live_` and is unknown, revoked or expired.
- `unauthorized` — the `Authorization` header is absent or malformed on
  an operation that also accepts a dashboard session (the dual-auth and
  billing operations). `GET /v1/account` also returns it, for no
  `Authorization` header and for any bearer value that does not start
  `sk_test_` or `sk_live_` (that value is checked as a dashboard
  session token).

Not retryable as-is: supply a valid API key first.
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`

### 402 — Payment required. The account cannot fund this request, and no work was
performed. The unified `Error` envelope carries one of the billing codes
below in `error.code`; match on the code, never on the prose.

- `quota_exceeded` — the enforced balance for this request's meter
  cannot fund it. Capture endpoints (`/v1/scrape`, `/v1/fetch`,
  `/v1/screenshots`, `/v1/sitemap` and the bulk siblings) meter
  bandwidth: they reject when the balance cannot fund the request's
  byte reservation, and `error.details.required_bytes` and
  `error.details.available_bytes` report the bytes the request reserves
  and the bytes available. `/v1/search` meters credits.
- `spending_cap_reached` — the request would push overage spend past the
  account's configured cap. Only reachable on a custom overage-enabled
  plan; standard plans stop at `quota_exceeded`.
- `billing_entitlement_impaired` — the paid subscription is in a payment
  impairment (e.g. `past_due`). `error.message` names the reason.
- `billing_entitlement_expired` — the paid subscription entitlement
  period has ended, or its dunning grace period has run out.

Not retryable as-is: settle the balance, raise the cap, or restore the
subscription first. This response carries no `X-Quota-*` headers — they
travel with the 2xx responses of the operations that emit them.
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`

### 403 — The target is on ByteKit's blocklist. Nothing is captured and nothing is billed.
The unified `Error` envelope carries one of two codes:

- `host_forbidden` — a blocked domain, any `.gov` host, or a blocked port.
  `details` is `{ host }`.
- `path_forbidden` — an authentication-style path such as a login page.
  `details` is `{ path }`.
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`

### 422 — Validation error, or a screenshot that failed inside the sync hold.
The two carry DIFFERENT bodies (issue #2679):

- A request-validation failure returns the unified `Error` envelope
  with code `validation_error`.
- A capture that reached a terminal `failed` state inside the sync
  hold returns the full `ScreenshotResponse` resource, whose
  `error_code`/`error_message` carry the real failure (`blocked`,
  `timeout`, …). This is the canonical shape for that case: the
  resource body is what carries the `ss_` handle, `poll_url` and
  `expires_at` a client needs to redeem the failed capture, none of
  which fit the `Error` envelope.

Discriminate on the shape of the top-level `error` key. The envelope
form carries `error` as an OBJECT (`{ code, message, http_status,
failed_at }`); the resource form has no `error` key at all, and
carries the flat `error_code`/`error_message` pair plus `id` instead.

Do NOT discriminate on `status`. Both forms carry
`"status": "failed"` on this response — `Error.status` is a required
property with `enum: [failed]` — so it separates nothing here.

`X-Screenshot-ID` accompanies the resource form only: a
request-validation failure is raised before a screenshot row exists,
so no screenshot headers travel with it.
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`
- `id`
- `url`
- `image_url`
- `page_title`
- `image_width`
- `image_height`
- `file_size_bytes`
- `credits_charged`
- `billing`
- `billing.raw_bytes`
- `billing.factors`
- `billing.factors.name`
- `billing.factors.value`
- `billing.factors.reason`
- `billing.multiplier`
- `billing.billed_bytes`
- `error_code`
- `error_message`
- `created_at`
- `completed_at`
- `expires_at`
- `poll_url`

### 429 — Rate limit (`rate_limited`) or concurrency limit (`concurrency_limit`) exceeded. Carries a `Retry-After` header — seconds derived from the same clock as `X-RateLimit-Reset` for the rate-limit variant, a fixed small back-off for the concurrency variant.
- `status`
- `error`
- `error.code`
- `error.message`
- `error.http_status`
- `error.failed_at`
- `error.details`