Capture a website screenshot with PHP.

One script, the cURL extension PHP already has, and an image on disk at the end of it. No Composer package to install for a single HTTP request.

Then what an application needs around it: errors and retries, a JSON body, a route in a Laravel app, and a signed URL to put in a page.

What you need

  • PHP 8.1 or newer, with the cURL extension — nearly every PHP install has it. Nothing from Composer.
  • An API key from the dashboard, in an environment variable that getenv() reads.
  • A free account, if you do not have one: 100 screenshots a month, no card.

Run php -m and look for curl in the list if you are not sure. Create the key on the API keys page; it is shown once, so store it before closing the dialog. Keep it on the server: never in a page's JavaScript or a mobile app.

The request

Save this as screenshot.php. It asks for one page at a 1280 × 800 viewport, at twice the pixel density, as WebP, and writes what comes back to a file:

screenshot.php
<?php
// screenshot.php: request one screenshot and save it.

$query = http_build_query([
    'url' => 'https://en.wikipedia.org/wiki/Cartography',
    'format' => 'webp',
    'quality' => 80,
    'viewport_width' => 1280,
    'viewport_height' => 800,
    'device_scale_factor' => 2,
]);

$request = curl_init('https://api.urlshot.io/v1/screenshot?' . $query);
curl_setopt_array($request, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('URLSHOT_API_KEY')],
    CURLOPT_TIMEOUT => 60,
]);

$body = curl_exec($request);
if ($body === false) {
    throw new RuntimeException('Request failed: ' . curl_error($request));
}

// Anything that is not an image is the JSON error envelope.
if (curl_getinfo($request, CURLINFO_RESPONSE_CODE) !== 200) {
    $error = json_decode($body, true, flags: JSON_THROW_ON_ERROR)['error'];
    throw new RuntimeException("{$error['code']}: {$error['message']} (request {$error['requestId']})");
}

file_put_contents('screenshot.webp', $body);
echo "Wrote screenshot.webp\n";

Run it

Bash
export URLSHOT_API_KEY=sk_your_key_here
php screenshot.php

One render, one credit. CURLOPT_TIMEOUT is the longest the script will wait; a page that takes longer to render fails on the API's side first, with an error you can read.

What comes back

On success, curl_exec returns the image bytes themselves: no JSON wrapper, no base64. This is the file that script writes:

The Wikipedia article on cartography, captured by the API at 1280 pixels wide
/v1/screenshot?url=https://en.wikipedia.org/wiki/Cartography&format=webp&quality=80&viewport_width=1280&viewport_height=800&device_scale_factor=2

Every response also carries headers that account for the call:

Response headers returned with a screenshot.
X-Request-IDOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitRenders your plan may run at once.
X-Urlshot-RendererThe renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on.
X-RateLimit-RemainingRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlHow long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0.

cURL reads past the headers unless you ask for them. A header callback keeps each one as it arrives:

PHP
<?php
// Before curl_exec(): keep each header as it arrives. Names are lower-cased,
// since HTTP/2 sends them that way and HTTP/1.1 promises no case at all.
$headers = [];
curl_setopt($request, CURLOPT_HEADERFUNCTION, function ($request, string $line) use (&$headers): int {
    $parts = explode(':', $line, 2);
    if (count($parts) === 2) {
        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
    }
    return strlen($line);
});

// After it: what the request cost, and how to refer to it.
$credits = $headers['x-urlshot-credits-used']; // '1', or '0' from cache
$requestId = $headers['x-request-id'];
echo "request {$requestId} used {$credits} credit(s)\n";

Errors and retries

A failed call returns JSON instead of an image, with a stable code, a message, and the requestId to quote if you ask about it. The scripts on this page throw a RuntimeException with all three.

These codes are worth retrying, and each comes with a Retry-After header in seconds:

  • concurrency_limit_exceeded, 429: The workspace is already running the maximum number of concurrent renders.
  • rate_limit_exceeded, 429: The request rate limit for this API key has been exceeded.
  • service_unavailable, 503: The screenshot service is temporarily unavailable.

Every other code fails the same way however often you send it, and one that got as far as the browser has already cost a credit. So this retries on Retry-After, read by the same kind of header callback, and on nothing else:

