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

daily at 06:00 Vilnius time
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

ExpressionWhen
0 6 * * *every day at 06:00
0 */4 * * *every four hours
0 9 * * 1-509: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:

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.

verifying, Node
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

EndpointDoes
GET /v1/scheduleslist them, with last and next run time
PATCH /v1/schedules/:idchange the cron, timezone, options or webhook
POST /v1/schedules/:id/runfire it now, without waiting for the cron
GET /v1/schedules/:id/runsthe last 100 runs, each completed or failed
DELETE /v1/schedules/:idremove 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

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.

 SchedulingWhere from
getSnap.devyesAPI, with signed webhooks and run history
ScreenshotAPI.netyestheir dashboard, managed by hand
Urlboxnotheir own guide routes it through Zapier
ScreenshotOneno—
ApiFlashno—

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