The quickest way to screenshot a website from a terminal is to send the URL to a screenshot API and save what comes back. The browser runs on the API’s servers, so all you need locally is curl, with no Chrome, Puppeteer, or missing system libraries to deal with.
This guide uses Bash and curl on macOS or Linux. Everything also works in WSL on Windows. If you want to run the browser on your own machine instead, see the Puppeteer screenshot guide.
One URL, one command
Get a ScreenshotOne access key, export it as SCREENSHOTONE_ACCESS_KEY, and run:
curl --fail --silent --show-error --max-time 120 --get \ 'https://api.screenshotone.com/take' \ --header "X-Access-Key: ${SCREENSHOTONE_ACCESS_KEY:?Set the access key first}" \ --data-urlencode 'url=https://example.com/' \ --data-urlencode 'format=png' \ --data-urlencode 'full_page=true' \ --data-urlencode 'viewport_width=1280' \ --data-urlencode 'viewport_height=800' \ --output example.pngThat’s it. example.png is your screenshot. The response is the image itself, with no JSON to parse.
A few of these flags matter more than they look:
--data-urlencodeencodes the target URL correctly even if it has its own?a=1&b=2query string.- The single quotes stop your shell from treating
&as “run in background.” --failmakes HTTP errors return a non-zero exit code. Without it, curl happily saves the error response asexample.png, and you find out later.
A reusable script that won’t overwrite good captures
For repeat runs, you want one more property: if a capture fails, keep the last good image. This script downloads to a temporary file and only replaces the real one once the result looks valid. Save it as capture-one.sh:
#!/usr/bin/env bashset -euo pipefail
: "${SCREENSHOTONE_ACCESS_KEY:?Set SCREENSHOTONE_ACCESS_KEY}"if [[ $# -lt 1 || $# -gt 2 ]]; then printf 'Usage: bash capture-one.sh URL [output.png]\n' >&2 exit 2fi
target_url=$1output_path=${2:-screenshot.png}
mkdir -p -- "$(dirname -- "$output_path")"temporary_path=$(mktemp "${output_path}.tmp.XXXXXX")trap 'rm -f -- "$temporary_path"' EXIT
content_type=$(curl --fail --silent --show-error --max-time 120 --get \ 'https://api.screenshotone.com/take' \ --header "X-Access-Key: $SCREENSHOTONE_ACCESS_KEY" \ --data-urlencode "url=$target_url" \ --data-urlencode 'format=png' \ --data-urlencode 'full_page=true' \ --data-urlencode 'viewport_width=1280' \ --data-urlencode 'viewport_height=800' \ --output "$temporary_path" \ --write-out '%{content_type}')
if [[ $content_type != image/png* || ! -s $temporary_path ]]; then printf 'Did not get a PNG back; kept the existing file.\n' >&2 exit 1fi
mv -f -- "$temporary_path" "$output_path"printf 'Saved %s\n' "$output_path"Run it with a quoted URL:
bash capture-one.sh 'https://example.com/?category=design&sort=new' captures/example.pngTwo notes on the capture settings:
- The viewport width picks the layout.
full_page=trueonly makes the image taller. It won’t give you the desktop layout on its own, so set the width you actually want. - Full-page captures scroll the page first so lazy-loaded images have a chance to appear. If some are still missing, increase the scroll delay. See the full-page options.
A list of URLs
Put one URL per line in urls.txt. Lines starting with # are ignored:
https://example.com/https://example.org/# Add the public pages you want to capture.Save this as capture-list.sh, next to capture-one.sh:
#!/usr/bin/env bashset -euo pipefail
input_file=${1:-urls.txt}output_dir=${2:-captures}script_dir=$(cd -- "$(dirname -- "$0")" && pwd)mkdir -p -- "$output_dir"manifest="$output_dir/results.tsv"printf 'file\tstatus\turl\n' > "$manifest"
number=0failures=0while IFS= read -r target_url || [[ -n "$target_url" ]]; do target_url=${target_url%$'\r'} [[ -z "$target_url" || "$target_url" == \#* ]] && continue number=$((number + 1)) filename=$(printf '%04d.png' "$number") if bash "$script_dir/capture-one.sh" "$target_url" "$output_dir/$filename"; then status=ok else status=failed failures=$((failures + 1)) fi printf '%s\t%s\t%s\n' "$filename" "$status" "$target_url" >> "$manifest"done < "$input_file"
if [[ $number -eq 0 ]]; then printf 'No URLs found in %s.\n' "$input_file" >&2 exit 2fiprintf 'Captured %s URLs, %s failed.\n' "$number" "$failures"[[ $failures -eq 0 ]]Run it into a timestamped folder so runs don’t mix:
bash capture-list.sh urls.txt "captures/$(date -u +%Y%m%dT%H%M%SZ)"A failed URL doesn’t stop the run. It’s marked failed in results.tsv, the other URLs continue, and the script exits non-zero at the end so cron or CI notices. Use results.tsv to see which file belongs to which URL rather than going by the file names.
The script processes URLs one at a time, which is fine for dozens of URLs. For thousands, check your plan’s concurrency limit, run a few in parallel (for example with xargs -P), and add retries with backoff.
Run it on a schedule
The API takes the screenshots, and something else decides when. That can be cron, systemd timers, or a CI service you already use. With cron on Linux:
0 8 * * * /bin/bash /opt/site-captures/run-daily.sh >> /opt/site-captures/capture.log 2>&1And run-daily.sh:
#!/usr/bin/env bashset -euo pipefailcd /opt/site-captures# Keep the key in a file only this user can read, outside version control.source /opt/site-captures/access-key.envexport SCREENSHOTONE_ACCESS_KEYbash capture-list.sh urls.txt "captures/$(date -u +%Y%m%dT%H%M%SZ)"Cron catches a lot of people out because it runs with a minimal environment: a short PATH, no shell profile, and the server’s time zone. Use absolute paths, and run the wrapper manually once before you rely on the schedule.
If a run could take longer than the gap between runs, wrap it in flock so two don’t overlap. Make sure failures reach you as alerts. Nobody reads capture.log until something has already gone wrong.
For captures alongside a deployment review, the GitHub Actions screenshot guide shows how to capture deployed routes and save the images as workflow artifacts.
Or use the ScreenshotOne CLI
If you’d rather have named commands than curl flags, install the ScreenshotOne CLI. It’s also available as a Docker image (see the CLI integration page). Once your keys are configured:
screenshotone take 'https://example.com/' -o viewport.pngscreenshotone full-page 'https://example.com/' --full-page-scroll -o full-page.pngscreenshotone pdf 'https://example.com/' --pdf-print-background -o document.pdfThe CLI signs its requests, so follow its README to set both the access key and the secret key. One gotcha: its --json flag prints the signed request URL. It doesn’t ask the API for JSON metadata the way response_type=json does.
When it doesn’t work
| Symptom | Check |
|---|---|
| Fails before sending anything | Is SCREENSHOTONE_ACCESS_KEY exported in this shell? |
| 401 or 403 | Is the key correct, and does your account require signed requests? |
| The PNG shows a login page | The API doesn’t have your browser’s cookies |
| Missing images further down | Increase the scroll delay, or wait for a specific selector |
| Very slow | Look at what the page loads, and set timeouts before adding retries |
| Works manually, fails in cron | Working directory, PATH, or credentials in cron’s environment |
My advice: get one URL looking right, then roll the same settings out to the list. A 200 OK only means the API returned an image. You still need to look at it to know the page rendered the way you wanted.


