# Quickstart This guide will get you all set up and ready to use the ScreenshotBuddy API. We'll cover how our API works, how to authenticate with it, and how to make your first API request. > Before you can make requests to the ScreenshotBuddy API, you will need to grab your API key from your dashboard. You find it under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). Every option a request accepts is listed on two pages: [Taking screenshots](https://screenshotbuddy.io/documentation/taking-screenshots.md) for images and [Creating PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) for documents. The [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json) is the complete reference, generated from the same definitions the API validates against. If you are an agent rather than a person, [/llms.txt](https://screenshotbuddy.io/llms.txt) indexes this documentation for machine readers, and every page of it is also served as markdown at its own URL with `.md` on the end. ## Making your first API request After obtaining your API key, you can make your first API request. Below, we'll show you how you can take a screenshot of a website using the API. The API is served from a host of its own, `api.screenshotbuddy.io`, rather than from the site you are reading this on. Every path below is relative to that base URL, and it is the only host you ever send a token to. Endpoint: `GET https://api.screenshotbuddy.io/v1/snap` Replace `{token}` with your API key. The response body is the image itself, so write it straight to a file instead of parsing it as JSON. ```bash curl "https://api.screenshotbuddy.io/v1/snap?url=https%3A%2F%2Fexample.com" \ -H "Authorization: Bearer {token}" \ --output screenshot.png ``` ```javascript import { writeFile } from 'node:fs/promises'; const response = await fetch('https://api.screenshotbuddy.io/v1/snap?url=https%3A%2F%2Fexample.com', { headers: { Authorization: 'Bearer {token}' }, }); if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); } await writeFile('screenshot.png', Buffer.from(await response.arrayBuffer())); ``` ```python import requests response = requests.get( 'https://api.screenshotbuddy.io/v1/snap', params={'url': 'https://example.com'}, headers={'Authorization': 'Bearer {token}'}, ) response.raise_for_status() with open('screenshot.png', 'wb') as file: file.write(response.content) ``` ```php get('https://api.screenshotbuddy.io/v1/snap', [ 'url' => 'https://example.com', ]) ->throw(); Storage::put('screenshot.png', $response->body()); ``` The Laravel example uses `->throw()` so a failed request raises a `RequestException`. Prefer `$response->failed()` if you would rather handle the error yourself. ## Rate limits Every account can ask for up to 20 renders per minute by default. Higher plans and custom arrangements can have higher limits. Repeats we answer out of your cache are counted apart from that, against a flat 300 per minute, so a burst of captures you already hold does not eat into the renders you were saving. A conditional request we can answer `304` out of that cache counts against neither; one we had to render before we could answer it counts as the render it was. Every response to a request that carried your token includes an `X-RateLimit-Limit` header with the size of the budget that answer was counted against, and an `X-RateLimit-Remaining` header with what is left of it. A signed URL is answered without them, because its answer goes to whoever you handed the URL to rather than to you. If you go over a limit, the API returns HTTP status `429` together with a `Retry-After` header telling you how many seconds to wait before trying again. To see where you stand before you send anything, ask `/usage`: it reports your credits, your plan and both of your per-minute limits, under a limit of its own. The [rate limits](https://screenshotbuddy.io/documentation/rate-limits.md) page covers this in full, including the separate limit the playground runs under. If you are rendering slow pages, or a whole list of them at once, submit them as a [batch](https://screenshotbuddy.io/documentation/batch-renders.md) instead and collect the results afterwards, rather than holding a request open and pacing the calls yourself. # Authentication Every request to the ScreenshotBuddy API is authenticated with an API token that belongs to your account. This guide shows you where to create one, how to send it, and what happens when you do not. > You create and revoke tokens in your dashboard, under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). The token is shown once, when you create it, so copy it straight into wherever your application keeps its secrets. ## Creating a token A token is a long random string that stands in for your account. You can hold as many as you like, so give each application its own: revoking one then stops that application without touching the others. Two things are decided when you create one: what it is allowed to do, and how long it lives. Both are covered below, and both can be changed later without touching the rest of your keys. ## What a token is allowed to do A token carries one permission per mode of the `/snap` endpoint. `screenshot` lets it render images, and `pdf` lets it print PDFs, which is what `pdf=true` asks for. A new token holds both, so nothing has to be configured before your first call. Narrowing one is worth doing when a key only ever needs half of that. A service that renders thumbnails and nothing else can hold a `screenshot` only token, and a leak of that key cannot be turned into PDF rendering on your account. A request for a mode the token was not granted is answered with HTTP status `403` and the `missing_ability` code. Nothing is rendered and no credit is spent. Tick the missing permission under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens) and the same request works. ## How long a token lives By default a token has no expiry: it keeps working until you revoke or rotate it. When you create one you can give it a lifetime instead, and it stops working on its own once that runs out. That is worth choosing for a key handed to a contractor, a one-off migration or anything else you already know the end date of. An expired token is refused the same way an unknown one is: HTTP status `401` with the `unauthenticated` code, shown below. The expiry of every token is listed under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens), so you can see what is about to run out before it does. ## Rotating a token Rotating replaces a token with a fresh one that keeps its name and its permissions, and shows you the new secret once. If the token had a lifetime, the replacement gets the same lifetime again, counted from the moment you rotate. The token you replaced stops working immediately, so put the new secret in place first, or rotate at a moment the integration can be updated straight away. It is the quickest answer to a leak, and to the routine of changing a long lived key every so often. ## Sending your token Put the token in the `Authorization` header of every request, as a bearer token. There is no other way to authenticate: the API does not read tokens from the query string or from a cookie. Send it to `api.screenshotbuddy.io` and to nowhere else. The API lives on a host of its own, separate from the site you are reading this on, and that host is the only one your token belongs on. Anything else asking for it is not us. Header: `Authorization: Bearer {token}`, where `{token}` is the token you created in your dashboard. ```bash curl "https://api.screenshotbuddy.io/v1/snap?url=https%3A%2F%2Fexample.com" \ -H "Authorization: Bearer {token}" \ --output screenshot.png ``` ## Keeping your token safe A token is as good as your password: anyone holding it can render pages and spend your credits. Keep it on your server, in an environment variable or a secrets manager, and let your own backend make the call. Never put a token in client-side code. Anything that reaches a browser or a mobile app can be read out of it, including JavaScript bundles, source maps and network traces, so a token used from the browser is a published token. If a token does leak, revoke it under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens) and create a new one. Revoking is immediate, and the leaked token stops working on the next request. ## When authentication fails A request without a token, or with one that is unknown or revoked, is answered with HTTP status `401` and this body. You get JSON whatever `Accept` header you send, so a plain client never lands on an HTML login page. ```json { "code": "unauthenticated", "message": "Unauthenticated.", "request_id": "1f3c0e6a-6a5d-4a4c-9a3f-0a3f7d1c2b84" } ``` Branch on `code` rather than on `message`: the code is a stable contract, while the message is written for a person and may be reworded. Keep the `request_id` as well, so you can quote it if you ask us about a failure. A valid token is not enough on its own: the email address of the account has to be verified. Until it is, the API answers `403` with the `email_unverified` code. Verifying the address in your dashboard is all it takes. Every other way a request can fail is listed on the [errors](https://screenshotbuddy.io/documentation/errors.md) page. # Taking screenshots This guide will show you how to take screenshots using the ScreenshotBuddy API. > Make sure you have read the [quickstart guide](https://screenshotbuddy.io/documentation.md). It will help you understand how to authenticate and make requests to the API. ## Options | Parameter | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `url` | string | yes | | The URL of the page to render. It must start with `http://` or `https://`, be at most `2048` characters long, and point at a publicly reachable host. Private, loopback and internal addresses are refused. | | `fullPage` | boolean | no | `false` | Whether to capture the entire scrollable page instead of just the viewport. Defaults to `false`. Screenshots only; sending it with `pdf` is rejected, because a PDF always prints the whole document. | | `pdf` | boolean | no | `false` | Set this to `true` to render a PDF instead of an image. It changes which other parameters are accepted, so see [creating PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) for the options that belong to that mode. Defaults to `false`. | | `format` | string | no | `png` | The image format to return. Possible values are `png`, `jpeg` and `webp`. Defaults to `png`. Screenshots only; sending it with `pdf` is rejected. Use `paperFormat` to set the paper size of a PDF. | | `quality` | integer | no | | The quality of the image, between `1` and `100`. It applies to lossy formats only, so `format` has to be `jpeg` or `webp`; sending it with `png` is rejected. Screenshots only; sending it with `pdf` is rejected. | | `width` | number | no | | The viewport width of a screenshot, or the paper width of a PDF, between `1` and `10000`. Must be used together with `height`. For a PDF it overrides `paperFormat` and is read in the unit set by `marginUnit`. | | `height` | number | no | | The viewport height of a screenshot, or the paper height of a PDF, between `1` and `10000`. Must be used together with `width`. For a PDF it overrides `paperFormat` and is read in the unit set by `marginUnit`. | | `scale` | integer | no | `1` | In this mode, between `1` and `3`. The scale of the rendering, between `1` and `3` for screenshots and between `0.1` and `2` for PDFs. Screenshots take whole numbers only, because the browser renders at whole device scale factors; PDFs take fractions. Defaults to `1`. A value outside the range of the mode you are in is rejected. | | `delay` | integer | no | | How long to wait before capturing, in milliseconds, between `0` and `10000`. Useful for pages that animate on load. Screenshots only; sending it with `pdf` is rejected, because the PDF renderer has no way to wait a fixed amount of time before it prints. | | `selector` | string | no | | A CSS selector. Only the first element that matches it is captured, instead of the page. At most `512` characters, and it may not contain single quotes, backslashes or control characters, so write attribute selectors with double quotes: `a[href="/pricing"]`. It chooses what to capture, so it cannot be combined with a clip region or with `fullPage`. Screenshots only; sending it with `pdf` is rejected. | | `clipX` | integer | no | | The distance from the left edge of the page to the region to capture, in pixels, between `0` and `10000`. All four clip parameters (`clipX`, `clipY`, `clipWidth`, `clipHeight`) have to be set together. A clip region chooses what to capture, so it cannot be combined with `selector` or with `fullPage`. Screenshots only; sending it with `pdf` is rejected. | | `clipY` | integer | no | | The distance from the top edge of the page to the region to capture, in pixels, between `0` and `10000`. Set it together with the other three clip parameters. Screenshots only; sending it with `pdf` is rejected. | | `clipWidth` | integer | no | | The width of the region to capture, in pixels, between `1` and `10000`. Set it together with the other three clip parameters. Screenshots only; sending it with `pdf` is rejected. | | `clipHeight` | integer | no | | The height of the region to capture, in pixels, between `1` and `10000`. Set it together with the other three clip parameters. Screenshots only; sending it with `pdf` is rejected. | | `omitBackground` | boolean | no | `false` | Whether to render the page background transparent. Defaults to `false`. The format has to be able to hold transparency, so `png` or `webp`; sending it with `jpeg` is rejected rather than answered with a black background. Screenshots only; sending it with `pdf` is rejected. | | `waitForSelector` | string | no | | A CSS selector to wait for before capturing. The render continues once an element matching it exists. At most `512` characters, and under the same character restriction as `selector`: no single quotes, backslashes or control characters. Screenshots only; sending it with `pdf` is rejected. | | `waitUntil` | string | no | `networkidle2` | The load event to wait for before capturing. Possible values are `load`, `domcontentloaded`, `networkidle0` (no network connections for half a second) and `networkidle2` (at most two). Defaults to `networkidle2`. Screenshots only; sending it with `pdf` is rejected. | | `cache` | boolean | no | `true` | Whether an identical repeat of this request may be answered with the rendering we already made, and whether this rendering is kept for the next one. Defaults to `true`. A cached answer costs no credit, says so with `X-Cache: HIT`, and is counted against the account's cached-answer limit rather than against its renders; send `cache=0` to render the page again, which costs a credit and a render slot as any render does. Entries belong to your own account. See [caching](https://screenshotbuddy.io/documentation/caching.md) for the whole picture. | | `cacheTtl` | integer | no | `86400` | How long the rendering is kept, in seconds, between `60` and `2592000` (thirty days). Defaults to `86400`. Sending it with `cache=0` is rejected, because there is no lifetime to set on a rendering that is not being kept. | The PDF options are a different set, listed on the [creating PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) page. ## What the target answered A login wall renders. So does a `404` page, and a "too many requests" notice. The capture comes back as a crisp `200` with a real image in it, because that is genuinely what the page served, and nothing about the file itself says it is not the page you had in mind. A screenshot answer therefore carries `X-Target-Status`: the status the page itself answered while we were loading it, which is a different thing from the status of our response to you. A rendered `404` page is a `200` from us carrying `X-Target-Status: 404`. Read it before you store a capture, and you will not quietly fill a bucket with pictures of a sign-in form. A `4xx` or `5xx` target still costs a credit. We loaded the page and rendered what the server served, which is the work you asked for, and capturing an error page on purpose is a perfectly ordinary thing to want. The header is there so you can tell the two apart, not so you can be refunded for one of them. A challenge page is the case the header cannot flag for you. A bot check, Cloudflare's "checking your browser" interstitial or anything shaped like it, is usually served as a `200`, so `X-Target-Status: 200` is a truthful answer about a page that is not the one you meant. A consent wall or a cookie banner covering the content is the same shape. Nothing failed when a capture comes back as a picture of a challenge: the site served that to us because we arrived as automated traffic, and the render is billed like any other. `delay` and `waitForSelector` help when the interstitial clears on its own; when it does not, there is nothing you can send that gets us past it, because the API carries neither your credentials nor your cookies. A login wall that answers a real `401` or `403` at least says so in the header. The header is absent when we were not told a status, and absent means not known rather than `200`. [PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) never carry it: the renderer reports nothing at all about the page behind a PDF render. An element capture, one that names a `selector`, carries none either, and for a close cousin of that reason: the one renderer endpoint that can capture a single element is the one that says nothing about the page it loaded. Neither do captures we cached before this header existed, until they lapse and are rendered again. ## The playground The most common options on this page are controls in the playground at https://screenshotbuddy.io/playground. Change the viewport, switch the image type, and choose what the capture covers: the viewport, the whole page, one element named by a CSS selector, or a pixel region. The result appears next to the code that produces it. The options with no control of their own, `waitForSelector` and `omitBackground` among them, you add to the query string of the generated snippet yourself. # Creating PDFs This guide will show you how to create PDFs using the ScreenshotBuddy API. Send `pdf=true` alongside the parameters below. > Make sure you have read the [quickstart guide](https://screenshotbuddy.io/documentation.md). It will help you understand how to authenticate and make requests to the API. ## Options | Parameter | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `url` | string | yes | | The URL of the page to render. It must start with `http://` or `https://`, be at most `2048` characters long, and point at a publicly reachable host. Private, loopback and internal addresses are refused. | | `pdf` | boolean | no | `false` | Set this to `true` to render a PDF instead of an image. It changes which other parameters are accepted, so see [creating PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) for the options that belong to that mode. Defaults to `false`. | | `landscape` | boolean | no | `false` | Whether to use landscape orientation. Defaults to `false` (portrait). PDFs only; sending it without `pdf` is rejected. A screenshot is shaped by `width` and `height` instead. | | `paperFormat` | string | no | `a4` | The paper format. Possible values are `letter`, `legal`, `tabloid`, `ledger`, and `a0` through `a6`. Defaults to `a4`. PDFs only; sending it without `pdf` is rejected. Use `format` to set the image format of a screenshot. | | `width` | number | no | | The viewport width of a screenshot, or the paper width of a PDF, between `1` and `10000`. Must be used together with `height`. For a PDF it overrides `paperFormat` and is read in the unit set by `marginUnit`. | | `height` | number | no | | The viewport height of a screenshot, or the paper height of a PDF, between `1` and `10000`. Must be used together with `width`. For a PDF it overrides `paperFormat` and is read in the unit set by `marginUnit`. | | `marginTop` | number | no | | Top margin, between `0` and `1000`, in the unit set by `marginUnit`. All four margins (`marginTop`, `marginRight`, `marginBottom`, `marginLeft`) have to be set together. PDFs only; sending a margin without `pdf` is rejected. | | `marginRight` | number | no | | Right margin, between `0` and `1000`, in the unit set by `marginUnit`. Set it together with the other three margins. PDFs only; sending a margin without `pdf` is rejected. | | `marginBottom` | number | no | | Bottom margin, between `0` and `1000`, in the unit set by `marginUnit`. Set it together with the other three margins. PDFs only; sending a margin without `pdf` is rejected. | | `marginLeft` | number | no | | Left margin, between `0` and `1000`, in the unit set by `marginUnit`. Set it together with the other three margins. PDFs only; sending a margin without `pdf` is rejected. | | `marginUnit` | string | no | `mm` | The unit for the margins and for a custom paper size. Possible values are `mm`, `cm`, `in` and `px`. Defaults to `mm`. PDFs only; sending it without `pdf` is rejected, because a screenshot's `width` and `height` are viewport pixels. | | `scale` | number | no | `1` | In this mode, between `0.1` and `2`. The scale of the rendering, between `1` and `3` for screenshots and between `0.1` and `2` for PDFs. Screenshots take whole numbers only, because the browser renders at whole device scale factors; PDFs take fractions. Defaults to `1`. A value outside the range of the mode you are in is rejected. | | `cache` | boolean | no | `true` | Whether an identical repeat of this request may be answered with the rendering we already made, and whether this rendering is kept for the next one. Defaults to `true`. A cached answer costs no credit, says so with `X-Cache: HIT`, and is counted against the account's cached-answer limit rather than against its renders; send `cache=0` to render the page again, which costs a credit and a render slot as any render does. Entries belong to your own account. See [caching](https://screenshotbuddy.io/documentation/caching.md) for the whole picture. | | `cacheTtl` | integer | no | `86400` | How long the rendering is kept, in seconds, between `60` and `2592000` (thirty days). Defaults to `86400`. Sending it with `cache=0` is rejected, because there is no lifetime to set on a rendering that is not being kept. | The `delay` parameter is not accepted for PDFs. The PDF renderer has no way to wait a fixed amount of time before it prints, so a request that combines `delay` with `pdf` is rejected rather than rendered without the wait you asked for. The screenshot options are a different set, listed on the [taking screenshots](https://screenshotbuddy.io/documentation/taking-screenshots.md) page. A PDF is a rendering of whatever the server served, exactly as a screenshot is. A login wall, a `404` page or a bot challenge is printed as faithfully as the page you meant, and a `4xx` or `5xx` target still costs a credit. What a PDF answer does not carry is `X-Target-Status`: the renderer reports nothing at all about the page behind a PDF render, so the document itself is the only thing that says what was captured. The screenshot page explains the header under [what the target answered](https://screenshotbuddy.io/documentation/taking-screenshots.md). ## The playground Switch the playground at https://screenshotbuddy.io/playground to PDF mode to pick a paper format, flip to landscape, and read the generated document in the browser before you write a single line of code. # Errors This page lists every status the ScreenshotBuddy API answers with, what each one means, and which of them are worth sending again. > Check the status code before you touch the body. A successful response is the rendered file itself, so parsing it as JSON fails on a working request. ## The shape of an error Every error is JSON in the same shape, whatever went wrong. | Field | Type | Description | | --- | --- | --- | | `code` | string | The reason the request was refused, as a short stable string. This is the field to branch on. Every code is listed under error codes below. | | `message` | string | The same reason in words, written for a person to read. It can be reworded or made more specific at any time, so show it to a human rather than matching your code against it. | | `errors` | object | Sent only when a particular parameter is at fault. It is keyed by the query parameter, with the messages for that parameter, and it is left out entirely otherwise. | | `request_id` | string | Identifies that one answer, and it is in our logs against the request that produced it. Quote it when you ask us about a failure and we can look up exactly what happened. | You get JSON whatever `Accept` header you send, so a client that asks for nothing in particular still gets an error it can parse rather than an HTML page. The `request_id` is also sent as the `X-Request-Id` header, and the two are always the same value. A successful render answers with the file rather than JSON, so the header is the one place the id exists on every answer we send. Log it from there and you have it whether the request worked or not. ## Statuses | Status | Worth retrying | Codes | Description | | --- | --- | --- | --- | | `200` | success | | The rendered file. The body is the image or the PDF itself rather than JSON, so write it to a file instead of parsing it. The Content-Type names the format that was rendered. | | `304` | success | | The rendering is byte for byte the copy already held by the caller, named in the `If-None-Match` of the request, so no body is sent. What it costs depends on how that was established. An answer the cache could give is free: no credit, and counted against neither per-minute budget. One that had to render the page first, because the request sent `cache=0` or because the entry had lapsed, costs exactly what that render costs, a credit and a slot of the render budget, and what the caller saves is the download. `X-Cache: HIT` marks the free one; anything else was rendered, and carries the X-RateLimit headers to say so. | | `401` | do not retry | `unauthenticated` | The request carried no bearer token, or a token that is unknown or revoked. | | `402` | do not retry | `no_active_plan`, `credit_limit_reached` | The account has no active plan, or it has used every credit in the current period. The code says which of the two it is. | | `403` | do not retry | `email_unverified`, `missing_ability` | The email address of the account has not been verified yet, or the token is not allowed to ask for the mode the request is in. The code says which of the two it is: a token carries a permission per mode, and one narrowed to screenshots cannot print a PDF. | | `422` | do not retry | `validation_failed`, `invalid_url`, `blocked_host`, `target_unreachable` | A query parameter did not validate, or the target URL could not be reached or loaded ("The target URL could not be reached or loaded. Check that it is publicly available and try again."). The errors object names the parameter at fault. No credit is charged. | | `429` | retry | `rate_limited` | The account went over one of its two per-minute limits: the renders its plan allows, or the separate and far larger number of answers it may be served out of the cache. The X-RateLimit headers on this answer describe the limit that refused it. Wait for the number of seconds in the Retry-After header before sending the request again. | | `500` | do not retry | `render_failed`, `server_error` | The render failed for a reason we did not recognise. The credit is refunded. | | `502` | retry | `upstream_error`, `upstream_quota_exceeded` | The rendering service failed to process the request. The credit is refunded and the request is worth sending again. | | `503` | retry | `upstream_rate_limited` | The rendering service was momentarily busy and stayed busy across our own retries. The credit is refunded. Unlike the 502 this is not a failure of anything: wait the few seconds in the Retry-After header and the same request goes through. | | `504` | retry | `render_timeout` | The render did not finish within its time budget. The credit is refunded and the request is worth sending again. | The statuses marked `retry` (429, 502, 503 and 504) carry a `Retry-After` header with the number of seconds to wait before sending the same request again. Wait that long rather than retrying straight away: a retry that arrives sooner is answered the same way. The others describe a request that would fail the same way every time, so change something before you send it again. A render that fails after the request was accepted refunds its credit, so a retry costs you nothing you were not going to spend. ## Error codes Every code the API can answer with, and the status it comes back on. A status can carry more than one code, because the status alone does not always say what happened: both `402`s stop the request, but only one of them is fixed by buying more credits. | Code | Status | Meaning | | --- | --- | --- | | `unauthenticated` | 401 | No bearer token was sent, or the token is unknown or revoked. | | `invalid_signature` | 401 | A signed URL did not verify. Either the signature does not match the query, or the token it names is unknown, revoked or expired. Rebuild the URL from the canonical form on the signed URLs page, and check that nothing was appended to it after it was signed. | | `signed_url_expired` | 401 | A signed URL is past the `expires` moment it was signed with. The signature itself was fine, so sign a new URL with a later expiry. | | `email_unverified` | 403 | The account exists but its email address has not been verified yet. | | `missing_ability` | 403 | The token is not allowed to ask for this mode. A token carries a permission per mode, `screenshot` and `pdf`, and both are granted unless the token was deliberately narrowed. Widen it in your dashboard, or send the request with a token that holds the permission. | | `no_active_plan` | 402 | The account is on no plan, so it has no credits to spend. Pick a plan and the same request works. | | `credit_limit_reached` | 402 | Every credit in the current period is spent. It resets at the end of the period. | | `validation_failed` | 422 | A query parameter is missing, malformed, or not accepted in the mode the request is in. The errors object names it. | | `invalid_url` | 422 | The url is missing or is not a URL the API will accept. Fix the string and send it again. | | `blocked_host` | 422 | The url points at a private, loopback or internal address. Reformatting it will not help; only a publicly reachable host is rendered. | | `target_unreachable` | 422 | The page itself could not be reached or loaded. Check that it is publicly available. | | `rate_limited` | 429 | The account went over its renders per minute, or over the separate limit on the answers it may be served out of the cache. The X-RateLimit headers say which of the two. Wait for the Retry-After header and send the request again. | | `render_failed` | 500 | The render failed for a reason we did not recognise. The credit is refunded. | | `server_error` | 500 | Something failed that we did not expect at all. The catch-all, and always worth reporting with the request id. | | `upstream_error` | 502 | The rendering service failed to process the request. The credit is refunded and a retry is worthwhile. | | `upstream_quota_exceeded` | 502 | The rendering service has spent its own capacity for the period. Nothing is broken, so back off for longer than you would on an ordinary upstream failure. | | `upstream_rate_limited` | 503 | The rendering service was busy and stayed busy while we retried. Nothing is broken and nothing about the request is wrong; wait the seconds in Retry-After and send it again. | | `render_timeout` | 504 | The render ran past its time budget. The credit is refunded and the same request may well succeed later. | | `not_found` | 404 | No endpoint exists at that path. | | `method_not_allowed` | 405 | The endpoint exists but does not accept that HTTP method. The Allow header names the ones it does. | | `api_moved` | 410 | The request went to the base URL the API used to be served on. Nothing about the request is wrong: send it to the base URL named in the message and the path after it, the parameters, the headers and the token all stay as they are. | Three of them are not tied to taking a screenshot: `not_found`, `method_not_allowed` and `server_error` can answer any request under `https://api.screenshotbuddy.io/v1`, so handle them wherever you call us rather than only around a render. A fourth, `api_moved`, arrives from nowhere on this host at all: it is what the base URL the API used to be served on answers now, and nothing but the base URL has to change to make it go away. Codes are a published contract. We add new ones as the API grows, so treat one you do not recognise the way you would treat its status, and keep the `request_id` either way. ## Validation errors A request that leaves out `url`, or sends a parameter the mode it is in does not accept, comes back as `422` with the parameter named in the `errors` object. The code says which kind of failure it was: `invalid_url` for a url the API will not take, `blocked_host` for one it will not visit, and `validation_failed` for any other parameter. ```json { "code": "invalid_url", "message": "The url query parameter is required.", "errors": { "url": [ "The url query parameter is required." ] }, "request_id": "1f3c0e6a-6a5d-4a4c-9a3f-0a3f7d1c2b84" } ``` Which parameters a mode accepts is on the [taking screenshots](https://screenshotbuddy.io/documentation/taking-screenshots.md) and [creating PDFs](https://screenshotbuddy.io/documentation/creating-pdfs.md) pages. The `429` status has a page of its own under [rate limits](https://screenshotbuddy.io/documentation/rate-limits.md). # Rate limits The ScreenshotBuddy API limits how fast an account may ask for captures. There are two limits rather than one, because the two kinds of answer cost us completely different things: a render is a browser and a page load, while a repeat we can serve out of your cache is a file read. This page explains what each of them is, how to read them off a response, and what happens when you go over one. > The rate limit is separate from your credits. It caps how fast you may spend them, not how many you have. ## Your limits An account may ask for up to 20 renders per minute by default. Larger plans carry a higher limit, and an account can be given a limit of its own, so treat 20 as the floor rather than the number your integration should hard-code. Answers served out of your [cache](https://screenshotbuddy.io/documentation/caching.md) are counted apart from that, against a flat 300 per minute. That number is the same for every account whatever plan it is on, because a hit costs a file read rather than a render and there is nothing in it for a plan to scale. It is sized for the case it exists for: a page of [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md) thumbnails fetches twenty images the moment it loads, every one of them a hit. Charged to the render limit, that single page load would spend a whole minute of a plan's renders. ## What spends which A request spends a render when it asks us to render: a capture we have not made yet, or one sent with `cache=0`. A repeat we answer out of the cache spends a cached answer instead. A conditional request we can answer `304` out of your cache spends neither, so asking whether a capture you already hold has changed stays free in every sense. A `304` we had to render to establish is the other case. The page was loaded before we could say it looks the same, so it spends a render exactly like the `200` it replaced, and it carries the render pair to say so. What the answer saved you is the download. A request we refuse before we get as far as the cache costs you nothing at all. Failing validation, asking for a mode your token was narrowed away from, or sending from an account whose email is not verified leaves both budgets exactly where they were. Being refused for having no plan, or for having spent every credit, does spend a render. That request did ask us for render capacity, and a refusal that costs nothing to ask for is one that can be asked for in a loop. ## Reading your limit off a response Rather than keeping count yourself, read the headers the API sends back. | Header | Type | Description | | --- | --- | --- | | `X-RateLimit-Limit` | integer | The size of the budget this answer was counted against: your own render limit when the page was rendered for you, the flat 300 when the answer came out of the cache. The `X-Cache` header on the same answer says which of the two it was. | | `X-RateLimit-Remaining` | integer | What is left of that same budget after the request you are reading it on. When it reaches `0`, hold off until the minute is over. | | `Retry-After` | integer | Sent with a `429`, and with the other statuses worth retrying. It is the number of seconds to wait before sending the request again. | | `X-Credits-Limit` | integer | The credits your current period was granted. Sent with every answer to a request that carried a working token, a rendered file included. | | `X-Credits-Remaining` | integer | The credits you have left, counted after the request you are reading it on. A render that worked is already subtracted, and one that failed is already refunded, so you never have to guess which of the two happened. | | `X-Credits-Reset` | integer | When your credits are granted again, as a Unix timestamp in seconds. | | `X-Request-Id` | string | Identifies that one answer. It is on every response we send, so you can log it alongside a rendered file as well as alongside an error. | | `X-Target-Status` | integer | The status the page you captured answered, which is not the status of this response: a rendered `404` page comes back as a `200` carrying `X-Target-Status: 404`. On screenshot answers only, and absent when we were not told one. Absent means not known rather than `200`. See [what the target answered](https://screenshotbuddy.io/documentation/taking-screenshots.md). | The two sets answer two different questions. Credits are how many you have; the rate limit is how fast you may spend them. Running out of credits is not fixed by waiting a minute, and hitting the rate limit costs you nothing. The rate limit pair is on a rendered answer and on a `429`, and nowhere else. A `304` we answered out of your cache carries neither, because it was counted against neither budget, and a refusal we answer before anything is counted has no budget to report. A `304` we had to render for does carry the pair, because it was counted. On a `429` the pair describes the limit that actually refused the request, next to the `Retry-After` that says how long it holds, so you can tell which of the two you ran into. The headers only arrive with an answer, so they tell you where you stand after you have spent a request. To find that out before you send one, ask the usage endpoint below. Both sets are on answers to a request that carried your bearer token, and on none of the answers to a [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md). What a signed request escapes is the reporting, not the limit. Every fetch of a signed URL is counted in full, against both budgets and against the credits of the account whose token signed it, exactly as the same request sent with that token as a bearer would be, and it is refused with a `429` when it goes over. What a signed answer never carries is the numbers. A signed URL is built to be handed out, so its answer is read by whoever you gave it to and cached by whatever sits in front of them: your plan size and how much of it you have spent are not something an embedded image should be telling its readers. `X-Request-Id` and `Retry-After` are on both, because neither says anything about your account, so a signed `429` still tells whoever fetched it how long to wait. To see where a signed URL left your account, read the headers off a bearer request or ask the usage endpoint below. ## Checking your usage A `GET` to `https://api.screenshotbuddy.io/v1/usage` reports where your account stands: the credits you have left, used and were granted this period, the plan you are on, the date the period resets, and both of the per-minute limits your account is actually held to, as `requests_per_minute` and `cached_requests_per_minute`. It describes your account rather than refusing on it. An account with no plan, or one that has spent every credit, is answered `200` with the numbers that say so, where a render would come back as `402`. It carries a limit of its own of 60 requests per minute, separate from both limits above. Checking your usage therefore never spends anything you were saving for a render, so you are free to ask before every batch. ```bash curl "https://api.screenshotbuddy.io/v1/usage" \ -H "Authorization: Bearer {token}" ``` ```json { "credits": { "remaining": 8432, "used": 1568, "total": 10000 }, "plan": { "name": "Business", "slug": "business" }, "period": { "started_at": "2026-08-01T00:00:00+00:00", "resets_at": "2026-09-01T00:00:00+00:00" }, "rate_limit": { "requests_per_minute": 40, "cached_requests_per_minute": 300 } } ``` `plan` and `period` are `null` for an account that has neither yet, so read them before you use them. The [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json) describes the response in full. ## Going over a limit A request that goes over either limit is answered with HTTP status `429` and a `Retry-After` header. Nothing is rendered and no credit is spent, so waiting the stated number of seconds and sending the request again is all it takes. The [errors](https://screenshotbuddy.io/documentation/errors.md) page lists the other statuses worth retrying. If you are working through a queue of pages, spread the requests out instead of firing them all at once. A short pause between calls keeps you inside the limit and finishes the batch sooner than a burst that spends most of its time being refused. Pace that against the render number: a batch made mostly of captures you already hold is answered from the cache and may run far faster than your render limit alone suggests. Or hand the pacing to us. Every item of an [asynchronous batch](https://screenshotbuddy.io/documentation/batch-renders.md) is counted against the same render budget, so a batch buys no extra capacity, but an item that meets the limit waits and asks again instead of being refused. Submitting one spends neither budget and runs under a small throttle of its own of 10 submissions per minute. ## The playground The playground has a limit of its own: one capture per minute, whatever your account may do through the API. It is there to try options out by hand, so the pace is set for a person rather than for a script. # Caching Ask for the same page twice and the second answer is the rendering we already made for you. It arrives faster and costs no credit. This page explains when that happens, how to tell that it did, and how to turn it off. > Caching is on unless you turn it off. If you are watching a page for changes, send `cache=0` so every request renders the page as it is now. ## How it works A rendered file is kept under the exact request that produced it: the url, and every option you sent with it. Change any of them, a viewport, an image format, a selector, and you have described a different picture, so the page is rendered again. A screenshot and a PDF of one page are two entries for the same reason. Entries belong to your own account. Nobody else's request is ever answered with a rendering you paid for, and yours is never answered with theirs, so what you render stays between you and us. Identical requests that arrive at the same time are rendered once rather than once each. If you fan a batch of workers out over the same page, the first one renders it and the others are handed the result. The cache is also how an [asynchronous batch](https://screenshotbuddy.io/documentation/batch-renders.md) hands its results over. There is no response for a queued render to travel back in, so the rendering is kept here and the batch gives you a link that fetches it. That is why an item of a batch may not turn caching off, and why its `cacheTtl` has a floor of its own. ## What it costs A cached answer costs no credit. We render nothing, so there is nothing to charge for, and the answer arrives without waiting for a browser. It is counted against a [rate limit](https://screenshotbuddy.io/documentation/rate-limits.md) of its own rather than against the one your renders come out of: 300 cached answers per minute, the same for every account whatever plan it is on. That is far more room than the render limit gives you, which is the point. A page that fetches twenty thumbnails the moment it loads is twenty hits, and it should not be able to spend a minute of the renders you were saving for real work. Because a hit costs us nothing, it is served even when your credits have run out or your plan has lapsed. A request that has to render is refused in that situation; one we can answer from a rendering you already paid for is not. ## Reading the headers Every rendered answer says where it came from. | Header | Type | Description | | --- | --- | --- | | `X-Cache` | string | `HIT` means the file came out of the cache, so nothing was rendered and no credit was spent, and the `X-RateLimit` headers beside it report the cached budget of 300 rather than your render limit. `MISS` means the page was rendered for this request and the result was kept for the next one. The header is absent altogether when you sent `cache=0`: that request declined the cache rather than missing it. | | `ETag` | string | A quoted fingerprint of the file itself, so two renderings that produced the same bytes carry the same tag. Keep it alongside whatever you did with the file and send it back on your next request to find out, for free, whether anything changed. | ## Asking whether anything changed Send the `ETag` of the copy you already hold back as an `If-None-Match` header. If the entry is still the one that tag describes, the answer is `304` with no body at all: no credit, no file to download a second time, and nothing counted against either rate limit. It is the one answer the API gives away entirely, so a client that polls for changes can do so as often as it likes. A conditional request the cache cannot answer, because you sent `cache=0` or because the entry has lapsed, renders the page first and then compares. If the fresh bytes carry the tag you hold, that is a `304` with no body too. This one costs what the render costs, a credit and a slot of your render budget, because the page had to be loaded to find out. What you save is the download, and the answer carries the `X-RateLimit` pair to say which budget paid for it. Anything else is answered normally, with the file. ```bash curl "https://api.screenshotbuddy.io/v1/snap?url=https%3A%2F%2Fexample.com" \ -H "Authorization: Bearer {token}" \ -H 'If-None-Match: ""' \ --output screenshot.png ``` ## Watching a page for changes Put the two together and you have a monitor. Send `cache=0` with the `If-None-Match` of the last capture you kept, as often as the page deserves. A `304` means the page still looks exactly as it did. A `200` means it does not, and the body is the new capture, carrying the new `ETag` to hold for the next round. Every round really loads the page, so what you are told is about the page rather than about our cache, and every round costs one render. That is the price of finding out. Branch on the status rather than on the file you were writing to. A `304` has no body, so a downloader pointed straight at the copy you are keeping would empty it; write to a scratch name and keep it only when the status was `200`. ```bash curl "https://api.screenshotbuddy.io/v1/snap?url=https%3A%2F%2Fexample.com&cache=0" \ -H "Authorization: Bearer {token}" \ -H 'If-None-Match: ""' \ --write-out "%{http_code}\n" \ --output capture-new.png ``` ## How long a rendering is kept A rendering is kept for 86400 seconds unless you say otherwise. Send `cacheTtl`, in seconds, to choose your own: at least `60` and at most `2592000`, which is thirty days. Pick it from how often the page changes rather than from how often you ask: a marketing page can sit at the maximum, a dashboard should not. Once the time is up the next request renders the page again, and costs a credit again. ## Always rendering afresh Send `cache=0` and the page is rendered as it is right now. That request neither reads the cache nor fills it, it carries no `X-Cache` header, and it costs a credit like any other render. Sending `cacheTtl` alongside it is rejected with a `422`: there is no lifetime to set on a rendering that is not being kept. # Signed URLs A signed URL is a capture request that proves, on its own, that whoever built it holds one of your API tokens. You can hand it to a browser, a CDN or an email client, because the token itself never travels with it. > You sign on your server, with the signing secret of a token. The secret stays there; only the resulting signature goes into the URL. Find both the token id and its signing secret under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). ## Why signed URLs exist The thing that usually wants a screenshot is a browser. An `` tag cannot send an `Authorization` header, and putting a token where a browser can read it publishes it, as the [authentication](https://screenshotbuddy.io/documentation/authentication.md) page warns. The usual way out is to proxy every capture through your own backend, which means running a service whose only job is to add a header. A signed URL removes that service. You build the URL wherever you already render your page, sign it with a secret that never leaves your server, and put the result in the `src`. Anyone who sees the URL can fetch exactly the capture it describes and nothing else: they cannot change the page it points at, widen the viewport, or use it to make any other request against your account. Because the answer is a plain `GET` with cache headers, a CDN in front of it works the way it does for any other image. You do not always have to build one. An [asynchronous batch](https://screenshotbuddy.io/documentation/batch-renders.md) hands back a signed URL per finished item, minted for you with the token you polled with, which is how the results of a batch are collected at all. They are these same URLs under the same rules, so everything below describes what you are given as well as what you build. ## What you need Two values, both belonging to one API token, both under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). | Value | Type | Description | | --- | --- | --- | | `tokenId` | integer | The id of the token, shown next to it in the token list as `42\|...`. It is the number in front of the pipe in the token you copied when you created it. It is not a secret, and it travels in the URL as an ordinary parameter. | | signing secret | string | 64 lowercase hexadecimal characters, revealed on demand from the token list. This is the key you sign with, and it is as sensitive as the token itself: keep it in an environment variable or a secrets manager, never in anything a browser receives. | The token's [permissions](https://screenshotbuddy.io/documentation/authentication.md) still apply. A URL signed with a `screenshot` only token that asks for `pdf=1` is refused with a `403`, exactly as a bearer request would be. ## How to sign Signing is six steps over the parameters you are about to send. Follow them exactly: the server rebuilds the same string from the request it receives and compares the two. 1. Collect every query parameter you intend to send, including `tokenId` and, if you want one, `expires`. Leave `signature` out: it is the thing you are about to produce. 2. Sort the parameters by name, comparing the raw bytes of the names. That is a plain bytewise sort, not a locale-aware or case-insensitive one, so uppercase letters sort before lowercase ones. 3. Percent encode each name and each value with RFC 3986 rules, then join each pair with `=`. A space becomes `%20`, never `+`, and `-`, `_`, `.` and `~` are the only punctuation left alone. In PHP that is `rawurlencode`; in JavaScript, `encodeURIComponent`. 4. Join the encoded pairs with `&`. 5. Put `snap-signed-v1` and a single newline character in front of the whole thing. This prefix is part of what you sign. 6. Take the HMAC-SHA256 of that string, keyed with the signing secret. Use the secret as it is written, its 64 characters as ASCII bytes; do not decode it back into 32 bytes first. Write the result as lowercase hexadecimal and send it as `signature`. The order you put the parameters in the finished URL does not matter, and neither does whether your HTTP client spells a space as `%20` or `+`. The server decodes the query first and rebuilds the canonical string from the decoded values, so only the values themselves are signed. ## A worked example Check your implementation against this before you wire it to a real token. The secret below is not a real one, and every value on this page is produced by the same code that verifies your requests. ```text secret 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef parameters url https://example.com/pricing width 1200 height 630 tokenId 42 expires 1767225600 canonical string (the \n after the prefix is a real newline) snap-signed-v1 expires=1767225600&height=630&tokenId=42&url=https%3A%2F%2Fexample.com%2Fpricing&width=1200 signature fb13a974f037f44544653893b3468696f2357ac5c4460dde3c3e61c34b5dc514 ``` Which makes this the URL to put in the `src`. ```html ``` ## Reference implementations Both of these produce the signature in the worked example above when you hand them the same secret and parameters. ```php $value) { $pairs[] = rawurlencode($name) . '=' . rawurlencode((string) $value); } $query['signature'] = hash_hmac( 'sha256', "snap-signed-v1\n" . implode('&', $pairs), $secret ); return 'https://api.screenshotbuddy.io/v1/snap/signed?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); } echo signedSnapUrl( ['url' => 'https://example.com/pricing', 'width' => '1200', 'height' => '630'], 42, getenv('SCREENSHOTBUDDY_SIGNING_SECRET'), time() + 3600 ); ``` ```javascript import { createHmac } from 'node:crypto'; const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase() ); export function signedSnapUrl(query, tokenId, secret, expires = null) { const params = { ...query, tokenId: String(tokenId) }; if (expires !== null) { params.expires = String(expires); } const canonical = Object.keys(params) .sort() .map((name) => `${encode(name)}=${encode(params[name])}`) .join('&'); const signature = createHmac('sha256', secret) .update(`snap-signed-v1\n${canonical}`) .digest('hex'); const url = new URL('https://api.screenshotbuddy.io/v1/snap/signed'); for (const [name, value] of Object.entries({ ...params, signature })) { url.searchParams.set(name, value); } return url.toString(); } console.log( signedSnapUrl( { url: 'https://example.com/pricing', width: '1200', height: '630' }, 42, process.env.SCREENSHOTBUDDY_SIGNING_SECRET, Math.floor(Date.now() / 1000) + 3600 ) ); ``` `Array.prototype.sort` with no comparator sorts by UTF-16 code unit, which is the bytewise order this scheme asks for as long as your parameter names are ASCII, and every parameter this API accepts is. The `encode` helper exists because `encodeURIComponent` leaves `!`, `'`, `(`, `)` and `*` alone while RFC 3986 does not. ## Expiring a URL Send `expires` as a Unix timestamp in seconds and the URL stops working after that moment. It is optional: without it, a signed URL keeps working for as long as the token behind it does, and it is the same URL every time you mint it, which is what an embed wants. See caching a signed URL below. It is an ordinary signed parameter, so nobody can push it back without the secret. Choose it from how long the page that carries the URL is expected to live. A dashboard that reloads every few minutes wants a short one; a capture linked from an email that people open days later wants a long one, or none at all. A URL past its expiry is answered with `401` and the `signed_url_expired` code, which is the one failure worth handling on its own: it means your signing code is fine and the URL simply needs minting again. ## When a signature fails Both failures answer `401` with the usual error envelope, so branch on `code`. | Code | Status | Meaning | | --- | --- | --- | | `invalid_signature` | 401 | A signed URL did not verify. Either the signature does not match the query, or the token it names is unknown, revoked or expired. Rebuild the URL from the canonical form on the signed URLs page, and check that nothing was appended to it after it was signed. | | `signed_url_expired` | 401 | A signed URL is past the `expires` moment it was signed with. The signature itself was fine, so sign a new URL with a later expiry. | ```json { "code": "invalid_signature", "message": "This signed URL is not valid. Check that the signature covers every query parameter, and that the token it names still exists.", "request_id": "1f3c0e6a-6a5d-4a4c-9a3f-0a3f7d1c2b84" } ``` An unknown token id, a revoked token and a wrong signature all answer `invalid_signature`, in the same words. That is deliberate: distinguishing them would let anyone walk the id space and find out which tokens exist. Repeated failures from one address are refused with a `429` for a while, so a signing bug in a loop backs off rather than hammering the endpoint. > Every query parameter is covered by the signature, so adding one breaks it. The usual way this happens is not your code: it is something appending a tracking parameter such as `fbclid` or `utm_source` to the URL on its way to us. If a URL that worked in testing fails in the wild, compare what actually arrived with what you signed. ## Rotating the secret [Rotating a token](https://screenshotbuddy.io/documentation/authentication.md) gives it a new signing secret along with its new value, and deleting a token takes its secret with it. Either way, every URL already signed with the old secret stops verifying immediately. That is what you want after a leak, and it is worth knowing before a routine rotation: anything already published with a long lived signed URL, an email that went out last week, a cached page, will start showing a broken image. If that matters, sign with a token you rotate on your own schedule and keep a second one for the URLs you cannot recall. ## Caching a signed URL Our own [cache](https://screenshotbuddy.io/documentation/caching.md) works exactly as it does for a bearer request, and it is the same cache: a signed URL and a bearer request for the same capture on the same account share one entry, so whichever arrives second is a free `X-Cache: HIT`. That is what lets a page of embeds behave. A list with twenty thumbnails in it fetches twenty images the moment it loads, and once each capture has been made every one of those fetches is a hit. Hits are counted against a [rate limit](https://screenshotbuddy.io/documentation/rate-limits.md) of their own, 300 per minute flat, rather than against the renders your plan allows per minute. A page full of embeds is paced by that far larger number, and it leaves your render allowance for the captures that have still to be made. Both budgets are counted in full on a signed URL, against the account whose token signed it, exactly as they are on a bearer request, and the credits are spent the same way. What a signed answer never carries is the numbers: the `X-RateLimit-` and `X-Credits-` headers are taken off before it leaves, because whoever fetched the image is not who your plan size and your spending are for. A fetch that goes over a limit is still refused with a `429` and a `Retry-After`, so pace a page of embeds by the limits themselves rather than by what an answer tells you. On top of that, a signed answer says what caches in front of us may do with it. A rendered answer carries `Cache-Control: public` with a `max-age` of the rendering's own lifetime, shortened to whatever is left of `expires` so that no cache outlives the URL. Send `cache=0` and it is `no-store` instead, which is also what every refusal carries: a `402` that a CDN kept would go on serving a broken image long after you topped up. Leaving `expires` off is what makes those caches worth having. The signature is a function of the parameters alone, so the same capture signed with the same secret produces the same signature every time, and the URL your page renders today is character for character the URL it rendered yesterday. A browser or a CDN that already holds that image recognises it and does not come back to us for it. Add an `expires` and the URL changes every time you build it, which is a fresh entry in every cache and a fresh fetch from us, so keep it for the URLs that ought to stop working rather than for the embeds you want cached. Send `cacheTtl` to choose that lifetime, the same way you would on `/snap`. # Batch and async renders A render is a browser loading somebody else's page, and some pages take their time about it. Asking for one synchronously means holding a connection open for as long as that takes, which is fine from a script and awkward from a web request that has its own timeout to answer to. The asynchronous endpoints take the waiting off your side of the wire: you hand us a set of captures, we answer immediately, and you collect the results when they are ready. > Nothing about a render changes by asking for it this way. The same permissions, the same credit, the same cache and the same per-minute limits apply to every item. ## When to use it Two situations, and both of them are about time rather than about volume. The first is a page that is slow to settle. A dashboard that waits on three API calls, a report that renders a chart, anything you have to give a `delay` or a `waitForSelector` to. Synchronously that is a request your own stack has to be willing to sit on, and the timeout that gives up first is usually not ours. Asked for asynchronously, the render takes exactly as long as it takes and nothing on your side is waiting for it. The second is a set of captures rather than one: thumbnails for every entry on a list page, an archive of the pages that changed overnight. Done synchronously, that is a queue of your own with a pause between calls tuned to a [render limit](https://screenshotbuddy.io/documentation/rate-limits.md) you have to know, retries for the items that met it anyway, and somewhere to keep the answers. Submitted as a batch, it is one request and one thing to collect. We do the pacing. What it is not for is a capture you are about to show someone. A synchronous `GET /snap` answers with the file itself, which is one round trip rather than a submission, a poll and a fetch. ## Submitting a batch `POST https://api.screenshotbuddy.io/v1/renders` with your bearer token and a JSON body. `items` is the list of captures, at most 20 captures in one submission, and each item takes the same parameters as a [screenshot](https://screenshotbuddy.io/documentation/taking-screenshots.md) or a [PDF](https://screenshotbuddy.io/documentation/creating-pdfs.md) request. `webhookUrl` is optional and says where to tell you the batch is done. ```bash curl "https://api.screenshotbuddy.io/v1/renders" \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{"items":[{"url":"https://example.com/pricing","width":1200,"height":630},{"url":"https://example.com/blog","fullPage":true,"cacheTtl":86400}],"webhookUrl":"https://example.com/hooks/renders"}' ``` Written out, the body is this. Every field of an item is optional except `url`, and an item that contradicts itself is refused exactly as the same query string would be over `GET /snap`: the rules are not a looser second copy, they are the same rules. ```json { "items": [ { "url": "https://example.com/pricing", "width": 1200, "height": 630 }, { "url": "https://example.com/blog", "fullPage": true, "cacheTtl": 86400 } ], "webhookUrl": "https://example.com/hooks/renders" } ``` The answer is a `202` carrying the whole batch document, every item `queued`. It is the same document the poll endpoint serves, so you learn the shape of the answer from the answer to your own submission, and you can start polling with the `id` in front of you. When something is wrong with an item, the `422` names it under the index of the item it belongs to, as `items.0.width`. Nothing is queued and nothing is charged: a batch is accepted whole or not at all. The submission itself renders nothing and reads nothing, so it spends neither of your per-minute budgets. It carries a small throttle of its own instead, 10 submissions per minute, which is generous for something that queues 20 renders at a time. ## What an item may not ask for Three rules exist here and nowhere else, and all three are about the render being asked for in advance rather than about the capture. **Caching may not be turned off.** There is no response to put a rendering in, so an asynchronous result is delivered through your [cache](https://screenshotbuddy.io/documentation/caching.md): we render the page, keep it, and hand you a link that fetches it. With `cache=0` there would be nothing left to collect, so an item that sends it is refused. Send that capture to `GET /snap` instead, where the rendering travels back in the answer. **A `cacheTtl` has a higher floor.** At least 3600 seconds, rather than the seconds the synchronous endpoint allows. A rendering that lapsed while its own batch was still draining would be one you could never collect, and an hour is comfortably longer than the quarter of an hour an item may spend waiting for room to run. **Two items may not describe the same capture.** Same URL, same options, in one submission: that is one render, and queueing it twice means two workers racing for it. Whichever lost the race would render the page a second time and take a second credit for it, so the batch is refused with a message naming the item it duplicates. There is one more bound, on the account rather than on the item. At most 100 items may be outstanding across all of your batches at once. It is counted that way, rather than per batch, because ten batches of twenty is the same amount of queued work as one batch of two hundred. A submission that would take you past it is refused with a `422` saying how many you already have waiting, which is a signal to collect some results rather than to retry. ## How your renders are paced A batch buys you no extra capacity. Every item is counted against the same [renders per minute](https://screenshotbuddy.io/documentation/rate-limits.md) your plan allows, and an account submitting twenty items renders them at the pace it would have rendered them at one by one. What changes is who does the waiting. An item that meets your per-minute limit is not failed: it goes back to the queue, waits out the number of seconds the limit reports, and asks again, for up to a quarter of an hour. That is the whole reason to send a set rather than a loop. A daily or monthly ceiling is different, because waiting is not what fixes it, so an item that meets one of those is failed with `rate_limited` and the message that says which ceiling it was. Items that are already in your cache are answered from it without rendering at all, which costs neither a credit nor a render slot. A batch made mostly of captures you already hold therefore drains far faster than the render limit alone suggests. ## Polling for the result `GET https://api.screenshotbuddy.io/v1/renders/{id}` with your bearer token answers with the batch as it stands, whatever state that is. It renders nothing and carries no throttle of its own, so poll it as often as suits you. A batch that is not yours, an id that never existed and a batch that has been deleted all answer `404` alike, so the endpoint says nothing about which ids are real. ```json { "id": "01k1x8w0k3n6qv2r7y9c4h5t8m", "status": "pending", "created_at": "2026-08-05T10:15:00+00:00", "finished_at": null, "prunes_at": "2026-08-12T10:15:00+00:00", "webhook": { "url": "https://example.com/hooks/renders", "delivered_at": null }, "items": [ { "id": "01k1x8w0k4a2be7d9f1g3h5j7k", "url": "https://example.com/pricing", "status": "done", "error": null, "etag": "\"8f14e45fceea167a\"", "target_status": 200, "expires_at": "2026-08-06T10:16:04+00:00", "signed_url": "https://api.screenshotbuddy.io/v1/snap/signed?url=https%3A%2F%2Fexample.com%2Fpricing&width=1200&height=630&tokenId=42&expires=1786097764&signature=6f1c...", "credit_cost": 1 }, { "id": "01k1x8w0k5m4np6qr8s0t2u4v6", "url": "https://example.com/blog", "status": "failed", "error": { "code": "target_unreachable", "message": "The target URL could not be reached or loaded. Check that it is publicly available and try again." }, "etag": null, "target_status": null, "expires_at": null, "signed_url": null, "credit_cost": 0 }, { "id": "01k1x8w0k6w8xy0za2b4c6d8e0", "url": "https://example.com/changelog", "status": "queued", "error": null, "etag": null, "target_status": null, "expires_at": null, "signed_url": null, "credit_cost": null } ] } ``` The batch's own `status` is `pending` until every item has finished and `complete` afterwards. Each item carries a status of its own. | Status | Meaning | | --- | --- | | `queued` | Waiting for a worker, or waiting out your per-minute render limit before trying again. | | `rendering` | A browser is loading the page right now. | | `done` | The page was rendered and kept. There is a result to fetch. | | `failed` | The render was refused or did not finish. The error field says why, and no credit was kept. | `done` and `failed` are the terminal ones. The set may grow, so treat a status you do not recognise as not finished yet rather than as a failure; that is the one branch that keeps working when it does. A finished item also reports `target_status`, the status the captured page itself answered, which is the [`X-Target-Status`](https://screenshotbuddy.io/documentation/taking-screenshots.md) of a synchronous answer under another name: a page that served `404` renders and bills like any other, so this is what tells a screenshot of your page from a screenshot of a sign-in form. It is `null` when we were not told one, which covers an item that has not finished, a PDF item and one answered out of a rendering cached before this was recorded. A failed item carries an `error` object with the same `code` and `message` an [error envelope](https://screenshotbuddy.io/documentation/errors.md) carries, from the same published set. One branch handles a synchronous refusal and an asynchronous one, and the `missing_ability` you would have seen on a `403` is the same code here when the token that submitted the batch was revoked, rotated or narrowed away from the mode the item asked for. ## Fetching the results A finished item carries a `signed_url`. It is an ordinary [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md), minted for you rather than by you, and opening it fetches the rendering: no bearer token, no credit, and it can go straight into an `` or a download. It is signed with the token that is doing the polling, not with the one that submitted the batch, so the link carries the permissions of whoever is asking now and stops working when their token is revoked or rotated. Its expiry never outlives the cached rendering behind it, which is what makes it free to open: a link that outlived its entry would render the page again and take another credit, charged to you and spent by whoever you forwarded it to. `signed_url` is `null` in three situations, and none of them means the result is gone. The item has not finished; or the cached rendering has lapsed, which `expires_at` told you was coming; or the token you polled with has no signing secret to mint a link with, which is the case for a browser session. In every one of them the other way of fetching the result still works: repeat the identical `GET https://api.screenshotbuddy.io/v1/snap` request the item was submitted as. While the rendering is still cached that is a hit, so it costs no credit and comes out of the far larger cached-answer budget rather than out of your renders. ## The webhook Send a `webhookUrl` with the submission and we post the finished batch to it once, when every item is terminal, rather than one message per item. The payload is exactly the document the poll endpoint serves, built by the same code, so an integration that reads the webhook and falls back to polling is reading one shape twice. The URL has to be an `http://` or `https://` address on a publicly reachable host, HTTPS in production. It is checked again at delivery rather than only at submission, because a hostname that resolved publicly when you submitted can resolve somewhere internal by the time the batch finishes. Three headers travel with each delivery. | Header | Type | Description | | --- | --- | --- | | `X-Webhook-Id` | string | The id of the batch. It is the same value on every attempt at delivering one batch, so it is what to record and compare against if you want to be sure you handle a delivery once. | | `X-Webhook-Timestamp` | integer | When the delivery was made, as a Unix timestamp in seconds. It is covered by the signature, so it cannot be moved without signing again. | | `X-Webhook-Signature` | string | The proof the delivery is ours: a lowercase hex HMAC-SHA256, 64 characters, keyed with the signing secret of the token the batch was submitted with. | The key is the same signing secret that signs a [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md), shown once when you reveal it under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). The signature is taken over three things joined together, in this order: 1. `snap-webhook-v1\n`, the literal string `snap-webhook-v1` and a newline. It is there so that an HMAC of your signing secret can never be mistaken for anything else that key signs, a signed URL above all, and the version in it means a future change to what is signed can say so rather than quietly changing what an existing signature means. 2. The `X-Webhook-Timestamp` header value exactly as it arrived, as digits, followed by one newline. 3. The raw request body, as the bytes arrived, before any JSON parsing. Parsing and re-encoding produces different bytes and therefore a different signature, so read the body first and verify it before you decode it. ```php 300) { return false; } $expected = hash_hmac('sha256', "snap-webhook-v1\n" . $timestamp . "\n" . $body, $secret); // Constant time: a normal comparison leaks how much of the signature was // right, one character at a time. return hash_equals($expected, $signature); } $body = file_get_contents('php://input'); if (! snapWebhookIsAuthentic( $body, $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', getenv('SCREENSHOTBUDDY_SIGNING_SECRET') )) { http_response_code(403); exit; } $batch = json_decode($body, true); ``` Answer with any `2xx` and the delivery is done; the batch records the moment under `webhook.delivered_at`. Anything else, or a connection that fails, is retried twice, after about half a minute and then about five minutes, which covers a receiver being restarted or deployed. After the third attempt we stop. That is deliberate: the webhook is a convenience rather than the record, and everything it carried is still on the poll endpoint for as long as the batch is kept. One case sends no webhook at all. If the token the batch was submitted with has been revoked or rotated by the time the batch finishes, there is no secret left to sign with, and posting the payload unsigned would hand your endpoint a document it has no way to verify. Polling with a current token still works. ## What it costs A credit per rendered item, exactly as a synchronous call. Each item reports what it actually cost as `credit_cost`: `1` for a render, `0` for an item answered from the cache and for an item that failed, because a failed render is refunded. It is `null` until the item has finished, since an item that has not run yet has not decided which of those it will be. Submitting costs nothing, polling costs nothing, and opening a `signed_url` while the rendering is still cached costs nothing. Adding up the `credit_cost` of every item is therefore the whole bill for a batch. Credits are checked per item at render time rather than once at submission, which is what a batch that outlives its allowance needs: the items you could pay for are rendered, and the ones you could not are failed with the billing code that says so. Topping up and submitting those again is all it takes. ## How long a batch is kept A batch is deleted 7 days after it finishes, and every answer says when that will be as `prunes_at`. A batch that is still running is told the earliest it could go, which moves out as the batch takes longer. After that moment the id answers `404`, so treat `prunes_at` as the deadline for reading anything out of a batch you still care about. A week is sized for the case the webhook exists for failing entirely: your endpoint was down over a weekend and you come back to it on Monday. It is not an archive, and it is not the lifetime of the renderings themselves, which is the `cacheTtl` each item asked for and is reported per item as `expires_at`. The two run out at their own pace, and a batch record whose renderings have lapsed still tells you what was rendered and what it cost. # The MCP server The same API, spoken over the Model Context Protocol, so an AI client can render a page itself instead of being told how to write the HTTP request. Five tools: one takes a screenshot, one prints a PDF, one reports where the account stands, and two submit and poll a batch. Underneath they are the endpoints the rest of this documentation describes, running the same validation, the same permissions, the same credits and the same cache. > Connecting a client mints no second credential and grants no second allowance. The tokens are the ones you already have, and what a render costs is counted in the same place it is counted for an HTTP call. ## Connecting a client The server is at `https://screenshotbuddy.io/mcp`, over streamable HTTP. A client needs two things from you: that URL and an `Authorization` header carrying an API token, the same header a REST call sends. There is no OAuth step and nothing to install. ```bash claude mcp add --transport http screenshotbuddy https://screenshotbuddy.io/mcp --header "Authorization: Bearer " ``` Run it in the project you want the server available in. Claude Code keeps the header with the server, so the token travels on every call and never has to be repeated. ```json { "mcpServers": { "screenshotbuddy": { "url": "https://screenshotbuddy.io/mcp", "headers": { "Authorization": "Bearer " } } } } ``` Cursor reads `.cursor/mcp.json` in a project and `~/.cursor/mcp.json` everywhere. Any other client that can add a remote MCP server takes the same two values under whatever it calls them, so if you can give it a URL and a header you can connect it. The [agents page](https://screenshotbuddy.io/agents) carries the same setup for a few more clients, and the one for a client that reads Agent Skills rather than MCP servers. Whatever the client, commit the URL and not the token. The file that names the server is a file people share; the token is a credential that spends your credits. ## The five tools `take-screenshot` and `create-pdf` take the parameters their REST counterparts take, from the same description of the API, and are checked by the same rules: a combination refused over `GET https://api.screenshotbuddy.io/v1/snap` is refused here in the same sentence. There is no `pdf` switch to set, because the tool is the mode. That is also what lets each tool publish the exact range its own mode allows rather than the wider of the two. | Tool | What it answers with | | --- | --- | | `take-screenshot` | The image itself, in the conversation, so the model can look at what it captured rather than read a description of it. Beside it comes a line naming the URL, the size and the format, and saying what the render cost. Its parameters are the [screenshot options](https://screenshotbuddy.io/documentation/taking-screenshots.md). | | `create-pdf` | A link to the rendering rather than the file, because a PDF is far too large to travel in a tool result. The page is rendered on the call, so a failure surfaces to the agent that can act on it rather than to whoever clicks the link. Its parameters are the [PDF options](https://screenshotbuddy.io/documentation/creating-pdfs.md). | | `check-usage` | The document `GET https://api.screenshotbuddy.io/v1/usage` serves, built by the same code: credits remaining, the plan, when the period resets and both per-minute limits. It reports zero rather than refusing when the account has nothing left, which is the case it exists for. See [the usage endpoint](https://screenshotbuddy.io/documentation/rate-limits.md). | | `submit-render-batch` | The batch that will carry up to 20 queued renders, answered immediately with its id, every item still queued. Nothing renders on the call and nothing is billed by it: each item is rendered in the background and billed as it would have been one at a time. An item takes the same parameters as the two rendering tools, plus `pdf` to make it a PDF, under the [batch rules](https://screenshotbuddy.io/documentation/batch-renders.md). | | `check-render-batch` | Where a submitted batch has got to: the document `GET /v1/renders/{batch}` serves, built by the same code, with a signed URL for every finished item and the machine readable reason for every failure. Polling is free the way `check-usage` is free, so poll a few seconds apart until the batch reports itself finished. | The link `create-pdf` answers with is an ordinary [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md), minted for you rather than by you. It needs no token, so it can be opened or forwarded as it is, and it works for at most 24 hours, less when the cached copy behind it lapses first. It is signed with the signing secret of the token that called the tool, which means it carries that token's permissions and stops working the moment the token is revoked or rotated. Opening it while the copy is still cached is free; a call that turned caching off leaves nothing to serve, so opening that link renders the page again and costs another credit, and the tool says so. A refusal comes back as the error a REST caller would have received, carrying the same machine readable `code` from the same published set. A model can therefore tell `missing_ability`, which no retry fixes, from `render_timeout`, which one might, without reading the English beside it. See [every error code](https://screenshotbuddy.io/documentation/errors.md). ## The token it authenticates with The same tokens as the REST API, created in the same place: [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). Copy the value while it is on screen and paste it into the header above. There is deliberately no endpoint that mints a token, so an agent cannot create one for itself, and a token that expires or is [rotated](https://screenshotbuddy.io/documentation/authentication.md) stops working here at the same moment it stops working everywhere else. The two [permissions](https://screenshotbuddy.io/documentation/authentication.md) narrow the tools exactly as they narrow the endpoints. A token granted `screenshot` and not `pdf` can call `take-screenshot` and is refused by `create-pdf`, with the same `missing_ability` the REST call answers `403` with, and the refusal happens before any credit is spent. Narrowing a token before handing it to an agent is the cheapest way to decide what it may spend your credits on. A call with no token at all, or with one we do not recognise, never reaches a tool: the endpoint answers `401` and the client reports that it cannot connect. ## What it costs One credit per fresh render, exactly as over HTTP. An identical call repeated is answered from your [cache](https://screenshotbuddy.io/documentation/caching.md) and costs nothing, and both rendering tools say which of the two happened in the line beside their answer, so a model can tell a paid render from a free one and has a reason to reach for the cache. `check-usage` is free whatever it reports. The per-minute budgets are counted in the flow both surfaces share, not per surface, so a client gets no second allowance by asking over MCP rather than over HTTP. A fresh render spends the render budget; an answer served from the cache comes out of the separate and far larger cached-answer budget instead. See [how the two budgets work](https://screenshotbuddy.io/documentation/rate-limits.md). The transport carries a ceiling of its own, set at the sum of those two budgets, and it exists for the calls that never reach a render at all: the protocol handshake, the tool listing, `check-usage`. Those cost a slot here and nothing anywhere else, which is why asking how much is left never spends a render. A client using the tools as intended meets its render budget long before the ceiling, which is only there to clip a flood. ## When the capture is not the page you asked for A login wall, a `404` page and a rate limit notice all render perfectly, so the image alone gives a model no way to tell them from the page it wanted. Over HTTP that is the [`X-Target-Status`](https://screenshotbuddy.io/documentation/taking-screenshots.md) header. Over MCP, `take-screenshot` says it in words, at the end of the line that describes the capture, and only when the target answered something other than a `2xx`: a sentence on every successful capture would be noise a model learns to skip, and the point of this one is that it is unusual. Nothing about billing moves. The page the server actually served was loaded and rendered, so it costs the credit any render costs. `create-pdf` reports no such status, here as over HTTP, so the document itself is the only thing that says what was captured. ## Batches go over the REST endpoint The tools render one page at a time, which is what a conversation wants. A set of captures, or a page slow enough to outlast a tool call, is submitted as a batch over the REST endpoint instead: `POST https://api.screenshotbuddy.io/v1/renders` with the same bearer token. There is no batch tool on this server, so that submission is an HTTP request rather than a tool call. See [batch and async renders](https://screenshotbuddy.io/documentation/batch-renders.md). # Changelog Every change to the API that a caller can notice, newest first. Changes to this site, to billing or to anything behind the endpoint are not here; if your code cannot tell it happened, it is not a change to the API. > One entry on this page is a breaking change, and it is the newest one: the API moved to a host of its own, `https://api.screenshotbuddy.io/v1`. Everything else has been an addition, and everything under `/v1` still is, which is why the version in the path has not moved. What the move did not touch is exactly what the promise below covers: no code was renamed, no parameter repurposed and no signature invalidated. ## What we promise The [error codes](https://screenshotbuddy.io/documentation/errors.md) and the status each one answers with are a published contract. Renaming a code, or changing which situation it covers, is a breaking change: an integration that retries on `render_timeout` and gives up on `blocked_host` would silently start doing the wrong thing. That waits for a version bump in the path. Adding a code is not a breaking change, so treat one you do not recognise as the status it arrived with. The human `message` next to it is free to be reworded, made more specific or translated at any time, which is why nothing should ever branch on it. New request parameters and new response headers arrive the same way: added, never repurposed. The [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json) is generated from the same definitions the API validates against, so it is the machine-readable version of this page's present tense. ## 5 August 2026 ### The API moved to a host of its own The API is served from `https://api.screenshotbuddy.io/v1`. Only the base URL changed: everything after `/v1` is the same path it was, and the parameters, the headers, the tokens, the signing scheme and every signed URL you have already minted all carry over untouched. A signed URL keeps verifying because the signature covers the canonical query and nothing else - the host was never part of what was signed, so a URL built against the old base URL is valid against the new one without being reissued. Change the base URL your client is built on and you are done. The [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json), the Postman collection and both reference clients already name the new one, so re-importing or re-copying is enough. The old `/api/v1/*` paths are not redirected, because a redirect would be a quiet way of keeping two homes alive and would send your `Authorization` header to a host you did not choose. They answer `410` with the code `api_moved` and a message naming the new base URL instead, so an integration nobody updated diagnoses itself in a single response rather than in a support thread. See [every error code](https://screenshotbuddy.io/documentation/errors.md). ### Renders can be queued and collected later `POST https://api.screenshotbuddy.io/v1/renders` takes up to 20 captures in one JSON body, queues them and answers `202` straight away, so a slow page is no longer a connection your own stack has to hold open and a list of pages is no longer a throttled loop you have to write. Each item takes the parameters a render request takes. `GET https://api.screenshotbuddy.io/v1/renders/{id}` reports where the batch has got to and hands back a [signed URL](https://screenshotbuddy.io/documentation/signed-urls.md) per finished item, and a submission that carried a `webhookUrl` is posted the same document once, signed with the submitting token's signing secret. Every item runs the lifecycle a synchronous call runs, permissions, credits and cache included, paced against the account's own render limit; an item that meets it waits rather than failing. Nothing changes for callers who do not use it: `GET https://api.screenshotbuddy.io/v1/snap` is untouched. See [how batch and async renders work](https://screenshotbuddy.io/documentation/batch-renders.md). ### Cache hits stopped spending the render budget The per-minute rate limit split in two. A fresh render spends your plan's render budget, exactly as before. An answer served from your cache now comes out of a separate budget of 300 per minute, flat across plans, and a `304` answered out of your cache counts against neither, so a page of twenty embedded signed-URL thumbnails no longer costs a minute's render allowance. A request refused for validation or for a permission the token was never granted stopped using an attempt at all. `X-RateLimit-Limit` and `X-RateLimit-Remaining` now describe the budget the answer was counted against, with `X-Cache` as the tell; a `304` answered out of your cache carries neither, and a `429` names the limit that refused next to its `Retry-After`. `GET https://api.screenshotbuddy.io/v1/usage` gained `rate_limit.cached_requests_per_minute` beside the render limit it already reported, and on the MCP surface `check-usage` and the protocol calls stopped spending render slots too. See [how the two budgets work](https://screenshotbuddy.io/documentation/rate-limits.md). ### A fresh render can answer 304 as well `If-None-Match` used to be answered by the cache alone. A request that had to render, because it sent `cache=0` or because the entry had lapsed, sent the whole file back even when the bytes it had just rendered carried the tag the caller said they held. That request is now answered `304` with no body, alongside its `ETag`, the `X-Cache` it would have carried and the `X-RateLimit` pair. This one is not the free `304` of the entry above. The page was loaded before we could say it looks the same, so it costs the credit and the render slot the `200` it replaced would have cost, and it is in your usage history at one credit like any other render. What you save is the download. Callers who never send `If-None-Match` see nothing change. Together with `cache=0` it is a change monitor: send the tag of your last capture on whatever schedule the page deserves, and a `200` means the page changed while a `304` means it did not. See [how caching works](https://screenshotbuddy.io/documentation/caching.md). ### Answers say what the target page answered A screenshot of a login wall, of a `404` page or of a rate limit notice came back as a clean `200` with a real image in it, and nothing in the answer said it was not the page you meant. Screenshot answers now carry `X-Target-Status`, the status the captured page itself answered while it was loading, on the `200` and on both kinds of `304`. A rendered `404` page is a `200` from us carrying `X-Target-Status: 404`. Nothing about billing moved: a `4xx` or `5xx` target costs the credit it always did, because the page the server actually served was loaded and rendered. The header is absent when we were not told a status, and absent means not known rather than `200`: `https://api.screenshotbuddy.io/v1/snap?pdf=1` never carries it, and neither does a capture cached before this shipped. Asynchronous items report the same thing as a new `target_status` field, and the `take-screenshot` MCP tool says so in words when the target answered something other than a `2xx`. See [what the target answered](https://screenshotbuddy.io/documentation/taking-screenshots.md). ## 4 August 2026 ### The API is served over MCP as well `https://screenshotbuddy.io/mcp` speaks the Model Context Protocol over streamable HTTP, so a client can call the API as tools instead of being told how to write the request. Three of them: `take-screenshot` hands the image back in the conversation, `create-pdf` answers with a signed link to the rendering because the file is too large to travel in a tool result, and `check-usage` serves the document `GET https://api.screenshotbuddy.io/v1/usage` serves. It takes the same bearer tokens in the same `Authorization` header, so there is no second credential to mint and no OAuth step, and a token narrowed to one mode stays narrowed. The two rendering tools accept the parameters their endpoints accept, checked by the same rules, and a render costs the credit it costs over HTTP and is counted against the same per-minute budget. Nothing changes for callers who do not use it. See [how the MCP server works](https://screenshotbuddy.io/documentation/mcp-server.md). ## 3 August 2026 The API went from one endpoint with one way in to the surface described by the rest of this documentation. Everything below landed on the same day and none of it changes an answer a caller was already getting. ### A Postman collection `GET https://api.screenshotbuddy.io/v1/postman.json` answers with the API as a Postman collection, built from the same definitions the [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json) is built from. It carries every endpoint with every parameter it accepts, takes its token from one collection variable, and arrives with the optional parameters listed but switched off. It needs no token itself, so importing it is something you can do before you have one. See [importing the collection](https://screenshotbuddy.io/documentation/sdks.md). ### Signed URLs for direct embedding `GET https://api.screenshotbuddy.io/v1/snap/signed` takes the same capture parameters as `/snap` plus a `tokenId`, an optional `expires` and an HMAC-SHA256 `signature` over them, so a capture can go straight into an `` tag without a token travelling to the browser. Every token gained a signing secret of its own, revealed on demand under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens), and a signed request is metered, billed, cached and rate limited exactly as the bearer request it stands in for. See [how to sign a URL](https://screenshotbuddy.io/documentation/signed-urls.md). ### Response caching and request dedup Asking for the same capture twice now answers from the rendering already made for your account, and a hit costs no credit. Responses say which it was with `X-Cache: HIT` or `MISS` and carry an `ETag` that `If-None-Match` turns into a `304`; `cache=0` opts out and `cacheTtl` chooses the lifetime. Identical requests that arrive at the same moment are rendered once and answered twice. See [how caching works](https://screenshotbuddy.io/documentation/caching.md). ### Credit and request metadata headers Every API response now carries `X-Request-Id`, and every authenticated one adds `X-Credits-Limit`, `X-Credits-Remaining` and `X-Credits-Reset`, so running low is something you read off a successful response rather than discover on a failed one. The request id is the same value as the `request_id` in an [error envelope](https://screenshotbuddy.io/documentation/errors.md) and in our logs, which makes it the thing to quote in a support request. See [what the headers mean](https://screenshotbuddy.io/documentation/rate-limits.md). ### The capture options the renderer already supported `/snap` accepted a URL and little else. It now takes `format`, `quality`, `width`, `height`, `scale`, `delay`, `selector`, the four `clip` fields, `omitBackground`, `waitForSelector` and `waitUntil` for screenshots, and `paperFormat`, `landscape` and the four margins for PDFs. Options that contradict each other are refused rather than quietly ignored: naming a `selector`, a `clip` and `fullPage` together is three different answers to where the capture starts. See [screenshot options](https://screenshotbuddy.io/documentation/taking-screenshots.md) and [PDF options](https://screenshotbuddy.io/documentation/creating-pdfs.md). ### Token permissions, expiry and rotation A token now carries the two permissions the product actually has, `screenshot` and `pdf`, checked before any credit is spent: asking for a PDF with a screenshot only token answers `403` and `missing_ability`. Tokens can also be given an expiry when you create them and rotated in place, which mints a replacement and deletes the old value in one step. Tokens that existed before this were given both permissions, so none of them changed behaviour. See [permissions, expiry and rotation](https://screenshotbuddy.io/documentation/authentication.md). ### A usage endpoint and machine-readable error codes `GET https://api.screenshotbuddy.io/v1/usage` reports your remaining, used and granted credits, your plan, when the period resets and the rate limit in effect. It answers `200` whatever state the account is in and has a throttle of its own, so polling it never eats into the budget for renders. See [the usage endpoint](https://screenshotbuddy.io/documentation/rate-limits.md). Alongside it, every non-2xx answer under `api/*` became one envelope: a stable `code`, a human `message`, an optional `errors` map and a `request_id`. Two failures that used to share a status and differ only in their English now differ in their code, so a client can tell a blocked host from a malformed URL without reading a sentence. See [every error code](https://screenshotbuddy.io/documentation/errors.md). # SDKs and tools The API is a handful of `GET` requests, so nothing here is required: a token and an HTTP client are enough. What follows is for the parts that are fiddly to get right the first time. A Postman collection to try the API in, two reference clients thin enough to read in a sitting, and the machine-readable document to generate anything else from. If the client you are writing in is an agent rather than a language, install the [ScreenshotBuddy agent skill](https://screenshotbuddy.io/.well-known/skills/screenshotbuddy-api/SKILL.md) with `npx skills add screenshotbuddy.io`. It teaches the agent the endpoints, the parameters, the error codes and the signing recipe in one step, so it does not have to read this documentation first. > Everything on this page is built from the same definitions the API validates against, so a parameter it offers is a parameter the API accepts. The [OpenAPI document](https://api.screenshotbuddy.io/v1/openapi.json) is where all of it comes from. ## The Postman collection Import this URL in Postman and you have every endpoint, with every parameter it accepts and the description of each one, ready to send. ```text https://api.screenshotbuddy.io/v1/postman.json ``` In Postman, choose Import, then Link, and paste it. The collection is generated when you fetch it, so the base URL inside it is already `https://api.screenshotbuddy.io/v1` and you never import a stale copy. Authentication is set once, on the collection rather than on each request. Open the collection's variables and put a token from [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens) in `token`; every request that needs one picks it up. The `baseUrl` variable beside it is what to change if you are pointing the collection at something else. | Variable | Type | Description | | --- | --- | --- | | `baseUrl` | string | The base URL every request is built on. It arrives filled in with `https://api.screenshotbuddy.io/v1`. | | `token` | string | Your API token, sent as the bearer token of the collection. It arrives empty, because a file people share around is no place for a credential. | Optional parameters are present but switched off, so you can see the whole menu without sending it. Tick the ones you want. The screenshot and PDF requests are separate because they accept different parameters: a parameter sent to the mode it does nothing in is [refused rather than ignored](https://screenshotbuddy.io/documentation/errors.md), so neither request offers the other's options. The signed request is the one exception to the collection's authentication: it sends no bearer token, because a [signature in the query](https://screenshotbuddy.io/documentation/signed-urls.md) is what authenticates it. Fill its `tokenId` and `signature` variables from your own signing code, which is what the two clients below do for you. ## The PHP client One file, `sdks/php/ScreenshotBuddy.php`, with no dependencies and nothing to install. Copy it into your project and require it. It needs PHP 8.2 or newer and the curl extension, which is the only thing it uses to make a request. There is no package to add, and that is on purpose. The file is short enough to read before you trust it, and copying it means an upgrade is something you choose rather than something a dependency resolver does to you at three in the morning. ```php screenshot('https://example.com/pricing', [ 'fullPage' => true, 'format' => 'jpeg', 'quality' => 80, ])); file_put_contents('pricing.pdf', $buddy->pdf('https://example.com/pricing', [ 'paperFormat' => 'a4', 'landscape' => true, ])); $usage = $buddy->usage(); echo $usage['credits']['remaining'], ' credits left', PHP_EOL; } catch (ScreenshotBuddyError $error) { // Branch on the code, never on the message. echo $error->errorCode, ': ', $error->getMessage(), PHP_EOL; echo 'Quote this at support: ', $error->requestId, PHP_EOL; } ``` `screenshot()` and `pdf()` return the bytes of the file, because that is what the API answers with. `usage()` returns the decoded JSON of the [usage endpoint](https://screenshotbuddy.io/documentation/rate-limits.md). Anything the API refuses is thrown as a `ScreenshotBuddyError` carrying the `code`, the `message` and the `request_id` out of the [error envelope](https://screenshotbuddy.io/documentation/errors.md). The options arrays are the API's query parameters, passed straight through, so the client cannot fall behind the API: a parameter added tomorrow works today. Booleans are converted to the `1` and `0` the API accepts, and an option set to `null` is left out rather than sent empty. It also signs. `signedUrl()` takes the query, a token id and that token's signing secret, and gives you a URL you can put in an `` tag. ```php $url = $buddy->signedUrl( ['url' => 'https://example.com/pricing', 'width' => 1200, 'height' => 630], 42, getenv('SCREENSHOTBUDDY_SIGNING_SECRET'), time() + 3600 ); echo ''; ``` It speaks the asynchronous surface too. `submitBatch()` hands a set of captures to [batch renders](https://screenshotbuddy.io/documentation/batch-renders.md) and returns the batch document the `202` carries, every item still `queued`. `batch()` asks for that document again by id. Both return the decoded JSON rather than bytes, and each item is the options array a single capture takes. ```php $batch = $buddy->submitBatch([ ['url' => 'https://example.com/pricing', 'fullPage' => true], ['url' => 'https://example.com/features', 'width' => 1200, 'height' => 630], ], 'https://your-app.example/webhooks/screenshotbuddy'); // Polling is free, so the pace of this loop is yours to choose. $report = $buddy->batch($batch['id']); foreach ($report['items'] as $item) { echo $item['status'], ' ', $item['signed_url'] ?? 'not yet', PHP_EOL; } ``` The webhook URL is optional: leave it off and you collect the batch by polling alone. A batch that is not yours, an id that never existed and one that has been pruned all answer `404` alike, which arrives as a `ScreenshotBuddyError` with that status rather than as an empty result. And it verifies. `ScreenshotBuddy::verifyWebhook()` is static, because the endpoint receiving a delivery holds the signing secret and nothing else. Give it the raw body, the two headers and that secret. The body has to be the bytes exactly as they arrived: a payload decoded and encoded again is different bytes and therefore a different signature, so verify first and decode after. A delivery timestamped more than five minutes from now is refused as well, which is what stops one somebody captured being replayed at you tomorrow. ```php $body = file_get_contents('php://input'); if (! ScreenshotBuddy::verifyWebhook( $body, $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', getenv('SCREENSHOTBUDDY_SIGNING_SECRET') )) { http_response_code(403); exit; } $batch = json_decode($body, true); ``` ## The JavaScript client The same client as an ES module, `sdks/js/screenshotbuddy.mjs`. Zero dependencies, built on `fetch`, with JSDoc types throughout so an editor can tell you what a method returns. Copy it in and import it. It runs on the server. `signedUrl()` reaches for `node:crypto` and takes your signing secret, and a secret that reaches a browser is a secret you have published, as the [authentication](https://screenshotbuddy.io/documentation/authentication.md) page warns. ```javascript import { writeFile } from 'node:fs/promises'; import { ScreenshotBuddy, ScreenshotBuddyError } from './screenshotbuddy.mjs'; const buddy = new ScreenshotBuddy(process.env.SCREENSHOTBUDDY_TOKEN, 'https://api.screenshotbuddy.io/v1'); try { await writeFile('pricing.png', await buddy.screenshot('https://example.com/pricing', { fullPage: true, format: 'jpeg', quality: 80, })); await writeFile('pricing.pdf', await buddy.pdf('https://example.com/pricing', { paperFormat: 'a4', landscape: true, })); const usage = await buddy.usage(); console.log(`${usage.credits.remaining} credits left`); } catch (error) { if (!(error instanceof ScreenshotBuddyError)) throw error; // Branch on the code, never on the message. console.error(error.code, error.message, error.requestId); } const url = buddy.signedUrl( { url: 'https://example.com/pricing', width: 1200, height: 630 }, 42, process.env.SCREENSHOTBUDDY_SIGNING_SECRET, Math.floor(Date.now() / 1000) + 3600 ); ``` `screenshot()` and `pdf()` resolve to a `Uint8Array` of the file, which is what `writeFile` and every stream in Node take as is. `usage()` resolves to the parsed JSON. A refusal rejects with a `ScreenshotBuddyError` carrying `code`, `message`, `status` and `requestId`. The asynchronous surface is the same three members under the same names. `submitBatch()` submits a set of captures to [batch renders](https://screenshotbuddy.io/documentation/batch-renders.md) and resolves to the batch document, `batch()` asks for that document again by id, and both resolve to parsed JSON rather than bytes. ```javascript const batch = await buddy.submitBatch([ { url: 'https://example.com/pricing', fullPage: true }, { url: 'https://example.com/features', width: 1200, height: 630 }, ], 'https://your-app.example/webhooks/screenshotbuddy'); // Polling is free, so the pace of this loop is yours to choose. const report = await buddy.batch(batch.id); for (const item of report.items) { console.log(item.status, item.signed_url ?? 'not yet'); } ``` `ScreenshotBuddy.verifyWebhook()` is the receiving half, and it is static for the same reason the PHP one is: the route that takes a delivery holds the signing secret and nothing else. It reaches for `node:crypto` and compares in constant time. Hand it the body exactly as it arrived, before any parsing, because a payload that has been through `JSON.parse` and back out of `JSON.stringify` is different bytes and therefore a different signature. A delivery timestamped more than five minutes from now is refused, which is what stops one somebody captured being replayed at you tomorrow. ```javascript // express.raw hands you the bytes; a JSON body parser hands you an object, // which no longer signs the same. app.post('/webhooks/screenshotbuddy', express.raw({ type: 'application/json' }), (request, response) => { const body = request.body.toString('utf8'); const authentic = ScreenshotBuddy.verifyWebhook( body, request.get('X-Webhook-Timestamp'), request.get('X-Webhook-Signature'), process.env.SCREENSHOTBUDDY_SIGNING_SECRET ); if (!authentic) { return response.sendStatus(403); } const batch = JSON.parse(body); response.sendStatus(204); }); ``` ## Generating a client of your own Two languages is not many. For anything else, point a generator at the OpenAPI document: it describes every parameter, every status, every header and the full set of error codes, and it is generated from the same definitions the API validates against rather than written alongside them. ```bash curl "https://api.screenshotbuddy.io/v1/openapi.json" --output openapi.json ``` It needs no token, so a generator in a build step can fetch it without a credential. The one thing to know before you generate: a render answers with the file itself rather than JSON, so tell your generator to treat the `200` of `/snap` as binary. Everything that is not a `2xx` is the same JSON envelope on every endpoint.