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.
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"{
"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. 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.
- 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:
curl "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084" \
-H "X-Access-Key: YOUR_ACCESS_KEY"{
"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:
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.pngYou get eiffel.png, the full-page screenshot of the article.

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. Download the file before then and keep your own copy.
cached is true when the job was answered from the cache, 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.
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.
Asking for a job is free. These calls have their own, larger rate limit, so polling does not take away from your renders.
If your key has Require signature turned on, the polling request needs a signature too. See Signed links.
#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.
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.
{
"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"
}{
"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 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:
<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:
- Read the raw body, as the exact bytes that arrived. Do this before you parse the JSON.
- Read the
x-timestampheader. 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. - Compute HMAC-SHA256 of
timestamp + "." + bodywith your secret key. - Compare your result with the
x-signatureheader, 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. - Only then parse the JSON and act on it.
# 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"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)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
$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'] ?? ''));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))
}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.startWith 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.
#Where to go next
- Bulk screenshots: start up to 100 jobs with one call.
- Signed links: the other place your secret key is used.
- Errors: every code a failed job can report.
- How to archive pages as PDF: async and webhooks in a real workflow.