The screenshot URL
How a screenshot request is built, when to use GET or POST, how to encode the page address, and what the response contains.
A screenshot request is one web address. This one captures example.com as a JPEG, 800 pixels wide:
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https://example.com&format=jpeg&viewport_width=800" \
--output example.jpgYou get example.jpg. The response body is the image itself, with the header Content-Type: image/jpeg.
#Anatomy of the request
Here is the same address, split into its parts:
https://curlshot.com/api/v1/screenshot the endpoint
?access_key=YOUR_ACCESS_KEY who is asking
&url=https://example.com what to capture
&format=jpeg an option
&viewport_width=800 another optionThe endpoint is always https://curlshot.com/api/v1/screenshot. The address https://curlshot.com/api/v1/take is an alias and works the same way.
After the ? come the options. Each one is name=value, and they are joined with &. The order does not matter.
You need exactly one source: url, html or markdown. Everything else is optional and has a default. The Options reference lists them all.
#url
Address of the page to capture.
There is no default. Send it, or send html or markdown in its place.
The address has to follow four rules:
- It is a full address. It starts with
http://orhttps://and is at most 4096 characters long.example.comalone returnsinvalid_options. - It is public.
localhostand private network addresses are refused withhost_not_allowed. - It has no username and password in it. For a page behind a login, use the
authorizationoption. - Its port is a web port. The port is the number after the host name, as in
https://your-app.example:8443. Most addresses have none and use80or443.
Ports 80 and 443 always work. So do most ports from 1024 up, such as 3000, 8080 or 8443.
Every other port below 1024 is refused. So are the ports of well-known services that are not websites: databases, caches, message queues, remote desktops and the like. Examples are 3306, 5432, 6379, 9200, 11211 and 27017. A refused port answers host_not_allowed.
#GET or POST
You can send the same options in two ways.
GET puts the options in the address, as above. It works anywhere a URL works: a terminal, an <img> tag with a signed link, a browser tab.
POST puts the options in a JSON body. JSON is a text format for structured data. Set the header Content-Type: application/json.
curl -X POST "https://curlshot.com/api/v1/screenshot" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"format": "jpeg",
"viewport_width": 800
}' \
--output example.jpgThe result is the same example.jpg.
Use POST when:
- You send
html,markdown,stylesorscripts. These are long and full of characters that are awkward in a URL. - A value is long, such as a big cookie or a list of headers.
- You want the access key out of the URL. Addresses often end up in server logs.
In JSON you can use real types: true instead of "true", 800 instead of "800", and arrays for lists.
A body that is not valid JSON, or is not a JSON object, returns invalid_request.
#Encode the URL
The page address you want to capture is itself a URL, and it sits inside another URL. If it contains &, ?, =, # or a space, the two get mixed up.
Take this page address:
https://news.ycombinator.com/front?day=2024-01-15&p=2Paste it in as it is, and the request breaks:
https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https://news.ycombinator.com/front?day=2024-01-15&p=2The & ends the url value early. We receive url=https://news.ycombinator.com/front?day=2024-01-15 and a separate option called p. There is no option called p, so the request fails with invalid_options and the message p: is not a known option.
The fix is percent-encoding. Each special character is replaced by % and a two-character code, so it can no longer be mistaken for part of the outer address.
https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https%3A%2F%2Fnews.ycombinator.com%2Ffront%3Fday%3D2024-01-15%26p%3D2Now & has become %26 and ? has become %3F. The whole page address arrives as one value.
You do not need to do this by hand. Every language has a function for it:
# -G sends a GET request; --data-urlencode encodes each value for you.
curl -G "https://curlshot.com/api/v1/screenshot" \
--data-urlencode "access_key=YOUR_ACCESS_KEY" \
--data-urlencode "url=https://news.ycombinator.com/front?day=2024-01-15&p=2" \
--output hn.png// URLSearchParams encodes every value.
const params = new URLSearchParams({
access_key: 'YOUR_ACCESS_KEY',
url: 'https://news.ycombinator.com/front?day=2024-01-15&p=2',
})
const requestUrl = `https://curlshot.com/api/v1/screenshot?${params}`from urllib.parse import urlencode
# urlencode encodes every value.
query = urlencode({
"access_key": "YOUR_ACCESS_KEY",
"url": "https://news.ycombinator.com/front?day=2024-01-15&p=2",
})
request_url = f"https://curlshot.com/api/v1/screenshot?{query}"<?php
// http_build_query encodes every value.
$query = http_build_query([
'access_key' => 'YOUR_ACCESS_KEY',
'url' => 'https://news.ycombinator.com/front?day=2024-01-15&p=2',
]);
$requestUrl = "https://curlshot.com/api/v1/screenshot?$query";package main
import (
"fmt"
"net/url"
)
func main() {
// url.Values encodes every value.
params := url.Values{}
params.Set("access_key", "YOUR_ACCESS_KEY")
params.Set("url", "https://news.ycombinator.com/front?day=2024-01-15&p=2")
fmt.Println("https://curlshot.com/api/v1/screenshot?" + params.Encode())
}require "uri"
# encode_www_form encodes every value.
query = URI.encode_www_form(
access_key: "YOUR_ACCESS_KEY",
url: "https://news.ycombinator.com/front?day=2024-01-15&p=2"
)
request_url = "https://curlshot.com/api/v1/screenshot?#{query}"With POST there is nothing to encode. The address goes into the JSON body as an ordinary string.
#Booleans and lists
A boolean is an on or off option, such as full_page. In a query string, these all mean on: true, 1, yes, on. These all mean off: false, 0, no, off.
Some options take a list: hide_selectors, block_resources, block_requests, headers and cookies. You can write a list in two ways:
block_resources=font,media
block_resources=font&block_resources=mediaIn a POST body, send a JSON array: "block_resources": ["font", "media"].
Any other option may appear only once. An empty value, such as full_page=, counts as not set, so the default applies.
#Get JSON instead of the file
By default the response is the file. Add response_type=json to get a description of the file and a link to it.
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https://example.com&response_type=json"{
"id": "a187741109b34d6c991bdab9b2c5da03",
"url": "https://curlshot.com/api/v1/files/a187741109b34d6c991bdab9b2c5da03.png?expires=1791155483&token=Zk3vQ8sT1nY6bW2xLr9cHd4JmPq7uAe0GfKoIiNtVyE",
"format": "png",
"width": 1280,
"height": 1024,
"bytes": 48213,
"render_ms": 1240,
"cached": false,
"expires_at": "2026-10-04T23:11:23.563Z"
}This suits code that wants to pass a link along, not the bytes.
The url is a link to the stored file. It carries its own token, so it opens without an access key and you can hand it to a browser or another service. It stops working at expires_at. Download the file if you need it for longer.
| Field | What it holds |
|---|---|
id | The id of this screenshot. |
url | The expiring link to the file. |
format | png, jpeg, webp, pdf, mp4, webm or gif. |
width, height | Size of the image or the video in pixels. |
bytes | Size of the file. |
render_ms | How long the render took, in milliseconds. |
cached | true when a stored copy was served. See Caching. |
expires_at | When the link and the stored file expire. |
#Response headers
Every answer carries X-Reference-Id, including errors. Quote it when you contact us.
A request that gets as far as a render also tells you where your limits stand, whether the answer is a file, JSON or a render error:
| Header | What it tells you |
|---|---|
X-Quota-Limit | Screenshots your plan includes in the current period. |
X-Quota-Remaining | Screenshots you have left: what remains of the plan, plus any bonus screenshots. |
X-Quota-Reset | When the period resets, as an ISO date. |
X-RateLimit-Limit | Requests your account may send per minute. |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | When the minute resets, in Unix seconds. |
X-Concurrency-Limit | Renders your account may run at the same time. |
X-Concurrency-Remaining | Free render slots right now. |
A request that is refused before that point, for example with invalid_options or a wrong key, has only X-Reference-Id.
A successful answer adds these:
| Header | What it tells you |
|---|---|
X-Cache | HIT if a stored copy was served, MISS if the page was rendered. See Caching. |
X-Render-Ms | How long the render took, in milliseconds. |
X-Image-Width, X-Image-Height | Size of the image in pixels. Sent when the answer is the image itself. |
A 429 answer also has Retry-After: the number of seconds to wait before you try again. Usage and limits explains the three limits.
#Where to go next
- Options reference: every option with its default and limits.
- Authentication and API keys: the three ways to send your key.
- HTML and Markdown input: render your own markup with POST.
- Signed links: put a screenshot URL in a public page safely.