PHP examples
Runnable PHP samples for screenshots, HTML input, JSON answers, retries, signed links and background jobs, using the curl extension and hash_hmac.
Save this as basic.php and run it with php basic.php. It uses the curl extension, which most PHP installs already have.
<?php
$query = http_build_query([
'access_key' => 'YOUR_ACCESS_KEY',
'url' => 'https://example.com',
]);
$ch = curl_init("https://curlshot.com/api/v1/screenshot?$query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, // give the body back as a string
CURLOPT_TIMEOUT => 120,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
exit('Connection failed: ' . curl_error($ch) . "\n");
}
if ($status !== 200) {
exit(json_decode($body, true)['error_message'] . "\n");
}
file_put_contents('example.png', $body);
echo "Saved example.png\n";Saved example.png
Every sample on this page is a complete file. They work on PHP 7.4 and newer.
http_build_query encodes the values for you. So you can write the page address as it is, even when it contains & or ?.
CURLOPT_TIMEOUT is how long your own code waits for an answer, in seconds. The API's own timeout option can be as long as 90 seconds, so give your code more than that.
#Add options
Each option is one more entry in the array. Here the access key goes in the X-Access-Key header, which keeps it out of the address.
<?php
$query = http_build_query([
'url' => 'https://en.wikipedia.org/wiki/Eiffel_Tower',
'viewport_device' => 'iphone_15_pro',
'full_page' => 'true',
'block_cookie_banners' => 'true',
'format' => 'jpeg',
'image_quality' => 85,
]);
$ch = curl_init("https://curlshot.com/api/v1/screenshot?$query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => ['X-Access-Key: YOUR_ACCESS_KEY'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
exit('Connection failed: ' . curl_error($ch) . "\n");
}
if ($status !== 200) {
exit(json_decode($body, true)['error_message'] . "\n");
}
file_put_contents('eiffel.jpg', $body);
echo "Saved eiffel.jpg\n";Saved eiffel.jpg
Option names must match exactly. An unknown name, such as fullpage for full_page, is rejected with invalid_options and a hint. Every option is explained in the options reference.
#Render your own HTML with POST
To turn your own HTML into an image, send the options as a JSON body in a POST request. See HTML and Markdown input.
<?php
$html = <<<HTML
<body style="margin:0;display:grid;place-items:center;height:100vh;
font:700 64px sans-serif;background:#0f172a;color:#fff">
Hello from HTML
</body>
HTML;
$ch = curl_init('https://curlshot.com/api/v1/screenshot');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => [
'X-Access-Key: YOUR_ACCESS_KEY',
'Content-Type: application/json',
],
// In a JSON body, booleans and numbers are real values, not text.
CURLOPT_POSTFIELDS => json_encode([
'html' => $html,
'viewport_width' => 1200,
'viewport_height' => 630,
'format' => 'png',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
exit('Connection failed: ' . curl_error($ch) . "\n");
}
if ($status !== 200) {
exit(json_decode($body, true)['error_message'] . "\n");
}
file_put_contents('card.png', $body);
echo "Saved card.png\n";Saved card.pngYou get card.png, 1200 by 630 pixels: white text centered on a dark background.
#Get JSON instead of the file
With response_type=json the answer is a small JSON document: details about the render and a link to the stored file.
<?php
$query = http_build_query([
'url' => 'https://news.ycombinator.com',
'response_type' => 'json',
]);
$ch = curl_init("https://curlshot.com/api/v1/screenshot?$query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => ['X-Access-Key: YOUR_ACCESS_KEY'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
exit('Connection failed: ' . curl_error($ch) . "\n");
}
$result = json_decode($body, true);
if ($status !== 200) {
exit($result['error_message'] . "\n");
}
print_r($result);Array
(
[id] => a187741109b34d6c991bdab9b2c5da03
[url] => https://curlshot.com/api/v1/files/a187741109b34d6c991bdab9b2c5da03.png?expires=1791155483&token=wr0Y6nTqXC-yLpWGbch_RVMyZqjGaUgnu2Eeq-shclg
[format] => png
[width] => 1280
[height] => 1024
[bytes] => 265066
[render_ms] => 1464
[cached] =>
[expires_at] => 2026-10-04T23:11:23.563Z
)The url needs no access key, so you can hand it to a browser or to another service. It stops working at the time in expires_at, so download the file before then if you need to keep it. print_r shows false as an empty value, which is why cached looks blank.
#Handle errors and retry
A failed request returns JSON with an error_code and an error_message, not an image. Some codes are temporary and worth a second try. The rest fail the same way until you change the request. This helper retries the temporary ones and waits a little longer each time.
<?php
const API = 'https://curlshot.com/api/v1/screenshot';
const ACCESS_KEY = 'YOUR_ACCESS_KEY';
// Temporary problems. Everything else needs a change to the request.
const RETRYABLE = [
'renderer_busy',
'renderer_unavailable',
'service_unavailable',
'internal_error',
'timeout',
'navigation_failed',
'rate_limited',
'concurrency_limit',
];
class ScreenshotError extends RuntimeException
{
public string $errorCode;
public function __construct(int $status, string $errorCode, string $message)
{
parent::__construct("$errorCode: $message", $status);
$this->errorCode = $errorCode;
}
}
function take_screenshot(array $options, int $tries = 4): string
{
for ($attempt = 1; ; $attempt++) {
$retryAfter = 0;
$ch = curl_init(API . '?' . http_build_query($options));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => ['X-Access-Key: ' . ACCESS_KEY],
// Called once per response header. We only keep Retry-After.
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'retry-after:') === 0) {
$retryAfter = (int) trim(substr($line, strlen('retry-after:')));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body !== false && $status === 200) {
return $body;
}
if ($body === false) {
// The connection itself failed. Treat it as a temporary failure.
$error = new ScreenshotError(0, 'internal_error', curl_error($ch));
} else {
$json = json_decode($body, true);
$error = new ScreenshotError(
$status,
$json['error_code'] ?? 'internal_error',
$json['error_message'] ?? 'Unexpected answer'
);
}
if (!in_array($error->errorCode, RETRYABLE, true) || $attempt === $tries) {
throw $error;
}
// Use Retry-After when the API sends it. Otherwise wait 1, 2, 4 seconds.
$seconds = $retryAfter > 0 ? $retryAfter : 2 ** ($attempt - 1);
fwrite(STDERR, "Attempt $attempt failed ({$error->errorCode}). Waiting {$seconds}s.\n");
sleep($seconds);
}
}
try {
$image = take_screenshot(['url' => 'https://github.com/microsoft/playwright']);
file_put_contents('playwright.png', $image);
echo "Saved playwright.png\n";
} catch (ScreenshotError $error) {
fwrite(STDERR, 'Gave up: ' . $error->getMessage() . "\n");
exit(1);
}Attempt 1 failed (renderer_busy). Waiting 1s.
Saved playwright.pngGave up: invalid_options: url: must be a full URL starting with http:// or https://When the API knows how long to wait, it says so in the Retry-After header, in seconds. The errors page lists every code and says which ones can be retried.
#Create a signed link
A signed link is a screenshot address with a signature at the end. It is safe to show in a web page, because nobody can change it without your secret key. Build it on your server.
The signature is an HMAC-SHA256, a checksum made with your secret key, of the sorted and encoded parameters.
<?php
function signed_link(string $endpoint, array $params, string $secretKey): string
{
// Every parameter, sorted by name, comparing as plain text.
ksort($params, SORT_STRING);
$pairs = [];
foreach ($params as $name => $value) {
// rawurlencode is the strict form: a space becomes %20, not +.
$pairs[] = rawurlencode((string) $name) . '=' . rawurlencode((string) $value);
}
$canonical = implode('&', $pairs);
$signature = hash_hmac('sha256', $canonical, $secretKey);
return "$endpoint?$canonical&signature=$signature";
}
$link = signed_link(
'https://curlshot.com/api/v1/screenshot',
[
'access_key' => 'YOUR_ACCESS_KEY',
'url' => 'https://en.wikipedia.org/wiki/Eiffel_Tower',
'format' => 'webp',
'viewport_width' => '1280',
'cache' => 'true',
// Optional: the link stops working at this time, in Unix seconds. Here: one hour from now.
'expires' => time() + 3600,
],
'YOUR_SECRET_KEY'
);
echo $link, "\n";https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&cache=true&expires=1791159083&format=webp&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&viewport_width=1280&signature=6c5f73bd40f701699781ec1faf569d8c164f5936c823d1c2a293fa1ff9170e7eexpires is optional. It is a Unix time, the number of seconds since 1970. The link works until that time and never after: the API then answers 403 request_expired. It is part of the signed text, so nobody can move it. Leave it out for a link that never expires.
To test your signing code, keep the placeholder keys and leave out expires. The signature must then be b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213.
#Run a render in the background and poll
With async=true the API answers at once with a job, and the render runs in the background. You then poll: ask the job address every two seconds until the job is done or failed. More on this in Async and webhooks.
<?php
const ACCESS_KEY = 'YOUR_ACCESS_KEY';
// Small helper: GET an address and return [status, body].
// The access key is sent only when we talk to the API itself.
function http_get(string $url, bool $withKey = true): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => $withKey ? ['X-Access-Key: ' . ACCESS_KEY] : [],
]);
$body = curl_exec($ch);
if ($body === false) {
exit('Connection failed: ' . curl_error($ch) . "\n");
}
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}
// 1. Start the job.
$query = http_build_query([
'url' => 'https://en.wikipedia.org/wiki/Eiffel_Tower',
'full_page' => 'true',
'async' => 'true',
]);
[$status, $body] = http_get("https://curlshot.com/api/v1/screenshot?$query");
$started = json_decode($body, true);
if ($status !== 202) {
exit($started['error_message'] . "\n");
}
echo "Started {$started['job_id']}\n";
// 2. Ask every two seconds until the job is done or has failed. Give up after two minutes.
$job = null;
for ($i = 0; $i < 60; $i++) {
sleep(2);
[$status, $body] = http_get($started['job_url']);
$job = json_decode($body, true);
if ($status !== 200) {
exit($job['error_message'] . "\n");
}
echo "Status: {$job['status']}\n";
if ($job['status'] === 'done' || $job['status'] === 'failed') {
break;
}
}
if ($job === null || !in_array($job['status'], ['done', 'failed'], true)) {
exit("The job did not finish in time\n");
}
if ($job['status'] === 'failed') {
exit("{$job['error_code']}: {$job['error_message']}\n");
}
// 3. Download the finished file from the link in the job.
[$status, $file] = http_get($job['screenshot']['url'], false);
if ($status !== 200) {
exit("Could not download the file\n");
}
file_put_contents('eiffel-full.png', $file);
echo "Saved eiffel-full.png, {$job['screenshot']['bytes']} bytes\n";Started job_917e1044a2a8885401dae084
Status: processing
Status: done
Saved eiffel-full.png, 7373357 bytes
A finished job holds the result in screenshot, which is null until then. A failed job holds an error_code and an error_message instead.
Asking for the status does not use your quota. The file link in screenshot.url needs no access key and works until the job's screenshot.expires_at.
#Where to go next
- Options reference: every option you can put in the array.
- Errors: every error code with its cause and fix.
- Signed links: the signing steps explained one by one.
- Async and webhooks: get a call when the job ends, with no polling.
- Go examples: the same tasks in Go.