# 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:

```ts
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
```

4. In the route, verify the request came from Oakstack (see [Verifying requests](#verifying-requests)).

Or with an agent: the MCP tools `clock_create_job`, `clock_run_job`, and `clock_list_runs` do the same. With curl:

```sh
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 (`PATCH` with `{"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

```ts
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](https://oakstack.dev/docs/signatures.md).

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