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 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.
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.
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 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.
-
Name
baseUrlTypestringDescriptionThe base URL every request is built on. It arrives filled in with
https://api.screenshotbuddy.io/v1. -
Name
tokenTypestringDescriptionYour 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, 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 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
require __DIR__ . '/ScreenshotBuddy.php';
$buddy = new ScreenshotBuddy(getenv('SCREENSHOTBUDDY_TOKEN'), 'https://api.screenshotbuddy.io/v1');
try {
file_put_contents('pricing.png', $buddy->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. Anything the API refuses is thrown as a
ScreenshotBuddyError carrying the code, the message and the
request_id out of the error envelope.
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 <img> tag.
$url = $buddy->signedUrl(
['url' => 'https://example.com/pricing', 'width' => 1200, 'height' => 630],
42,
getenv('SCREENSHOTBUDDY_SIGNING_SECRET'),
time() + 3600
);
echo '<img src="' . htmlspecialchars($url) . '" alt="">';
It speaks the asynchronous surface too. submitBatch() hands a set of captures to
batch renders 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.
$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.
$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 page warns.
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 and resolves to the batch document,
batch() asks for that document again by id, and both resolve to parsed JSON rather than bytes.
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.
// 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.
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.
Settle the request before you wire it up
Whichever of these you reach for, the options are the same. The playground runs them against your own account and hands you the request that worked.