cURL · Code recipe
Take a screenshot of a website in cURL
Save a full-page PNG of any website, in any shell with curl with curl. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To take a screenshot of a website in cURL, POST its URL to https://urlpipe.dev/screenshot with sync set to true, decode the Base64 body with base64 --decode and write the bytes to a .png file. It is a full-page capture from real Chrome for 1 credit; leave sync out and the result goes to your webhook instead.
Free plan, no credit card. 1,000 credits a month.
One POST to /screenshot loads the page in real Chrome, scrolls it top to bottom so lazy images load, and captures the whole document — not just the fold. The image comes back Base64-encoded in a text/plain body, so the cURL work is two lines: decode it with base64 --decode and write the bytes.
The request below also sets page_options.block_cookie_banners, because a consent dialog over the page is the most common reason a screenshot is useless. Every other knob — viewport, device scale, JPEG or WebP, one element by selector, dark mode — goes in screenshot_options.
Setup
Before you start
curl is on every Mac and almost every Linux box. jq reads the token in the async script; install it from your package manager if jq --version finds nothing.
curl --version | head -1
jq --version # brew install jq / apt install jq
export URLPIPE_API_KEY="your_api_key"
The request
Save a screenshot as a PNG file
"sync": true keeps the request open until the result is ready. -w '%{http_code}' hands you the status and -o parks the body in a file, so an error message is printed, not decoded or saved as if it were the result. --max-time stops a page that never finishes loading from hanging the script. The body is Base64 text; base64 --decode turns it into the PNG.
#!/usr/bin/env bash
set -euo pipefail
response=$(mktemp)
trap 'rm -f "$response"' EXIT
# -w prints the status; -o keeps the body out of the way until the status is known.
status=$(curl -sS --max-time 90 -o "$response" -w '%{http_code}' \
https://urlpipe.dev/screenshot \
-H "Authorization: Bearer $URLPIPE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "page_options": {"block_cookie_banners": true}, "sync": true}')
if [ "$status" != 200 ]; then
echo "URLpipe answered $status: $(cat "$response")" >&2
exit 1
fi
base64 --decode < "$response" > screenshot.png
echo "Saved screenshot.png ($(wc -c < screenshot.png | tr -d ' ') bytes)"
Run it: bash screenshot.sh
Async
The async variant: a token, a webhook and a poll
Leave out sync and the answer is a token, straight away; jq -r .token pulls it out without quotes. The loop polls every two seconds and the case covers the four answers GET /result/:token can give.
#!/usr/bin/env bash
set -euo pipefail
response=$(mktemp)
trap 'rm -f "$response"' EXIT
API=https://urlpipe.dev
AUTH="Authorization: Bearer $URLPIPE_API_KEY"
# No "sync": the request is accepted at once and the work carries on without you.
status=$(curl -sS -o "$response" -w '%{http_code}' "$API/screenshot" \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"page_options": {"block_cookie_banners": true},
"report_to": "https://your-app.com/webhooks/urlpipe",
"labels": {"customer": "acme"}
}')
if [ "$status" != 200 ]; then
echo "URLpipe answered $status: $(cat "$response")" >&2
exit 1
fi
token=$(jq -r .token "$response")
echo "Accepted $token"
# The result is POSTed to report_to when it is ready. Polling by token is the
# other way to collect it: no endpoint needed, and a backup for the webhook.
for _ in $(seq 60); do
status=$(curl -sS -o "$response" -w '%{http_code}' "$API/result/$token" -H "$AUTH")
[ "$status" = 202 ] || break # 202 means still processing
sleep 2
done
case "$status" in
200) ;;
202) echo "Still processing after two minutes; try the token again later." >&2; exit 1 ;;
422) echo "The analysis failed: $(jq -r .error "$response")" >&2; exit 1 ;;
410) echo "The result is past the 30-day window; send the request again." >&2; exit 1 ;;
*) echo "URLpipe answered $status: $(cat "$response")" >&2; exit 1 ;;
esac
base64 --decode < "$response" > screenshot.png
echo "Saved screenshot.png ($(wc -c < screenshot.png | tr -d ' ') bytes)"
Run it: bash screenshot_async.sh
Errors
Handle errors and retries
-D writes the response headers to a file, which is where Retry-After is read from. A 401 answers in plain text, so it is decided on the status alone; jq reads the error code of everything else.
#!/usr/bin/env bash
set -euo pipefail
response=$(mktemp)
headers=$(mktemp)
trap 'rm -f "$response" "$headers"' EXIT
# urlpipe PATH JSON: POST a sync request, leaving the result in $response.
# Retries the two 429s that clear by themselves; fails with a message otherwise.
urlpipe() {
local attempt status code detail retry_after
for attempt in 0 1 2 3 4; do
status=$(curl -sS --max-time 90 -o "$response" -D "$headers" -w '%{http_code}' \
"https://urlpipe.dev$1" \
-H "Authorization: Bearer $URLPIPE_API_KEY" \
-H "Content-Type: application/json" \
-d "$2")
case "$status" in
200) return 0 ;;
401) echo "URLpipe: 401: the API key is missing or wrong. Check URLPIPE_API_KEY." >&2; return 1 ;;
esac
# A body that is not JSON leaves both empty.
code=$(jq -r '.error // ""' "$response" 2>/dev/null || true)
detail=$(jq -r 'if .message then "\(.error): \(.message)" else .error end' "$response" 2>/dev/null || true)
if [ "$status" = 429 ] && [ "$code" = rate_limited ]; then
# Sending too fast: Retry-After says how long the window has left.
retry_after=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' "$headers")
sleep "${retry_after:-1}"
elif [ "$status" = 429 ] && [ "$code" = concurrency_limit ]; then
# Every parallel slot on your plan is busy with your own requests.
sleep $((2 ** attempt))
elif [ "$status" = 504 ]; then
# Still running on our side; the token collects it from GET /result/:token.
echo "URLpipe: 504 processing_timeout: collect it later with token $(jq -r .token "$response")" >&2
return 1
else
# 403 email_unverified, 422 (a bad parameter, or a page that would not load),
# 429 quota_exceeded: sending the same request again gets the same answer.
echo "URLpipe: $status: $detail" >&2
return 1
fi
done
echo "URLpipe: 429: still refused after 5 attempts" >&2
return 1
}
urlpipe /screenshot '{"url": "https://example.com", "page_options": {"block_cookie_banners": true}, "sync": true}' || exit 1
base64 --decode < "$response" > screenshot.png
echo "Saved screenshot.png ($(wc -c < screenshot.png | tr -d ' ') bytes)"
Run it: bash screenshot_errors.sh
Details
What to know about /screenshot
- The default viewport is 1350 × 797 and the capture follows the page down to 16,384 px; a taller page is cut there.
- Every response carries an
X-Result-Urlheader: a link to the same image that needs no API key and stays valid for 30 days. Often you can store that link instead of the file. - PNG is the default;
"format": "jpeg"or"webp"inscreenshot_optionsmakes a long page far smaller. - It captures images, not PDFs, and it does not record video.
- A screenshot of the same URL with the same options is served from the cache for 7 days by default, free. Set
max_ageto refresh sooner.
FAQ
Frequently asked questions
Why is the screenshot response Base64 and not an image?
How do I take a mobile screenshot?
Can I screenshot one element instead of the whole page?
Do I need an SDK to call URLpipe from cURL?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.