# Screenshot API in PHP: Guzzle, Laravel Http, webhooks, signed URLs

> Website screenshots from PHP with Guzzle or Laravel's Http client: full page, device presets, element capture, cookie banners, webhooks, signed URLs.

Published 2026-11-04 · https://shareglint.com/en/screenshot-api/php

To take a website screenshot in PHP, send `POST https://shareglint.com/v1/screenshots?wait=true` with Guzzle (or `Http::withToken()` in Laravel), a Bearer API key and a JSON body like `['url' => 'https://example.com', 'full_page' => true]`. The JSON response carries a `url` to the finished PNG, JPEG, WebP or PDF. For `<img>` tags and `og:image`, sign a `/s/` URL with `hash_hmac()` and skip the API call entirely.

No Chrome on your server and no SDK to install — Guzzle or Laravel's built-in client is enough. The examples were run against the API with PHP 8.5, Guzzle 7.15 and Laravel 13.

## One screenshot with Guzzle

```php
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://shareglint.com/v1/',
    'headers' => ['Authorization' => 'Bearer '.getenv('SHOT_API_KEY')],
    'timeout' => 60,          // above the server's sync window
    'connect_timeout' => 5,
    'http_errors' => false,   // we read the JSON body of 4xx/5xx ourselves
]);

function screenshot(Client $client, array $options): array
{
    $response = $client->post('screenshots', ['query' => ['wait' => 'true'], 'json' => $options]);
    $body = json_decode((string) $response->getBody(), true);

    return match ($response->getStatusCode()) {
        200 => $body,
        503 => waitFor($client, $body['id']),                    // sync window ran out
        500 => throw new RuntimeException('Capture failed: '.$body['error']),
        default => throw new RuntimeException($response->getStatusCode().' '.($body['error'] ?? '').' '.($body['message'] ?? '')),
    };
}

$shot = screenshot($client, [
    'url' => 'https://example.com',
    'full_page' => true,
    'lazy_load' => true,
    'block_cookie_banners' => true,
]);
file_put_contents('page.png', file_get_contents($shot['url']));
```

Guzzle's default `timeout` is 0 — wait indefinitely ([Guzzle request options](https://docs.guzzlephp.org/en/stable/request-options.html#timeout)) — so set one. `http_errors => false` keeps Guzzle from throwing before you can read `error` and `message`. Download the file without the client: it would send your key to the file host.

The default viewport is 1280×800; `'format' => 'jpeg'` with `'quality' => 80`, `'webp'` or `'pdf'` change the output.

## Polling a queued screenshot

Drop `?wait=true` and the API answers 202 immediately; this is the `waitFor()` the function above falls back to:

```php
function waitFor(Client $client, string $id, int $tries = 30): array
{
    for ($i = 0; $i < $tries; $i++) {
        $shot = json_decode((string) $client->get("screenshots/{$id}")->getBody(), true);
        if ($shot['status'] === 'completed') {
            return $shot;
        }
        if ($shot['status'] === 'failed') {
            throw new RuntimeException($shot['error']);
        }
        sleep(2);
    }
    throw new RuntimeException("Screenshot {$id} still running");
}
```

In the 202 response, `url` is this status endpoint, not the image. Polling with `sleep()` belongs in a CLI script or a queue worker, never in a web request.

## Options: full page, devices, one element

```php
// A phone: iPhone 15 viewport, pixel ratio 3, touch, mobile user agent
['url' => 'https://example.com', 'device' => 'iphone-15']

// One element's box, retina
['url' => 'https://example.com/pricing', 'selector' => '#plans', 'scale' => 2]

// A clean dashboard: no banners or ads, chat hidden, a tab clicked, data waited for
[
    'url' => 'https://example.com/dashboard',
    'block_cookie_banners' => true,
    'block_ads' => true,
    'hide_selectors' => ['#intercom-container', '.promo-bar'],
    'click_selector' => '#tab-yearly',
    'wait_for' => '.chart svg',
    'delay' => 1,
    'dark_mode' => true,
]
```