retry.php
<?php
/** Requests a screenshot, retrying only when the API says how long to wait. */
function screenshot(array $params, int $attempts = 4): string
{
    for ($attempt = 1; ; $attempt++) {
        $retryAfter = null;
        $request = curl_init('https://api.urlshot.io/v1/screenshot?' . http_build_query($params));
        curl_setopt_array($request, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('URLSHOT_API_KEY')],
            CURLOPT_TIMEOUT => 60,
            CURLOPT_HEADERFUNCTION => function ($request, string $line) use (&$retryAfter): int {
                if (preg_match('/^retry-after:\s*(\d+)/i', $line, $match)) {
                    $retryAfter = (int) $match[1];
                }
                return strlen($line);
            },
        ]);

        $body = curl_exec($request);
        if ($body === false) {
            throw new RuntimeException('Request failed: ' . curl_error($request));
        }
        if (curl_getinfo($request, CURLINFO_RESPONSE_CODE) === 200) {
            return $body;
        }

        // Only a 429 or 503 carries Retry-After, in seconds. Anything else fails
        // the same way however often it is sent.
        $error = json_decode($body, true, flags: JSON_THROW_ON_ERROR)['error'];
        if ($retryAfter === null || $attempt === $attempts) {
            throw new RuntimeException("{$error['code']}: {$error['message']} (request {$error['requestId']})");
        }
        sleep($retryAfter);
    }
}

file_put_contents('screenshot.webp', screenshot([
    'url' => 'https://en.wikipedia.org/wiki/Cartography',
    'format' => 'webp',
]));
echo "Wrote screenshot.webp\n";

Every code, its status and its message is in the error reference.

More options

Every option is a query parameter with a default. These change a capture the most:

8 of the 17 options, with their ranges and defaults.
full_pageCapture the full scrollable page instead of only the viewport.boolean
formatOutput image format.png | jpeg | webp
qualityEncoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.1–100
viewport_widthViewport width in CSS pixels.200–3840
device_scale_factorDevice pixel ratio to emulate.0.5–3
dark_modeEmulate prefers-color-scheme: dark before navigation.boolean
hide_selectorsCSS selector whose matching elements are hidden after navigation. Repeatable.string[]
block_cookie_bannersHide known consent dialogs and restore page scrolling before capturing. Available on every plan.boolean

hide_selectors is the one to watch in PHP. Given a list, http_build_query writes hide_selectors[0]=…&hide_selectors[1]=…, which is how PHP reads a list back but not how the API does: it answers invalid_request for an unknown parameter. Repeat the name instead, once per selector:

options.php
<?php
$query = http_build_query([
    'url' => 'https://en.wikipedia.org/wiki/Atlas',
    'full_page' => 'true',
    'format' => 'png',
]);

// Not inside http_build_query: it would send hide_selectors[0]=…, which the
// API rejects as an unknown parameter. Repeat the name once per selector.
foreach (['.vector-header-container', '.mw-footer'] as $selector) {
    $query .= '&hide_selectors=' . rawurlencode($selector);
}

$request = curl_init('https://api.urlshot.io/v1/screenshot?' . $query);
curl_setopt_array($request, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('URLSHOT_API_KEY')],
    CURLOPT_TIMEOUT => 60,
]);

$body = curl_exec($request);
if ($body === false) {
    throw new RuntimeException('Request failed: ' . curl_error($request));
}

// Anything that is not an image is the JSON error envelope.
if (curl_getinfo($request, CURLINFO_RESPONSE_CODE) !== 200) {
    $error = json_decode($body, true, flags: JSON_THROW_ON_ERROR)['error'];
    throw new RuntimeException("{$error['code']}: {$error['message']} (request {$error['requestId']})");
}

file_put_contents('atlas.png', $body);

That is the whole Atlas article as a PNG, without Wikipedia's header and footer. A boolean can go in as 'true', as here, or as PHP's true, which http_build_query sends as 1; the API accepts both. All 17 options are in the parameter reference.

POST with a JSON body

The same options can go in a JSON body, which is easier once you send CSS: nothing to encode, and a list is simply a list. json_encode turns PHP's true and arrays into JSON's, which is what the body needs; the string 'true' would be rejected there.

