Skip to content

Type an option name like full_page, an error code, or a topic.

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.

Request
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"
Response, status 202
{
  "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.

FieldMeaning
job_idThe name of your job. Yours will differ from this sample.
statusWhere the job is right now. It starts as queued.
job_urlThe address to ask for the result.
status_urlThe 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:

Request
curl "https://curlshot.com/api/v1/jobs/job_917e1044a2a8885401dae084" \
  -H "X-Access-Key: YOUR_ACCESS_KEY"
Response, when the job is done
{
  "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:

Request
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
The result: the full-page screenshot the job produced.

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:

StatusMeaningWhat to do
queuedThe job is waiting for a free render slot.Ask again in a moment.
processingThe page is being rendered.Ask again in a moment.
doneThe file is ready. screenshot is filled in.Download screenshot.url.
failedThe 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.

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

POST to your webhook_url, on success
{
  "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"
}
POST to your webhook_url, on failure
{
  "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:

HeaderMeaning
content-typeAlways application/json.
x-timestampWhen this delivery was signed, as Unix seconds (seconds since 1 January 1970). Part of what the signature covers.
x-signatureProof that the message came from us, and when. See the next section.
x-webhook-eventThe same value as event in the body: screenshot.completed or screenshot.failed.
x-job-idThe same value as job_id in the body.
x-webhook-attempt1 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:

What the signature covers
<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.
# 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"

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:

TrySent
1When the job ends.
25 seconds after try 1 failed.
310 seconds after try 2 failed.
420 seconds after try 3 failed.
540 seconds after try 4 failed.
680 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