# 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). ## 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. Endpoint: `GET https://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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 make up to 20 requests per minute by default. Higher plans and custom arrangements can have higher limits. Every response to a request that carried your token includes an `X-RateLimit-Limit` header with the number of requests you may make per minute, and an `X-RateLimit-Remaining` header with the number of requests you have left in the current minute. 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 the 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 your own requests per minute, 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. # 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. Header: `Authorization: Bearer {token}`, where `{token}` is the token you created in your dashboard. ```bash curl "https://screenshotbuddy.io/api/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) 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` | number | 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 and says so with `X-Cache: HIT`; send `cache=0` to render the page again, which costs a credit as any render does. Entries belong to your own account. See [caching](https://screenshotbuddy.io/documentation/caching) 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. ## 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, capture the full page, and see the result next to the code that produces it. The rest, a selector or a clip region 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) 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 and says so with `X-Cache: HIT`; send `cache=0` to render the page again, which costs a credit as any render does. Entries belong to your own account. See [caching](https://screenshotbuddy.io/documentation/caching) 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. ## 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. | | `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 rate limit of the account was exceeded. 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 requests per minute. 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. | Three of them are not tied to taking a screenshot: `not_found`, `method_not_allowed` and `server_error` can answer any request under `api/*`, so handle them wherever you call us rather than only around a render. 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 many requests an account may make per minute. This page explains what your limit is, how to read it off a response, and what happens when you go over it. > The rate limit is separate from your credits. It caps how fast you may spend them, not how many you have. ## Your limit An account may make up to 20 requests 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. The limit counts requests, not successful renders. A request that fails validation or comes back as `422` still uses one of the minute's attempts, even though it costs no credit. ## 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 number of requests your account may make per minute. This is your limit, whatever plan it came from. | | `X-RateLimit-Remaining` | integer | The number of requests you have left in the current minute. 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. | 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 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). 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. ## Checking your usage A `GET` to `https://screenshotbuddy.io/api/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 the `requests_per_minute` your account is actually held to. 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 the limit 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://screenshotbuddy.io/api/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 } } ``` `plan` and `period` are `null` for an account that has neither yet, so read them before you use them. The [OpenAPI document](https://screenshotbuddy.io/api/v1/openapi.json) describes the response in full. ## Going over the limit A request that goes over the 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. ## 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. ## 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 still counts as a request against your [rate limit](https://screenshotbuddy.io/documentation/rate-limits.md), which caps how fast you may ask rather than what asking costs. 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. `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, and no file to download a second time. Anything else is answered normally, with the file. ```bash curl "https://screenshotbuddy.io/api/v1/snap?url=https%3A%2F%2Fexample.com" \ -H "Authorization: Bearer {token}" \ -H 'If-None-Match: ""' \ --output screenshot.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. ## 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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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. 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`. 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. Send `cacheTtl` to choose that lifetime, the same way you would on `/snap`. # 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. > Nothing on this page is a breaking change. Everything so far has been an addition to `https://screenshotbuddy.io/api/v1`, which is why the version in the path has not moved. ## 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://screenshotbuddy.io/api/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. ## 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://screenshotbuddy.io/api/v1/postman.json` answers with the API as a Postman collection, built from the same definitions the [OpenAPI document](https://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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. > 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://screenshotbuddy.io/api/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://screenshotbuddy.io/api/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 the one you are reading this on 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://screenshotbuddy.io/api/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 ''; ``` ## 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://screenshotbuddy.io/api/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`. ## 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://screenshotbuddy.io/api/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.