How crontab calls your endpoint
These are the promises we make. Everything on this page is what the dispatcher actually does.
What we send
One HTTP request per attempt, with the method you chose — GET or POST — to the exact URL you saved. Your own headers (up to 20 of them) are sent first; ours are set afterwards, so a header named X-Crontab-* can never be spoofed by a job's configuration.
| Header | Meaning |
|---|---|
| X-Crontab-Job-Id | The job this request belongs to. Stable for the life of the job. |
| X-Crontab-Run-Id | The run. Stable across retries — use it to deduplicate. |
| X-Crontab-Attempt | Which attempt this is, 1 to 3. |
| X-Crontab-Timestamp | Unix seconds at the moment we signed the request. |
| X-Crontab-Signature | v1=<hex HMAC-SHA256(secret, `${timestamp}.${body}`)>. Body is empty for GET. |
| User-Agent | crontab/1 |
A POST body is sent verbatim, up to 64 KB. If you did not set a Content-Type yourself, we send application/json. GET requests have no body at all.
When it fires
The scheduler wakes every 60 seconds, so a job fires within about a minute of its scheduled time. Delivery is at-least-once: we do not promise exactly-once, which is why every request carries a run id you can deduplicate on.
Missed occurrences are not replayed. If we are down at 09:00, the 09:00 run does not happen late — the next scheduled slot is used instead. A job's next run time is always advanced to the first occurrence after now.
Throughput cap. A single tick starts at most 50 new occurrences. We log it when that limit is hit; it is not a limit you are likely to reach with a normal account.
Success and retries
Any 2xx is a success. Everything else is a failed attempt: a 4xx or 5xx, a connection error, or no response within 30 seconds.
Redirects are not followed. A 3xx is a failure. A redirect to an internal address is the classic way to turn a webhook into a request against a private network, so we refuse them outright. For the same reason, a URL that resolves to a private, loopback, link-local, or cloud-metadata address is rejected — when you save the job, and again at the moment we dispatch it.
A failed attempt is retried up to 2 times, at +1 minute and then +5 minutes. The run stays queued in between and keeps its run id; only the attempt number changes. After 3 failed attempts the run is failed.
Runs you start yourself with Run now are not retried — you can just press it again.
We record the status code, the duration, and the first 4 KB of the response body on every attempt, and show them on the job page.
Overlap
One run per job at a time. If a job is still running — or waiting on a retry — when its next scheduled time comes around, that occurrence is skipped, recorded as a skipped row with the reason, and the schedule moves on. Work never queues up behind a slow endpoint.
If our worker dies mid-request, the run is not lost: any run still marked running after two minutes is treated as a failed attempt (worker lost) and retried under the normal rules.
Verifying signatures
Every job has its own secret, shown on the job page and rotatable at any time. We sign `${timestamp}.${body}` with HMAC-SHA256 and send the lowercase hex digest as X-Crontab-Signature: v1=<hex>. For GET, the body is the empty string.
Check the timestamp as well as the digest — a signature is only good for 5 minutes, which is what stops an old request being replayed at you. This snippet does both, and has no dependencies:
// verify.ts — Bun, Node 20+, Deno, and edge runtimes. No dependencies.
const TOLERANCE_SECONDS = 300;
export async function verifyCrontab(req: Request, secret: string): Promise<boolean> {
const timestamp = req.headers.get("X-Crontab-Timestamp");
const signature = req.headers.get("X-Crontab-Signature");
if (!timestamp || !signature) return false;
// Reject replays: the timestamp must be within five minutes of our clock.
const sent = Number(timestamp);
if (!Number.isInteger(sent)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - sent) > TOLERANCE_SECONDS) return false;
// GET requests are signed over an empty body.
const body = req.method === "GET" ? "" : await req.text();
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`));
let expected = "";
for (const byte of new Uint8Array(mac)) expected += byte.toString(16).padStart(2, "0");
return timingSafeEqual(signature, `v1=${expected}`);
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
After rotating a secret, requests are signed with the new one from the next attempt onwards. Accept both for a few minutes if you cannot deploy the new secret at the same instant.
Alerts
We email you when a job's health changes, not when a run fails. A job that has been succeeding (or has never run) and then fails all 3 attempts gets you one “is failing” email; the next time it succeeds you get one “recovered” email. An endpoint that stays broken for a week sends two emails in total, not one per run.
A first success on a brand-new job is not a recovery, so it is silent. Retried attempts do not count either — health only moves once a run has reached its final outcome.
Alerts are per job and on by default. Turn them off in the job form or from the Alerts panel on the job page; every alert email links straight to it. Emails go to the address you signed in with.
Limits
| Limit | Free | Pro |
|---|---|---|
| Jobs | 3 | 20 |
| Finest schedule | hourly | every minute |
| Run history kept | 1 day | 30 days |
| Request timeout | 30 seconds | |
| Headers per job | 20 | |
| Request body | 64 KB | |
| Response captured | first 4 KB | |
| New runs started per tick | 50 every 60 seconds | |
| Failure emails | on health changes only, per job, on by default | |
Run history is swept on the schedule above. Nothing else about a job is deleted when you downgrade — jobs over the limit are paused, not removed.