# 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
<?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](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 `<img>` tag.

```php
$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](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.
