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:
<?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
export URLSHOT_API_KEY=sk_your_key_here
php screenshot.phpOne 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:

Every response also carries headers that account for the call:
| X-Request-ID | Opaque identifier for this request. Quote it in any support conversation. |
|---|---|
| X-Urlshot-Cache | HIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0. |
| X-Urlshot-Credits-Used | Render credits consumed by this request. A cache hit consumes zero. |
| X-RateLimit-Limit | Renders your plan may run at once. |
| X-Urlshot-Renderer | The 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-Remaining | Render slots still free after this request. Absent on a cache hit, where it is unknown rather than zero. |
| Cache-Control | How 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
// 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:
<?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:
| full_page | Capture the full scrollable page instead of only the viewport. | boolean |
|---|---|---|
| format | Output image format. | png | jpeg | webp |
| quality | Encoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png. | 1–100 |
| viewport_width | Viewport width in CSS pixels. | 200–3840 |
| device_scale_factor | Device pixel ratio to emulate. | 0.5–3 |
| dark_mode | Emulate prefers-color-scheme: dark before navigation. | boolean |
| hide_selectors | CSS selector whose matching elements are hidden after navigation. Repeatable. | string[] |
| block_cookie_banners | Hide 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:
<?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.
<?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, 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:
<?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=3600serves 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:
<?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.