Skip to main content
Ssnap Docs
Guides

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

app/Services/Ssnap.php
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:

config/services.php
'ssnap' => [
    'key' => env('SSNAP_API_KEY'),
],

Without a framework

capture.php
<?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:

stream.php
<?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.

Queue it
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.

routes/web.php
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 a 202 is a success. With callback_url set, $response->body() is a queue acknowledgement, not an image.
  • A selector matching nothing fails the render with capture_failed.
  • width alone is ignored — the viewport override needs both width and height.
  • 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_ttl is the difference between one capture and thousands.

Next