# Async and webhooks

Start a render in the background, then poll the job or let a webhook tell you when it is done. Includes signature checks in six languages.

Add `async=true` and the API answers at once, before the page is rendered.

```bash
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY\
&url=https://en.wikipedia.org/wiki/Eiffel_Tower\
&full_page=true\
&async=true"
```

```json
{
  "job_id": "job_917e1044a2a8885401dae084",
  "status": "queued",
  "job_url": "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084",
  "status_url": "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084"
}
```

The status `202` means "accepted, working on it". The render now runs in the background. A background render is called a job.

| Field | Meaning |
| --- | --- |
| `job_id` | The name of your job. Yours will differ from this sample. |
| `status` | Where the job is right now. It starts as `queued`. |
| `job_url` | The address to ask for the result. |
| `status_url` | The same address as `job_url`, under a second name. Use either one. |

The same address is also in the `Location` response header.

An async job counts the same as a normal request. One successful render uses one screenshot from your [quota](https://curlshot.com/docs/usage-and-limits.md). A failed job is free.

## When to use async

A normal request keeps the connection open until the screenshot is ready. That is fine for most pages.

Go async when waiting is a problem:

- The page is slow or very long, and your own server would give up before the render ends.
- You want to start many renders and collect them later. See also [Bulk screenshots](https://curlshot.com/docs/bulk.md).
- A user clicked a button and you want to answer them right away.

### async

Return at once and render in the background.

The default is `false`.

## Get the result by polling

Polling means asking again every so often until the work is done. Ask the address in `job_url`:

```bash
curl "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084" \
  -H "X-Access-Key: YOUR_ACCESS_KEY"
```

```json
{
  "job_id": "job_917e1044a2a8885401dae084",
  "batch_id": null,
  "status": "done",
  "job_url": "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084",
  "screenshot": {
    "id": "b02236a387a049c0808c92c1dfb89b12",
    "url": "https://curlshot.com/api/v1/files/b02236a387a049c0808c92c1dfb89b12.png?expires=1791155483&token=VmmQOePIjGEGAznWeh917tY71w4cPv8oaTrdfY157pA",
    "format": "png",
    "bytes": 1840112,
    "width": 1280,
    "height": 9120,
    "render_ms": 3410,
    "cached": false,
    "expires_at": "2026-10-04T23:11:23.897Z"
  },
  "error_code": null,
  "error_message": null,
  "created_at": "2026-10-03T23:11:20.630Z",
  "finished_at": "2026-10-03T23:11:23.897Z"
}
```

Download the file from `screenshot.url`. This sample reads the address out of the job answer with `jq`, a small tool for JSON, and then fetches it:

```bash
FILE_URL=$(curl "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" | jq -r '.screenshot.url')

curl "$FILE_URL" --output eiffel.png
```

You get `eiffel.png`, the full-page screenshot of the article.

![The whole Wikipedia article about the Eiffel Tower, from top to bottom](https://curlshot.com/docs/examples/wikipedia-full-page.webp)

That link needs no access key. The `token` inside it is the permission, so you can hand the link to another service or a browser as it is. Copy it exactly, and do not edit `expires`.

The link stops working at the time in `expires_at`. After that it answers [`file_not_found`](https://curlshot.com/docs/errors.md#file_not_found). Download the file before then and keep your own copy.

`cached` is `true` when the job was answered from the [cache](https://curlshot.com/docs/caching.md), which costs nothing.

The `status` field moves through these values:

| Status | Meaning | What to do |
| --- | --- | --- |
| `queued` | The job is waiting for a free render slot. | Ask again in a moment. |
| `processing` | The page is being rendered. | Ask again in a moment. |
| `done` | The file is ready. `screenshot` is filled in. | Download `screenshot.url`. |
| `failed` | The render did not work. `screenshot` is `null`. | Read `error_code` and `error_message`. |

A failed job uses the same codes as a normal request. They are listed on the [errors page](https://curlshot.com/docs/errors.md).

A job that hits a temporary problem, such as a busy renderer, is tried again for you before it is marked `failed`.

Any access key of the account that created the job can ask for it. With a key from another account, or a wrong id, you get [`job_not_found`](https://curlshot.com/docs/errors.md#job_not_found).

Asking for a job is free. These calls have their own, larger [rate limit](https://curlshot.com/docs/usage-and-limits.md#status-calls-have-their-own-rate-limit), so polling does not take away from your renders.

> **Tip**
>
> Ask every one or two seconds. Asking faster does not speed up the render.

If your key has **Require signature** turned on, the polling request needs a signature too. See [Signed links](https://curlshot.com/docs/signed-links.md#sign-a-status-call).

## Get the result by webhook

A webhook turns the question around. You give us an address on your server, and we call it when the job ends. No polling needed.

```bash
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY\
&url=https://en.wikipedia.org/wiki/Eiffel_Tower\
&full_page=true\
&async=true\
&webhook_url=https://your-app.example/hooks/screenshot"
```

The answer is the same `202` as before. When the job ends, we send a `POST` request with a JSON body to your address.

### webhook_url

Address that receives the result when the render is done. It must be a full `http://` or `https://` address that we can reach from the public internet.

There is no default. Leave it out and no webhook is sent.

### webhook_sign

Sign the webhook body so you can verify it came from us.

The default is `true`.

## The webhook payload

Payload means the body of the message we send you.

```json
{
  "event": "screenshot.completed",
  "job_id": "job_917e1044a2a8885401dae084",
  "batch_id": null,
  "status": "done",
  "job_url": "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084",
  "screenshot": {
    "id": "b02236a387a049c0808c92c1dfb89b12",
    "url": "https://curlshot.com/api/v1/files/b02236a387a049c0808c92c1dfb89b12.png?expires=1791155483&token=VmmQOePIjGEGAznWeh917tY71w4cPv8oaTrdfY157pA",
    "format": "png",
    "bytes": 1840112,
    "width": 1280,
    "height": 9120,
    "render_ms": 3410,
    "cached": false,
    "expires_at": "2026-10-04T23:11:23.897Z"
  },
  "error_code": null,
  "error_message": null,
  "created_at": "2026-10-03T23:11:20.630Z",
  "finished_at": "2026-10-03T23:11:23.897Z"
}
```

```json
{
  "event": "screenshot.failed",
  "job_id": "job_917e1044a2a8885401dae084",
  "batch_id": null,
  "status": "failed",
  "job_url": "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084",
  "screenshot": null,
  "error_code": "timeout",
  "error_message": "The page took too long to render. Try a larger `timeout` or a lighter `wait_until`.",
  "created_at": "2026-10-03T23:11:20.630Z",
  "finished_at": "2026-10-03T23:11:51.412Z"
}
```

On failure, `screenshot` is `null` and the two error fields are filled in. `batch_id` is set when the job came from a [bulk](https://curlshot.com/docs/bulk.md) call.

The payload is exactly the job answer from the polling section, plus `event`. Every field of the job is here, with the same name and the same meaning, so one piece of code can read both.

`screenshot.url` is the same kind of link as before. It needs no access key, and it expires.

The request also carries these headers:

| Header | Meaning |
| --- | --- |
| `content-type` | Always `application/json`. |
| `x-timestamp` | When this delivery was signed, as Unix seconds (seconds since 1 January 1970). Part of what the signature covers. |
| `x-signature` | Proof that the message came from us, and when. See the next section. |
| `x-webhook-event` | The same value as `event` in the body: `screenshot.completed` or `screenshot.failed`. |
| `x-job-id` | The same value as `job_id` in the body. |
| `x-webhook-attempt` | `1` for the first delivery, `2` for the first retry, and so on. |

## Check that the webhook is real

Your webhook address is public, so anyone could send a fake message to it. The `x-signature` and `x-timestamp` headers let you tell ours apart.

`x-signature` holds an HMAC-SHA256, written as lowercase hex. HMAC is a standard recipe that mixes a text with a secret and gives back a fixed-length code. The secret is the secret key of the API key that started the job.

The text that is signed is the timestamp, a dot, and the body:

```text
<value of x-timestamp>.<raw body>
```

The timestamp is in there for a reason. Without it, somebody who once saw a real webhook could send the same message again a week later, and its signature would still be right. With it, you can refuse a message that is too old. Each try is signed again, so a retry carries a fresh timestamp.

To check a webhook:

1. Read the raw body, as the exact bytes that arrived. Do this before you parse the JSON.
2. Read the `x-timestamp` header. Refuse the message if it is not a number, or if it is more than 5 minutes (300 seconds) away from your own clock. That window is the tolerance: wide enough for slow networks and clocks that are a little off, narrow enough to make an old message useless.
3. Compute HMAC-SHA256 of `timestamp + "." + body` with your secret key.
4. Compare your result with the `x-signature` header, using a constant-time compare. That is a compare function that takes the same time whether the first or the last character differs, so an attacker learns nothing from timing.
5. Only then parse the JSON and act on it.

**cURL**

```bash
# Send a signed test webhook to your own handler, to try it before going live.
SECRET_KEY="YOUR_SECRET_KEY"
BODY='{"event":"screenshot.completed","job_id":"job_917e1044a2a8885401dae084","batch_id":null,"status":"done"}'
TIMESTAMP=$(date +%s)

# HMAC-SHA256 of "<timestamp>.<body>" as lowercase hex. openssl prints a label first, so keep the last word.
SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $NF}')

curl -X POST "https://your-app.example/hooks/screenshot" \
  -H "Content-Type: application/json" \
  -H "x-timestamp: $TIMESTAMP" \
  -H "x-signature: $SIGNATURE" \
  -d "$BODY"
```

**Node.js**

```javascript
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'

const SECRET_KEY = process.env.SCREENSHOT_SECRET_KEY
const TOLERANCE_SECONDS = 300

function isValid(rawBody, signature, timestamp) {
  // Refuse a message that is not from about now: it may be a replay.
  if (!/^\d+$/.test(String(timestamp || ''))) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', SECRET_KEY).update(`${timestamp}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(String(signature || ''))
  // timingSafeEqual throws when the lengths differ, so check that first.
  return a.length === b.length && timingSafeEqual(a, b)
}

createServer((req, res) => {
  const chunks = []
  req.on('data', (chunk) => chunks.push(chunk))
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks) // the exact bytes, not parsed

    if (!isValid(rawBody, req.headers['x-signature'], req.headers['x-timestamp'])) {
      res.writeHead(401).end()
      return
    }

    const payload = JSON.parse(rawBody.toString('utf8'))
    res.writeHead(200).end() // answer first, do slow work afterwards

    console.log(payload.event, payload.job_id, payload.screenshot?.url)
  })
}).listen(3000)
```

**Python**

```python
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET_KEY = os.environ["SCREENSHOT_SECRET_KEY"].encode()
TOLERANCE_SECONDS = 300

def is_valid(raw_body: bytes, signature: str, timestamp: str) -> bool:
    # Refuse a message that is not from about now: it may be a replay.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET_KEY, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        raw_body = self.rfile.read(length)  # the exact bytes, not parsed

        if not is_valid(raw_body, self.headers.get("x-signature", ""), self.headers.get("x-timestamp", "")):
            self.send_response(401)
            self.end_headers()
            return

        payload = json.loads(raw_body)
        self.send_response(200)  # answer first, do slow work afterwards
        self.end_headers()

        screenshot = payload.get("screenshot") or {}
        print(payload["event"], payload["job_id"], screenshot.get("url"))

HTTPServer(("", 3000), Handler).serve_forever()
```

**PHP**

```php
<?php
$secretKey = getenv('SCREENSHOT_SECRET_KEY');
$toleranceSeconds = 300;

// The exact bytes of the body, not parsed.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';

// Refuse a message that is not from about now: it may be a replay.
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
    http_response_code(401);
    exit;
}

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secretKey);

// hash_equals is a constant-time compare.
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true);
http_response_code(200);

error_log($payload['event'] . ' ' . $payload['job_id'] . ' ' . ($payload['screenshot']['url'] ?? ''));
```

**Go**

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"
)

var secretKey = []byte(os.Getenv("SCREENSHOT_SECRET_KEY"))

const toleranceSeconds = 300

func isValid(rawBody []byte, signature, timestamp string) bool {
	// Refuse a message that is not from about now: it may be a replay.
	sentAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	age := time.Now().Unix() - sentAt
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}

	mac := hmac.New(sha256.New, secretKey)
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	// hmac.Equal is a constant-time compare.
	return hmac.Equal([]byte(expected), []byte(signature))
}

func handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body) // the exact bytes, not parsed
	if err != nil || !isValid(rawBody, r.Header.Get("x-signature"), r.Header.Get("x-timestamp")) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	var payload struct {
		Event string `json:"event"`
		JobID string `json:"job_id"`
	}
	if err := json.Unmarshal(rawBody, &payload); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	w.WriteHeader(http.StatusOK) // answer first, do slow work afterwards

	log.Println(payload.Event, payload.JobID)
}

func main() {
	http.HandleFunc("/hooks/screenshot", handler)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

**Ruby**

```ruby
require "json"
require "openssl"
require "webrick" # on Ruby 3 and newer: gem install webrick

SECRET_KEY = ENV.fetch("SCREENSHOT_SECRET_KEY")
TOLERANCE_SECONDS = 300

# Constant-time compare that works on every Ruby version.
def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  difference = 0
  a.bytes.zip(b.bytes) { |x, y| difference |= x ^ y }
  difference.zero?
end

def valid?(raw_body, signature, timestamp)
  # Refuse a message that is not from about now: it may be a replay.
  return false unless timestamp.to_s.match?(/\A\d+\z/)
  return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET_KEY, "#{timestamp}.#{raw_body}")
  secure_compare(expected, signature.to_s)
end

server = WEBrick::HTTPServer.new(Port: 3000)

server.mount_proc "/hooks/screenshot" do |req, res|
  raw_body = req.body.to_s # the exact bytes, not parsed

  unless valid?(raw_body, req["x-signature"], req["x-timestamp"])
    res.status = 401
    next
  end

  payload = JSON.parse(raw_body)
  res.status = 200 # answer first, do slow work afterwards

  puts [payload["event"], payload["job_id"], payload.dig("screenshot", "url")].join(" ")
end

server.start
```

With `webhook_sign=false` the `x-signature` and `x-timestamp` headers are left out. We suggest you keep it on.

## Answer fast, and expect repeats

Answer the webhook with any `2xx` status, such as `200`, as soon as you have checked the signature. Do slow work, like downloading the file or resizing it, after you have answered.

If your server answers with another status, or does not answer within 10 seconds, we count the delivery as failed and try again. The pause doubles each time:

| Try | Sent |
| --- | --- |
| 1 | When the job ends. |
| 2 | 5 seconds after try 1 failed. |
| 3 | 10 seconds after try 2 failed. |
| 4 | 20 seconds after try 3 failed. |
| 5 | 40 seconds after try 4 failed. |
| 6 | 80 seconds after try 5 failed. This is the last one. |

The `x-webhook-attempt` header tells you which try a message is.

That has one consequence. The same event can reach you more than once, for example when your `200` got lost on the way back. Your handler must be idempotent. That word means: handling the same message twice has the same effect as handling it once.

The `job_id` makes this simple. Remember which job ids you have already handled, and skip a message whose `job_id` you have seen.

If all six tries fail, the result is not lost. You can still fetch the job from its `job_url`.

> **Common mistakes**
>
> - **You signed the body alone.** The signed text is the `x-timestamp` value, a dot, then the body. Leave the timestamp out and the signature never matches.
> - **You check the signature but not the age.** Then an old message that somebody captured still passes. Refuse a timestamp more than 5 minutes from your clock.
> - **Your server clock is wrong.** If every webhook fails the age check, compare your clock with the `x-timestamp` you receive. Keep the clock in sync with NTP.
> - **You parsed the JSON first, then signed the re-built text.** Parsing and writing JSON again can change spacing and key order. Sign the raw bytes as they arrived.
> - **You used the wrong secret.** The signature is made with the secret key of the API key that started the job, not with the access key.
> - **Your framework already consumed the body.** Many web frameworks parse JSON for you. Look for their "raw body" option and use it on the webhook route.
> - **Your `webhook_url` is on `localhost`.** We cannot reach your laptop, and an address inside a private network is refused without a retry. Use a public address or a tunnel while you develop.
> - **You expect the stored file to stay forever.** The link in `screenshot.url` stops working at `expires_at`. Download the file when the webhook arrives and keep your own copy.
> - **You added your access key to `screenshot.url`.** The link already carries its own `token`. Use it exactly as it arrives.

## Where to go next

- [Bulk screenshots](https://curlshot.com/docs/bulk.md): start up to 100 jobs with one call.
- [Signed links](https://curlshot.com/docs/signed-links.md): the other place your secret key is used.
- [Errors](https://curlshot.com/docs/errors.md): every code a failed job can report.
- [How to archive pages as PDF](https://curlshot.com/docs/guides/archive-pages-as-pdf.md): async and webhooks in a real workflow.