Presets include `iphone-15`, `iphone-se`, `pixel-8`, `galaxy-s24`, `ipad`, `macbook` and `desktop-1920` (all of them in the [API docs](https://shareglint.com/en/docs#screenshots)). `block_cookie_banners` clicks "accept" on OneTrust, Cookiebot, Didomi, Usercentrics, Quantcast, TrustArc and Sourcepoint dialogs and hides what remains with the EasyList Cookie List; `block_ads` uses EasyList. `full_page` with `lazy_load` scrolls the page first so lazy images are in the picture.

## Laravel: Http client, a job and a webhook

Laravel's [HTTP client](https://laravel.com/docs/http-client) wraps Guzzle with a shorter syntax:

```php
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

$response = Http::withToken(config('services.screenshots.key'))
    ->timeout(60)
    ->post('https://shareglint.com/v1/screenshots?wait=true', [
        'url' => 'https://example.com',
        'device' => 'iphone-15',
        'block_cookie_banners' => true,
    ]);

$shot = $response->throw()->json(); // throws RequestException on 4xx/5xx
Storage::put("screenshots/{$shot['id']}.png", Http::get($shot['url'])->body());
```

In a controller, do not wait at all: queue the screenshot with `'webhook_url' => route('webhooks.screenshots')` and let us call you. Every call has `X-Webhook-Timestamp` and `X-Webhook-Signature` — the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your webhook secret:

```php
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class ScreenshotWebhookController
{
    public function __invoke(Request $request): Response
    {
        $timestamp = (string) $request->header('X-Webhook-Timestamp');
        $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), config('services.screenshots.webhook_secret'));

        abort_unless(
            hash_equals($expected, (string) $request->header('X-Webhook-Signature'))
                && abs(time() - (int) $timestamp) <= 300, // replay window
            400,
        );

        $event = $request->json()->all();
        if ($event['event'] === 'screenshot.completed') {
            // StoreScreenshot::dispatch($event['id'], $event['url']);
        }

        return response()->noContent();
    }
}
```

Register it in `routes/api.php` (no CSRF there) or exclude the path from CSRF in `bootstrap/app.php`. `hash_equals()` compares in constant time. We retry a failed delivery five times with backoff; answer 204 fast and download in a queued job. We tested this handler with a real signature and a tampered body (400).

## A signed /s/ URL for Blade

For an `<img>` or `og:image`, sign a URL with your signing secret and account `id` instead of calling the API on each page view:

```php
function signedScreenshotUrl(string $pageUrl, array $options = [], string $ext = 'png'): string
{
    $params = ['url' => $pageUrl];
    foreach ($options as $name => $value) {
        $params[$name] = match (true) {
            is_bool($value) => $value ? 'true' : 'false', // (string) false is ''
            is_array($value) => implode(',', $value),
            default => (string) $value,
        };
    }
    ksort($params, SORT_STRING);
    $query = implode('&', array_map(
        fn ($name, $value) => rawurlencode($name).'='.rawurlencode($value),
        array_keys($params),
        $params,
    ));
    $path = '/s/'.getenv('SHOT_ACCOUNT_ID').'.'.$ext;
    $s = substr(hash_hmac('sha256', $path.'?'.$query, getenv('SHOT_SIGNING_SECRET')), 0, 32);

    return 'https://shareglint.com'.$path.'?'.$query.'&s='.$s;
}
```

```blade
<img src="{{ signedScreenshotUrl('https://example.com', ['device' => 'mobile', 'block_cookie_banners' => true]) }}" alt="Example.com on a phone">
```

The canonical string is the path, `?`, then every parameter except `s`, sorted by name and encoded with `rawurlencode()`; `s` is the first 32 hex characters of the HMAC. `urlencode()` and `http_build_query()` (without `PHP_QUERY_RFC3986`) encode spaces as `+` and the signature fails. We checked this function against the server's verifier: a correct URL is a 302, any changed character a 403. The first hit captures and charges; repeats within 24 hours redirect to the cached file.

## Errors and quota

| Status | Meaning |
|---|---|
| 402 `quota_exceeded` | Monthly renders used up; nothing charged |
| 422 | Bad option or non-public `url`; `message` + `errors` |
| 429 | Rate limit; respect `Retry-After` |
| 500 (`?wait=true`) | The page failed; `error` explains, quota returned |
| 503 `sync_overloaded` | Not done in time; poll the `id` |

A viewport or element capture uses 1 render, a full page 2. Add an `Idempotency-Key` header when a job retries a `POST`, so the retry returns the first response instead of capturing twice. See [pricing](https://shareglint.com/en/pricing) for plans, or the [Puppeteer guide](https://shareglint.com/en/guides/puppeteer-screenshot) if you would rather run Chrome next to PHP.

## FAQ

### How do I take a screenshot of a website in PHP?

PHP cannot render a page itself, so either drive a headless Chrome (Browsershot, chrome-php) or call a screenshot API: POST {"url": "https://example.com"} to /v1/screenshots?wait=true with Guzzle or Laravel's Http client and a Bearer key, then download the url from the JSON response.

### Why does my webhook signature not match in Laravel?

Hash $request->getContent(), the raw body, not json_encode($request->all()): re-encoding changes escaping and key order. The string to sign is the X-Webhook-Timestamp header, a dot, and the raw body; compare with hash_equals.

### Why is my signed screenshot URL a 403 in PHP?

Most often urlencode() or http_build_query() was used to build the string you sign. Both write a space as +; the signature needs rawurlencode() (RFC 3986, %20). Also (string) false is an empty string, so convert booleans to 'true' or 'false' first.

### How much quota does a screenshot use?

A viewport or element screenshot uses 1 render, a full-page one 2. The same URL with the same options within 24 hours returns the existing file for free, and failed captures are refunded.
