# 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
<?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";
```

```text
Saved example.png
```

![The example.com home page, captured at 1280 by 1024 pixels](https://curlshot.com/docs/examples/first-screenshot.webp)

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`](https://curlshot.com/docs/options.md#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
<?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";
```

```text
Saved eiffel.jpg
```

![The Wikipedia article about the Eiffel Tower, rendered at phone width](https://curlshot.com/docs/examples/wikipedia-iphone.webp)

Option names must match exactly. An unknown name, such as `fullpage` for `full_page`, is rejected with [`invalid_options`](https://curlshot.com/docs/errors.md#invalid_options) and a hint. Every option is explained in the [options reference](https://curlshot.com/docs/options.md).

> **Note**
>
> Write booleans as the text `'true'` and `'false'`. `http_build_query` turns a real `true` into `1`, which the API also accepts, but the text form is clearer.

## 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](https://curlshot.com/docs/html-and-markdown.md).

```php
<?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";
```

```text
Saved card.png
```

You 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
<?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);
```

```text
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
<?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);
}
```

```text
Attempt 1 failed (renderer_busy). Waiting 1s.
Saved playwright.png
```

```text
Gave 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](https://curlshot.com/docs/errors.md) lists every code and says which ones can be retried.

## Create a signed link

A [signed link](https://curlshot.com/docs/signed-links.md) 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
<?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";
```

```text
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=6c5f73bd40f701699781ec1faf569d8c164f5936c823d1c2a293fa1ff9170e7e
```

`expires` 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`](https://curlshot.com/docs/errors.md#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](https://curlshot.com/docs/async-and-webhooks.md).

```php
<?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";
```

```text
Started job_917e1044a2a8885401dae084
Status: processing
Status: done
Saved eiffel-full.png, 7373357 bytes
```

![The full Wikipedia article about the Eiffel Tower in one tall image](https://curlshot.com/docs/examples/wikipedia-full-page.webp)

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`.

> **Common mistakes**
>
> - **You used `file_get_contents` and could not see the error.** It is fine for a quick test. On a failed status it returns `false` and hides the JSON body. Use curl, as on this page, so you can read `error_code`.
> - **You signed with `urlencode` or `http_build_query`.** By default both write a space as `+`. The signature needs `%20`, so use `rawurlencode`.
> - **You sorted with plain `ksort`.** Without `SORT_STRING`, PHP may compare names that look like numbers as numbers. Pass `SORT_STRING`.
> - **You saved the body without checking the status.** A failed request returns JSON. Saved as `.png`, it is a file that will not open.
> - **You printed a signed link into HTML as it is.** The `&` characters need escaping there. Pass the link through `htmlspecialchars` first.

## Where to go next

- [Options reference](https://curlshot.com/docs/options.md): every option you can put in the array.
- [Errors](https://curlshot.com/docs/errors.md): every error code with its cause and fix.
- [Signed links](https://curlshot.com/docs/signed-links.md): the signing steps explained one by one.
- [Async and webhooks](https://curlshot.com/docs/async-and-webhooks.md): get a call when the job ends, with no polling.
- [Go examples](https://curlshot.com/docs/examples/go.md): the same tasks in Go.
