Menu
Sign up

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 November 4, 2026 · 5 min read

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

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) — 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:

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

// 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). 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 wraps Guzzle with a shorter syntax:

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:

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:

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;
}
<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 for plans, or the Puppeteer guide 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.