PHP screenshot API
Capture screenshots and PDFs from PHP and Laravel, with queued jobs and signed webhooks.
PHP has no way to drive a headless browser in-process, so the usual answer is shelling
out to wkhtmltoimage or running a Node sidecar. Both mean binaries on your servers.
An HTTP call does not.
Examples use Laravel's Http client first, then plain cURL for everything else.
Laravel
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.ssnap.key'))
->timeout(60)
->post('https://ssnap.cc/api/v1/screenshot', [
'url' => 'https://example.com',
'format' => 'png',
'full_page' => true,
'cache_ttl' => 3600,
]);
if ($response->failed()) {
throw new RuntimeException($response->json('code').': '.$response->json('error'));
}
Storage::disk('local')->put('example.png', $response->body());Set the timeout explicitly. The default in Guzzle is short enough that a slow page plus a
delay will abort client-side while the render is still going — and a capture aborted at
your end has still been taken and still counts against quota.
Put the key in config/services.php, never inline:
'ssnap' => [
'key' => env('SSNAP_API_KEY'),
],Without a framework
<?php
$ch = curl_init('https://ssnap.cc/api/v1/screenshot');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('SSNAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://example.com',
'format' => 'png',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
// On failure the body is JSON, not an image, whatever `format` you asked for.
$error = json_decode($body, true);
throw new RuntimeException($error['code'].': '.$error['error']);
}
file_put_contents('example.png', $body);Streaming straight to a file
For full-page captures, write as the bytes arrive instead of holding the whole render in a PHP string:
<?php
$file = fopen('full.png', 'wb');
$ch = curl_init('https://ssnap.cc/api/v1/screenshot?'.http_build_query([
'url' => 'https://example.com',
'format' => 'png',
'full_page' => 'true',
'network_idle' => 'true',
]));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('SSNAP_API_KEY')],
CURLOPT_FILE => $file,
CURLOPT_TIMEOUT => 60,
]);
curl_exec($ch);
curl_close($ch);
fclose($file);Note the GET form: every parameter works identically as a query string or a JSON body.
Queue the render, don't hold the request
A synchronous capture inside a controller blocks a PHP-FPM worker for the whole render. Under any real traffic that is how you run out of workers.
Either dispatch your own job, or hand the queueing to Ssnap with callback_url — the API
answers 202 immediately and POSTs the finished capture to your endpoint, with a
120-second job budget instead of the synchronous 30-second render timeout.
Http::withToken(config('services.ssnap.key'))
->post('https://ssnap.cc/api/v1/screenshot', [
'url' => route('invoices.print', $invoice),
'format' => 'pdf',
'network_idle' => true,
'callback_url' => route('webhooks.ssnap'),
]);Receiving the webhook in Laravel
The callback endpoint is public by definition, so verify the signature before you trust the payload. The scheme is in Verify webhook signatures.
Route::post('/hooks/ssnap', function (Request $request) {
// Signature verification first — see the webhook guide for the exact header
// and hashing scheme, and use hash_equals rather than ===.
if ($request->input('status') === 'error') {
report(new RuntimeException($request->input('code')));
return response()->noContent();
}
ProcessCapture::dispatch($request->input('url'));
return response()->noContent();
})->withoutMiddleware(VerifyCsrfToken::class);CSRF must be excluded: the POST comes from Ssnap, not from a session.
Invoices and reports
PDF is the common PHP case, and it works the same way — format=pdf, with the caveat
that image post-processing (background, border_*, watermark_*) and selector are
skipped for PDFs. Put a watermark in the page's own print CSS instead. Full detail in
HTML to PDF API and
Invoice and report PDFs.
Rendering your own authenticated pages needs care: Ssnap fetches the url as an anonymous
public visitor, so a page behind session auth renders as your login screen. Use a signed,
time-limited url — Laravel's URL::temporarySignedRoute() is a direct fit.
Things that catch people out
Http::failed()covers 4xx and 5xx, but a202is a success. Withcallback_urlset,$response->body()is a queue acknowledgement, not an image.- A
selectormatching nothing fails the render withcapture_failed. widthalone is ignored — the viewport override needs bothwidthandheight.- Signed urls expire after 24 hours. Store the file if you need it longer; stored captures are themselves pruned per Storage and retention.
- Cache hits skip the quota charge. For a thumbnail endpoint hit by many users, a
cache_ttlis the difference between one capture and thousands.