Schedule daily website screenshots
A cron expression, your own timezone, and a signed webhook. For archives you can produce two years later, change records nobody has to remember to take, and evidence of what a page said on a given morning.
Why schedule rather than script
The obvious version of this is a cron job on a box you own that calls a screenshot API. That works, and for one page it is probably what you should do. It stops being simple at the third page, because you now own the cron daemon, the retry logic, the alerting for when the box is down, and the question of what timezone the box thinks it is in.
The version below moves that to the API: the schedule is a record you create,
update and delete like any other, and the run history is queryable rather than
scattered through /var/log.
Creating one
curl -X POST https://api.getsnap.dev/v1/schedules \
-H "X-API-Key: sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "pricing page, daily",
"cron": "0 6 * * *",
"timezone": "Europe/Vilnius",
"options": {
"url": "https://example.com/pricing",
"full_page": true,
"remove_popups": true
},
"webhook_url": "https://example.com/hooks/screenshot",
"webhook_secret": "a-long-random-string"
}'
options is the ordinary capture payload — anything you can
send to /v1/screenshot works here, including PDF output, device
emulation, selector hiding and S3 upload. The schedule is a wrapper around a
capture you have already decided on.
The timezone is the point
A schedule stores a real IANA timezone rather than an offset, which is what
makes "every morning at six" mean the same thing in March and in November.
Store UTC and set 0 6 * * * and your morning capture
arrives at seven for half the year — or five, depending which way your
country jumps.
The timezone is validated against the real IANA database when you create the
schedule. That check is there because the underlying cron library accepts
any string as a timezone and silently falls back to UTC, so a typo
like Europe/Vilnus would not error — it would just quietly
run your schedule in the wrong zone for as long as nobody noticed.
Cron expressions that actually fire
| Expression | When |
|---|---|
0 6 * * * | every day at 06:00 |
0 */4 * * * | every four hours |
0 9 * * 1-5 | 09:00 on weekdays only |
30 23 L * * | 23:30 on the last day of the month |
0 0 1 1 * | once a year, at midnight on 1 January |
Five-field and six-field expressions are both accepted. A short form like
* * * is rejected on creation rather than accepted and never
fired, which is the other failure the cron library permits by default.
Verifying the webhook
Every delivery carries two headers:
X-Webhook-Timestamp: 1793664000
X-Webhook-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
The signature is HMAC-SHA256 over the timestamp, a full stop, and the raw body, keyed with the secret you set on the schedule. Verify against the body exactly as it arrived — parse it afterwards, not before, because re-serialising JSON changes the bytes and the signature will not match.
const crypto = require('crypto');
function verify(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
const sent = headers['x-webhook-signature'];
// Reject anything old so a captured request cannot be replayed.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// Constant time: a plain === leaks how much of the signature matched.
return crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
}
Managing schedules
| Endpoint | Does |
|---|---|
GET /v1/schedules | list them, with last and next run time |
PATCH /v1/schedules/:id | change the cron, timezone, options or webhook |
POST /v1/schedules/:id/run | fire it now, without waiting for the cron |
GET /v1/schedules/:id/runs | the last 100 runs, each completed or failed |
DELETE /v1/schedules/:id | remove it and unregister the job |
Two of those matter more than they look. Run-now is how you test a webhook handler without waiting until six in the morning to find out it throws. And run history is how you notice the failure mode that actually bites: a schedule that has been failing quietly for a fortnight, because nothing complains when a screenshot does not arrive. Check the history rather than the absence of complaints.
Deactivating is a flag rather than a delete — set
is_active to false to pause one and keep its history.
What people schedule
- Evidence of what a page said. Pricing, terms, a published policy. The point is not the screenshot, it is being able to produce the one from eighteen months ago.
- Change records. A daily capture of a competitor's pricing page is a change log you did not have to write. Pair it with a visual diff if you want the difference rather than the archive.
- Dashboards that cannot be shared. A capture into Slack every Monday beats asking people to log into something they do not have access to.
- Catching the deploy that broke the page. Hourly captures of a landing page narrow "when did this break" from a week to an hour.
Who else does this
Checked against each provider's own documentation on 25 October 2026, and written up with the rest in the sourced comparison.
| Scheduling | Where from | |
|---|---|---|
| getSnap.dev | yes | API, with signed webhooks and run history |
| ScreenshotAPI.net | yes | their dashboard, managed by hand |
| Urlbox | no | their own guide routes it through Zapier |
| ScreenshotOne | no | — |
| ApiFlash | no | — |
A dashboard is fine for five schedules and wrong for five hundred, because there is no way to create them from your own code when a customer signs up. That is the distinction worth caring about, rather than the tick.
Frequently asked questions
What happens when the clocks change?
A real IANA zone means 06:00 stays 06:00. An offset or UTC means it drifts an hour, twice a year, in opposite directions.
How do I know the webhook came from you?
Recompute the HMAC over timestamp + "." + raw body with your
secret and compare in constant time. Reject anything more than a few minutes
old.
What if the page is broken when it fires?
The run is recorded as failed, not skipped, and stays in the history. That is the thing to alert on.
How is it billed?
Each run is one credit, the same as any capture. A daily schedule is about 30 a month, so the free plan's 100 covers three of them — and every option works on the free plan, because we meter credits rather than features.
Set one up and watch it fire
100 captures a month, free and permanent, no card. Use the run-now endpoint and you will know inside a minute whether your webhook handler works. Full field list in the API reference.
Get a free API key