Screenshots
Capture a screenshot of any URL — a working request, the options that matter, the response, what it costs, and the errors to handle.
For AI agents: https://bytekit.com/llms.txt — every docs page is available as markdown by appending .md
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
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:
{
"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_urlis shortened, andpoll_urlshows 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) |
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.
Options
Send any of these alongside url. All are optional and the defaults suit most pages. Unknown
fields are rejected with 422 validation_error.
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
200with"image_width": 1280and"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.
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.
# 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:
{
"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_urlshows 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, 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.
Errors
Errors use the unified envelope described in the Errors guide. 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 | 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):
{
"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 — get page content instead of an image
- Monitors — capture a page on a schedule and be notified when it changes
- Errors — error codes and retry guidance
- Rate Limits — usage counting and concurrency slots
- API Reference: Screenshots — full request and response schema