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

  1. Get an API key at https://oakstack.dev/dashboard/api-keys and set it as OAKSTACK_API_KEY.
  2. Add a route in your app that does the work, for example POST /api/cron/cleanup.
  3. 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
  1. 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

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).