post.php
<?php
$request = curl_init('https://api.urlshot.io/v1/screenshot');
curl_setopt_array($request, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('URLSHOT_API_KEY'),
        'Content-Type: application/json',
    ],
    // PHP's true and lists become JSON's true and arrays.
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://en.wikipedia.org/wiki/Atlas',
        'full_page' => true,
        'format' => 'png',
        'hide_selectors' => ['.vector-header-container', '.mw-footer'],
        'custom_css' => '#siteNotice { display: none !important }',
    ], JSON_THROW_ON_ERROR),
    CURLOPT_TIMEOUT => 60,
]);

$body = curl_exec($request);
if ($body === false) {
    throw new RuntimeException('Request failed: ' . curl_error($request));
}

// Anything that is not an image is the JSON error envelope.
if (curl_getinfo($request, CURLINFO_RESPONSE_CODE) !== 200) {
    $error = json_decode($body, true, flags: JSON_THROW_ON_ERROR)['error'];
    throw new RuntimeException("{$error['code']}: {$error['message']} (request {$error['requestId']})");
}

file_put_contents('atlas.png', $body);

Put every option in the body: a POST that also has query parameters is rejected. The rest of the rules are under POST with a JSON body.

In a Laravel app

In a Laravel app, call the API from a route and send the image on, so the key never leaves the server. Laravel's Http client, which is Guzzle underneath, does the request. First give the key a place in the config:

config/services.php
// config/services.php, inside the array it returns:
'urlshot' => [
    'key' => env('URLSHOT_API_KEY'),
],

Put the key itself in the app's .env, as URLSHOT_API_KEY=…. Exporting it in the shell is not enough: php artisan serve passes only a short list of environment variables on to the app. And read it with config(), not env(): once php artisan config:cache has run, env() outside a config file returns null. Then the route:

routes/web.php
<?php
// routes/web.php

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;

// The pages this app shows previews of. Visitors pick a name, never an
// address: a route that took any URL would render any page on your credits.
$pages = [
    'home' => 'https://example.com',
    'atlas' => 'https://en.wikipedia.org/wiki/Atlas',
];

Route::get('/previews/{page}', function (string $page) use ($pages) {
    $url = $pages[$page] ?? abort(404);

    $response = Http::withToken(config('services.urlshot.key'))
        ->timeout(60)
        ->get('https://api.urlshot.io/v1/screenshot', [
            'url' => $url,
            'viewport_width' => 1280,
            'format' => 'webp',
            'cache_ttl' => 3600,
        ]);

    if ($response->failed()) {
        $error = $response->json('error');
        Log::error("{$error['code']}: {$error['message']} (request {$error['requestId']})");
        abort(502);
    }

    return response($response->body(), 200, [
        'Content-Type' => $response->header('Content-Type'),
        'Cache-Control' => 'public, max-age=3600',
    ]);
});

  • Visitors choose a name, not an address. A route that rendered whatever URL it was given would let anyone spend your credits on pages of their own.
  • Repeat views are free. cache_ttl=3600 serves the same page from cache for an hour at no cost, the longest the free plan allows. See caching and credits.
  • A failure is logged, not passed on. The route writes the code and request id to the log and answers 502.

Run php artisan serve and open /previews/home.

Signed URLs

To put a capture straight into a page's <img>, sign the URL on your server instead. Anyone can load a signed URL, and changing any of its parameters breaks the signature:

sign.php
<?php

function signedUrl(array $options): string
{
    $query = http_build_query(['key' => getenv('URLSHOT_KEY')] + $options);

    $signature = hash_hmac('sha256', $query, getenv('URLSHOT_SIGNING_SECRET'));

    return 'https://api.urlshot.io/v1/screenshot?' . $query . '&signature=' . $signature;
}

$src = signedUrl(['url' => 'https://example.com', 'viewport_width' => '1200']);

URLSHOT_KEY is a publishable key (pk_…) and URLSHOT_SIGNING_SECRET its signing secret, both from the dashboard. hash_hmac returns lowercase hex, which is what the API compares. A signed URL does not expire, so add a cache_ttl to any you publish; the reference explains why under signed URLs.

Where to go next

  • The API reference — every parameter, response header and error code, generated from the contract the gateway validates against.
  • Link previews — a route like the Laravel one above, serving a picture of a page someone shared.
  • Pricing — what a credit costs once the free allowance runs out.

The same examples in other languages.

Or see every code example.

Run it with your own key.

100 screenshots a month on the free plan. No card needed.