Oakclock: scheduled jobs
Oakstack calls a URL in your app on a cron schedule. Every run is logged with its result. Failed calls are retried, and a job that keeps failing pauses itself instead of hammering a broken URL.
Use it for nightly cleanups, daily digests, syncing data every 15 minutes, or anything you would otherwise put in a server cron.
Quick start
- Get an API key at https://oakstack.dev/dashboard/api-keys and set it as
OAKSTACK_API_KEY. - Add a route in your app that does the work, for example
POST /api/cron/cleanup. - Create the job:
import { Oakstack } from "oakstack";
const oakstack = new Oakstack();
const job = await oakstack.clock.jobs.create({
name: "Nightly cleanup",
schedule: "0 3 * * *",
timezone: "America/Chicago",
url: "https://yourapp.com/api/cron/cleanup",
body: JSON.stringify({ task: "cleanup" }),
});
// Save job.signingSecret as OAKSTACK_SIGNING_SECRET in your app.
await oakstack.clock.jobs.run(job.id); // test it now instead of waiting for 3am
const [run] = await oakstack.clock.jobs.runs(job.id); // status, responseStatus, error
- In the route, verify the request came from Oakstack (see Verifying requests).
Or with an agent: the MCP tools clock_create_job, clock_run_job, and clock_list_runs do the same. With curl:
curl https://oakstack.dev/api/v1/clock/jobs \
-H "Authorization: Bearer $OAKSTACK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-cleanup-job" \
-d '{"name":"Nightly cleanup","schedule":"0 3 * * *","timezone":"America/Chicago","url":"https://yourapp.com/api/cron/cleanup"}'
Schedules
schedule is a standard 5-field cron expression: minute hour day-of-month month day-of-week. It is evaluated in timezone (any IANA name, default UTC), including daylight saving changes.
| Schedule | Meaning |
|---|---|
*/5 * * * * |
every 5 minutes |
0 * * * * |
every hour, on the hour |
0 9 * * * |
every day at 9:00 |
0 9 * * 1-5 |
weekdays at 9:00 |
0 0 1 * * |
midnight on the 1st of each month |
The finest granularity is one minute. Six-field expressions (with seconds) are rejected. If Oakstack was unable to run for a while, it fires the job once for the most recent missed time and then continues on schedule. It never fires a backlog of missed runs.
What your app receives
POST https://yourapp.com/api/cron/cleanup
Content-Type: application/json (when a body is set and you didn't set one)
User-Agent: Oakstack-Clock/1.0 (+https://oakstack.dev)
Oakstack-Job-Id: 6f1c... the job
Oakstack-Run-Id: 91ab... this run (same across its retries)
Oakstack-Scheduled-For: 2026-10-05T08:00:00.000Z
Oakstack-Attempt: 1 1-4
Oakstack-Signature: t=1791158400,v1=5f2b...
...plus any headers you set on the job
{"task":"cleanup"}
Answer with any 2xx status within 15 seconds to mark the run succeeded. Anything else is a failure: a non-2xx status, a timeout, or a connection error. Redirects are not followed, so give the final URL.
If the work takes longer than 15 seconds, answer 202 right away and do the work in the background.
Because a run can be retried, make your handler safe to call twice. Use Oakstack-Run-Id to skip work you already did.
Retries and failures
- A failed attempt is retried after about 30 seconds, then 2 minutes, then 10 minutes: 4 attempts in all. After the last attempt the run is marked
failed. - After 10 failed runs in a row, the job pauses itself with a
pausedReason. Fix the URL, then resume it (PATCHwith{"status":"active"}). - Every attempt's status code, error, the first 2 KB of your response, and the duration are in the run log, both in the dashboard and through
GET /api/v1/clock/jobs/:id/runs.
Verifying requests
import { verifySignature } from "oakstack";
export async function POST(req: Request) {
const body = await req.text();
if (
!(await verifySignature(
process.env.OAKSTACK_SIGNING_SECRET!,
req.headers.get("oakstack-signature"),
body,
))
) {
return new Response("bad signature", { status: 401 });
}
// ... do the work. Make it safe to run twice: use Oakstack-Run-Id to skip repeats.
return Response.json({ ok: true });
}
Each job has its own signingSecret. The scheme, and versions without the SDK (Node, Python), are in signatures.
API reference
SDK methods: oakstack.clock.jobs.create, .list, .get, .update, .pause, .resume, .delete, .run, .runs.
All REST endpoints take Authorization: Bearer <API key>. Write endpoints accept an Idempotency-Key header: retrying with the same key returns the original response instead of creating a second job.
| Method | Path | What it does |
|---|---|---|
POST |
/api/v1/clock/jobs |
Create a job. Returns 201 with the job. |
GET |
/api/v1/clock/jobs |
List jobs, newest first. ?limit= up to 100. |
GET |
/api/v1/clock/jobs/:id |
Get one job. |
PATCH |
/api/v1/clock/jobs/:id |
Change any field, or {"status":"paused"} / {"status":"active"}. |
DELETE |
/api/v1/clock/jobs/:id |
Delete a job and its run history. |
POST |
/api/v1/clock/jobs/:id/run |
Run now. Returns 202 with the queued run. |
GET |
/api/v1/clock/jobs/:id/runs |
Recent runs, newest first. |
Create fields:
| Field | Required | Notes |
|---|---|---|
name |
yes | 1-100 characters |
schedule |
yes | 5-field cron |
url |
yes | Public https:// URL, at most 2048 characters. No credentials in the URL; put them in headers. |
timezone |
no | IANA name, default UTC |
method |
no | POST (default), GET, PUT, PATCH, or DELETE |
headers |
no | Up to 20 extra headers, for example {"Authorization":"Bearer ..."}. You can't set Oakstack-*, Host, or Content-Length. |
body |
no | String up to 64 KB, sent as-is |
Job fields: id, name, schedule, timezone, url, method, headers, body, status (active or paused), pausedReason, nextRunAt, lastRunAt, lastRunStatus, consecutiveFailures, signingSecret, createdAt, updatedAt.
Run fields: id, jobId, trigger (schedule or manual), scheduledFor, status (pending, running, succeeded, or failed), attempt, maxAttempts, nextAttemptAt (while waiting to retry), responseStatus, responseSnippet, error, durationMs, createdAt, finishedAt.
Errors
Errors are JSON: { "statusCode": 400, "name": "invalid_request", "message": "schedule: must be a 5-field cron expression ..." }. Each message says what to fix.
| Status | name |
When |
|---|---|---|
| 400 | invalid_request |
Bad field, invalid schedule or time zone, or a private or http:// URL |
| 401 | invalid_api_key |
Missing, wrong, or revoked key |
| 404 | not_found |
No job with that id in your workspace |
| 429 | usage_limit_reached |
Your plan's job limit is full. Delete or pause a job, or upgrade. |
| 429 | rate_limited |
Too many API calls per minute. Wait for Retry-After. |
Limits
| Plan | Jobs |
|---|---|
| Free | 3 |
| Builder | 25 |
| Studio | 100 |
Paused jobs count toward the limit, because the limit is on jobs you keep. Resuming a job is refused when the plan's limit of active jobs is already full. If you downgrade, the newest jobs above the new limit are paused, never deleted.
Run logs are kept for 7 days (30 on Studio).