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.