API Reference

Full reference for the Scheduled jobs API (the Clockwork API). All endpoints are served from https://api.vernesoft.com.


Authentication

Clockwork can be reached two ways. Both operate on the same jobs for your tenant — pick whichever fits the caller.

Browser/console calls use your Kratos session cookie against the /dashboard/clockwork/* routes. The ory_kratos_session cookie is set automatically when you log in to the dashboard.

Cookie: ory_kratos_session=<your_session_token>

Requests without a valid session return 401 Unauthorized. Admin accounts cannot access tenant dashboard routes and receive 403 Forbidden.

2. API key — Programmatic

Server-to-server calls use a Bearer API key against the /v1/clockwork/* routes. A key is vrn_ followed by random characters; it does not name an API. Keys created before scopes existed and named vrn_clockwork_... still work and open Clockwork only.

Example header:

Authorization: Bearer vrn_9f8a7...

Create and rotate keys on the Dashboard → API Keys page (tick Scheduled jobs, the clockwork scope). The key is scoped to a single tenant — the tenant is derived from the key itself, so a caller can only ever read or modify its own jobs. A key without the clockwork scope is rejected with 403 Forbidden and the code forbidden (This API key is not authorized for Clockwork.).

Requests with a missing or revoked key return 401 Unauthorized.

Route mapping

The two access modes are 1:1 — identical request bodies, successful responses, and status codes. Only the base path and the auth header differ, and the shape of an error body: the API-key routes answer in the standard envelope, while the dashboard (cookie) routes keep the short form the dashboard itself reads, {"error": "<sentence>"}. The rest of this reference documents the /dashboard/clockwork/* (session) paths; to call the API-key equivalent, swap the prefix:

Session (cookie)API key (Bearer)
GET /dashboard/clockwork/jobsGET /v1/clockwork/jobs
POST /dashboard/clockwork/jobsPOST /v1/clockwork/jobs
PATCH /dashboard/clockwork/jobs/{job_id}PATCH /v1/clockwork/jobs/{job_id}
DELETE /dashboard/clockwork/jobs/{job_id}DELETE /v1/clockwork/jobs/{job_id}
GET /dashboard/clockwork/jobs/{job_id}/executionsGET /v1/clockwork/jobs/{job_id}/executions
GET /dashboard/clockwork/delayedGET /v1/clockwork/delayed
POST /dashboard/clockwork/delayedPOST /v1/clockwork/delayed
DELETE /dashboard/clockwork/delayed/{job_id}DELETE /v1/clockwork/delayed/{job_id}
GET /dashboard/clockwork/delayed/{job_id}/executionsGET /v1/clockwork/delayed/{job_id}/executions

Example — list jobs with an API key:

curl https://api.vernesoft.com/v1/clockwork/jobs \
  -H "Authorization: Bearer vrn_9f8a7..."

Example — create a cron job with an API key:

curl -X POST https://api.vernesoft.com/v1/clockwork/jobs \
  -H "Authorization: Bearer vrn_9f8a7..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Nightly report",
    "schedule": "0 2 * * *",
    "url": "https://yourapp.com/internal/reports/generate",
    "method": "POST"
  }'

Cron Jobs

Recurring jobs defined by a 5-field cron expression.

┌───── minute (0–59)
│ ┌───── hour (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌───── month (1–12)
│ │ │ │ ┌───── day of week (0–7, Sunday = 0 or 7)
* * * * *

Restrict day-of-month or day-of-week, not both.

A schedule that restricts both is rejected with a 400. POSIX crontabs combine those two fields with OR — 0 0 13 * 5 means "the 13th of every month, and every Friday" — while the scheduler underneath Clockwork combines them with AND, which would make the same expression mean "Friday the 13th" and fire it once or twice a year instead of about sixty-four times. Rather than pick one silently, we refuse the expression and say so.

Put * in one of the two fields. Schedules where at most one is restricted — the overwhelming majority — are unaffected and mean exactly what they mean everywhere else.

Sunday is both 0 and 7, as in POSIX. A range ending in 7 runs through Sunday, so 1-7 is every day and 6-7 is the weekend.

A schedule may be at most 255 characters. A longer one is rejected with a 400 rather than accepted and then failing to save.


Timezones and daylight saving

A schedule is read in UTC unless the job names a zone. Set timezone to an IANA time zone name — Europe/Paris, America/New_York, Asia/Kolkata — and the five fields are read as wall-clock time in that zone. 0 9 * * * in Europe/Paris means 09:00 in Paris all year round: 07:00 UTC in summer and 08:00 UTC in winter. Clockwork moves the job by itself when the clocks change; you do not edit it twice a year.

Only the reading of schedule changes. next_run_at, last_run_at and every other timestamp the API returns stay in UTC, and Delayed Jobs are untouched — their run_at is already an exact instant. A job created without timezone is a UTC job, exactly as before zones existed.

Twice a year the wall clock stops being a straight line: in spring an hour does not exist, in autumn an hour happens twice. Clockwork follows cronie (the cron that runs on most Linux servers) and treats two kinds of schedule differently.

A schedule that names a time of day — the minute and hour fields do not start with * (30 2 * * *, 0 9 * * *, 0,30 2 * * *):

SituationWhat happensExample: Europe/Paris, 30 2 * * *
The time does not exist (clocks go forward)The job runs once, at the first moment after it that does exist. It is not skipped.29 March 2026: 02:00 becomes 03:00, so 02:30 never happens. The job runs at 03:00 local, 01:00 UTC.
The time happens twice (clocks go back)The job runs once, at the first occurrence. It does not run again.25 October 2026: 02:30 happens at 00:30 UTC (CEST) and again at 01:30 UTC (CET). The job runs at 00:30 UTC only.
Any other dayThe job runs at that wall-clock time.28 March: 01:30 UTC (02:30 CET). 30 March: 00:30 UTC (02:30 CEST).

Several times inside the same missing hour (0,30 2 * * *) run once, at the end of the gap, not once each.

A schedule that repeats within the day — the minute or hour field starts with * (0 * * * *, */15 * * * *, 0 */2 * * *): it follows the clock as it is and keeps its pace.

SituationWhat happensExample: Europe/Paris, 0 * * * *
Clocks go forwardThe missing hour is not made up.29 March 2026: runs at 01:00 CET, then at 03:00 CEST. 23 runs that day.
Clocks go backEvery matching time in the repeated hour runs, so the job keeps running every hour.25 October 2026: runs at 02:00 CEST and at 02:00 CET (00:00 and 01:00 UTC). 25 runs that day.

If a job must run exactly once a day, give it a time of day (0 9 * * *) rather than a *.

The zone is checked when you save the job. An unknown name — a misspelling, a UTC offset such as +02:00, or the wrong case (europe/paris) — is rejected with 400 and the code invalid_payload, and nothing is stored. Changing timezone or schedule on an existing job recomputes next_run_at at once, and a retry that was waiting under the old timing is dropped.


List Cron Jobs

GET /dashboard/clockwork/jobs

Returns all cron jobs for the authenticated tenant, ordered by creation date (newest first).

Example

curl https://api.vernesoft.com/dashboard/clockwork/jobs \
  -H "Cookie: ory_kratos_session=<session>"

Response (200 OK)

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "tenant_id": "tenant_001",
    "name": "Health ping",
    "schedule": "*/5 * * * *",
    "timezone": "UTC",
    "url": "https://yourapp.com/health",
    "method": "GET",
    "headers": {},
    "body": null,
    "is_active": true,
    "last_run_at": "2026-04-09T12:00:00Z",
    "next_run_at": "2026-04-09T12:05:00Z",
    "created_at": "2026-04-01T10:00:00Z",
    "updated_at": "2026-04-09T12:00:00Z"
  }
]

timezone is the zone schedule is read in (UTC unless set — see Timezones and daylight saving). All timestamps are UTC.


Create Cron Job

POST /dashboard/clockwork/jobs

Request Body

FieldTypeRequiredDescription
namestringYesHuman-readable name for the job.
schedulestringYes5-field cron expression (e.g. 0 * * * *).
timezonestringNoIANA time zone the schedule is read in, e.g. Europe/Paris. Default: UTC. See Timezones and daylight saving.
urlstringYesTarget URL to call on each execution. Must be http or https and point at a publicly reachable address.
methodstringNoHTTP method. Default: POST. Allowed: GET, POST, PUT, PATCH, DELETE.
headersobjectNoKey-value map of custom request headers. Names starting with X-Clockwork- are reserved and ignored.
bodystringNoRaw request body (sent as-is). Sent with Content-Type: application/json unless headers sets a Content-Type of its own. See The request Clockwork sends.

Example

curl -X POST https://api.vernesoft.com/dashboard/clockwork/jobs \
  -H "Cookie: ory_kratos_session=<session>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Nightly report",
    "schedule": "0 2 * * *",
    "url": "https://yourapp.com/internal/reports/generate",
    "method": "POST",
    "headers": { "X-Internal-Token": "secret" },
    "body": "{\"type\": \"daily\"}"
  }'

Response (201 Created)

Returns the created CronJob object.

Status CodeMeaning
201 CreatedJob created successfully.
400 Bad RequestInvalid cron expression, unknown timezone, or unsupported HTTP method.
401 UnauthorizedMissing or invalid session.

Update Cron Job

PATCH /dashboard/clockwork/jobs/{job_id}

All fields are optional — only provided fields are updated.

Request Body

FieldTypeDescription
namestringNew job name.
schedulestringNew 5-field cron expression. Read in the job's timezone.
timezonestringNew IANA time zone, e.g. Europe/Paris. Moves the job's next run; see Timezones and daylight saving.
urlstringNew target URL. Same rules as on creation — see Target URLs.
methodstringNew HTTP method.
headersobjectNew headers map (replaces existing).
bodystringNew body (replaces existing).
is_activebooleanSet to false to pause the job without deleting it.

Example

curl -X PATCH https://api.vernesoft.com/dashboard/clockwork/jobs/550e8400-e29b-41d4-a716-446655440000 \
  -H "Cookie: ory_kratos_session=<session>" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'

Response (200 OK)

Returns the updated CronJob object.

Status CodeMeaning
200 OKJob updated.
400 Bad RequestInvalid cron expression, unknown timezone, or HTTP method.
404 Not FoundJob not found (or belongs to another tenant).

Delete Cron Job

DELETE /dashboard/clockwork/jobs/{job_id}

Permanently deletes the cron job and all its execution history.

curl -X DELETE https://api.vernesoft.com/dashboard/clockwork/jobs/550e8400-e29b-41d4-a716-446655440000 \
  -H "Cookie: ory_kratos_session=<session>"
Status CodeMeaning
204 No ContentJob deleted.
404 Not FoundJob not found.

List Cron Job Executions

GET /dashboard/clockwork/jobs/{job_id}/executions

Returns the last 100 executions for a cron job, ordered newest first. A run that was retried contributes one row per attempt.

curl https://api.vernesoft.com/dashboard/clockwork/jobs/550e8400-e29b-41d4-a716-446655440000/executions \
  -H "Cookie: ory_kratos_session=<session>"

Response (200 OK)

[
  {
    "id": "exec_002",
    "job_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "success",
    "started_at": "2026-04-09T12:01:00Z",
    "completed_at": "2026-04-09T12:01:00.290Z",
    "duration_ms": 290,
    "response_status": 200,
    "response_body": "{\"ok\": true}",
    "error_message": null,
    "attempt": 2
  },
  {
    "id": "exec_001",
    "job_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "failed",
    "started_at": "2026-04-09T12:00:00Z",
    "completed_at": "2026-04-09T12:00:00.312Z",
    "duration_ms": 312,
    "response_status": 503,
    "response_body": "upstream unavailable",
    "error_message": null,
    "attempt": 1
  }
]

The example above is one run: the first attempt got a 503 and the retry a minute later succeeded.

FieldMeaning
statussuccess — 2xx. failed — non-2xx or network error. running — in progress.
attemptWhich try produced this row, counting from 1. See Retries.
response_statusHTTP status the target returned, or null if the request never completed.
error_messageWhy the request never completed; null when the target replied.

Delayed Jobs

One-shot jobs that execute once at a specific future timestamp.


List Delayed Jobs

GET /dashboard/clockwork/delayed

Returns all delayed jobs for the authenticated tenant, ordered by run_at ascending.

curl https://api.vernesoft.com/dashboard/clockwork/delayed \
  -H "Cookie: ory_kratos_session=<session>"

Response (200 OK)

[
  {
    "id": "660f9511-f3ac-52e5-b827-557766551111",
    "tenant_id": "tenant_001",
    "name": "Send welcome email",
    "run_at": "2026-04-10T09:00:00Z",
    "url": "https://yourapp.com/internal/send-welcome",
    "method": "POST",
    "headers": {},
    "body": "{\"user_id\": \"usr_001\"}",
    "status": "pending",
    "created_at": "2026-04-09T10:00:00Z",
    "updated_at": "2026-04-09T10:00:00Z"
  }
]
status valueMeaning
pendingWaiting for run_at. A job awaiting a retry returns to this state with a later run_at.
runningCurrently being executed.
successThe target returned 2xx.
failedThe target failed and no attempts remain.
cancelledCancelled before execution.

Create Delayed Job

POST /dashboard/clockwork/delayed

Request Body

FieldTypeRequiredDescription
namestringYesHuman-readable name for the job.
run_atstringYesISO 8601 UTC timestamp. Must be in the future.
urlstringYesTarget URL to call at execution time.
methodstringNoHTTP method. Default: POST. Allowed: GET, POST, PUT, PATCH, DELETE.
headersobjectNoKey-value map of custom request headers. Names starting with X-Clockwork- are reserved and ignored.
bodystringNoRaw request body (sent as-is). Sent with Content-Type: application/json unless headers sets a Content-Type of its own. See The request Clockwork sends.

Example

curl -X POST https://api.vernesoft.com/dashboard/clockwork/delayed \
  -H "Cookie: ory_kratos_session=<session>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Send invoice reminder",
    "run_at": "2026-04-15T08:00:00Z",
    "url": "https://yourapp.com/internal/invoices/remind",
    "method": "POST",
    "body": "{\"invoice_id\": \"inv_042\"}"
  }'

Response (201 Created)

Returns the created DelayedJob object.

Status CodeMeaning
201 CreatedJob scheduled.
400 Bad Requestrun_at is in the past, or unsupported HTTP method.
401 UnauthorizedMissing or invalid session.

Cancel Delayed Job

DELETE /dashboard/clockwork/delayed/{job_id}

Cancels a pending delayed job. Jobs that are already running or completed cannot be cancelled.

curl -X DELETE https://api.vernesoft.com/dashboard/clockwork/delayed/660f9511-f3ac-52e5-b827-557766551111 \
  -H "Cookie: ory_kratos_session=<session>"
Status CodeMeaning
204 No ContentJob cancelled.
404 Not FoundJob not found.
409 ConflictJob is not in pending status and cannot be cancelled.

List Delayed Job Executions

GET /dashboard/clockwork/delayed/{job_id}/executions

Returns the execution records for a delayed job, ordered newest first — one per attempt, so a job that was retried has more than one.

curl https://api.vernesoft.com/dashboard/clockwork/delayed/660f9511-f3ac-52e5-b827-557766551111/executions \
  -H "Cookie: ory_kratos_session=<session>"

Response (200 OK)

[
  {
    "id": "exec_002",
    "job_id": "660f9511-f3ac-52e5-b827-557766551111",
    "status": "success",
    "started_at": "2026-04-10T09:00:01Z",
    "completed_at": "2026-04-10T09:00:01.874Z",
    "duration_ms": 874,
    "response_status": 200,
    "response_body": "{\"sent\": true}",
    "error_message": null,
    "attempt": 1
  }
]

Target URLs

A job's url has to be somewhere Clockwork can reach from the public internet. Requests that are not are refused with 400, and the error names the rule:

RefusedWhy
ftp://…, file://…only http and https are fetched
https://user:pass@example.com/…credentials in a URL are stored in plaintext and shown back in the job list — send them in a header instead
http://10.0.0.5/, http://192.168.1.1/, http://127.0.0.1/private and loopback addresses
http://169.254.169.254/…link-local, which is where cloud metadata services live
http://internal-service/…a single-label hostname, which cannot be a public name

The same rule is applied again when the job runs, and the hostname is resolved first — so a name that resolves to a private address is refused at execution time even if it looked fine when the job was created. Those attempts appear in the execution history as failed, with the reason in error_message and no response recorded.

The request Clockwork sends

Content-Type. When a job has a body, Clockwork sends it with Content-Type: application/json. If the job's headers set a Content-Type of their own (in any letter case), that value is sent instead, and it is the only one: text/plain, application/x-www-form-urlencoded or application/vnd.github+json go through as written. A job with no body gets no default Content-Type.

Timeout. Clockwork waits 30 seconds for the target to answer, counted over the whole request. A target that is still working at 30 seconds is abandoned and the attempt is recorded as failed with no response_status and the reason in error_message, the same as a refused connection. It counts as a network failure, so it is retried. Redirects are not followed: a 3xx is recorded as the answer and the attempt fails. A target that needs longer should accept the request, answer at once and finish the work in the background.

Retries

A failed execution is retried automatically. Retry state is stored alongside the job rather than held in memory, so a pending retry survives a worker restart or deployment.

Each run gets up to 3 attempts: the first, then two retries — one minute and five minutes later. Clockwork's scheduler wakes once a minute, so a retry fires on the first tick at or after its due time.

What is retried

OutcomeRetriedWhy
Network failure — connection refused, timeout, DNS, TLSYesThe request never arrived, so nothing indicates it was wrong.
5xxYesA server-side error, commonly transient.
429 Too Many RequestsYesAn explicit "slow down".
Any other 4xxNoThe request itself is rejected; an identical retry gets the same answer.
2xx—Succeeded.

Make your endpoint idempotent

A retry repeats the identical request. Because 5xx responses are retried, an endpoint that fails after performing part of its work will perform that work again on the next attempt — Clockwork cannot distinguish a failure before a side effect from one after it.

If your job is not naturally idempotent, return a 4xx for permanent errors so Clockwork stops, and guard the side effect on your side using the headers below.

Every request Clockwork sends carries two headers:

HeaderValue
X-Clockwork-Execution-IdUUID of this execution. Matches the id in the execution history, so a request can be traced back to what Clockwork recorded.
X-Clockwork-AttemptWhich try this is, counting from 1. Anything above 1 is a retry.

A practical guard: when X-Clockwork-Attempt is greater than 1, check whether the work was already done before doing it again. X-Clockwork-Execution-Id changes per attempt — it identifies this try, not the run — so use it for correlation and logging rather than as a deduplication key.

The X-Clockwork- prefix is reserved. Headers in a job definition that start with it are ignored, so the target always receives exactly one value for each.

Cron jobs vs delayed jobs

  • Cron jobs — a retry is an extra run inserted between regular runs. It never shifts the schedule: next_run_at is untouched, and once the attempts are spent the job simply waits for its next scheduled time.
  • Delayed jobs — the job returns to pending with a later run_at, because there is no schedule to fall back on. It is marked failed only after the last attempt.

Seeing attempts

Every attempt is recorded as its own execution, numbered by attempt, so the history shows what your endpoint returned each time rather than only the last word. Both execution endpoints return them newest first.


Plan Limits

Clockwork is part of your plan, and it is not metered: scheduled runs and Clockwork API calls are not counted or billed. What a plan limits is how many jobs exist at once, and how long their history is kept:

PlanActive recurring jobsDelayed jobs waiting to runHistory of runs
Free101007 days
Pro1,00010,00090 days
Advanced10,000100,000180 days
Contractagreedagreed180 days

A recurring job is active until it is paused. A delayed job counts while it is pending or running; once it has run, failed or been cancelled it frees its place. Executions older than the history window are deleted.

Creating a job over the limit, or resuming a paused one (PATCH with "is_active": true), is refused with 402 Payment Required. This refusal carries the usual envelope plus the members the limit needs:

{
  "error": {
    "code": "UPGRADE_REQUIRED",
    "message": "The Free plan allows 10 active recurring jobs and you already have 10. Delete or pause a job, or upgrade to Pro (1000 allowed).",
    "request_id": "req_…",
    "plan": "free",
    "kind": "cron",
    "limit": 10,
    "current": 10
  }
}

kind is cron or delayed. On Advanced, where there is no bigger self-service plan, the code is JOB_LIMIT_REACHED. The limit does not lift by waiting, so do not retry a 402: delete or pause a job, or change plan. The same 402 UPGRADE_REQUIRED is what the dashboard answers to a second project on the Free plan. The other refusals an integration can meet are on Plans, Limits and Refusals.

Editing, pausing and deleting jobs always work, including at the limit. If you move to a smaller plan, nothing is deleted or paused: jobs you already have keep running, and only new jobs and resumes are refused until you are under the new limit.

GET /v1/clockwork/usage returns where you stand:

{
  "plan": "free",
  "tier": "free",
  "cron": { "active": 3, "limit": 10 },
  "delayed": { "pending": 12, "limit": 100 }
}

tier is the plan's name (free, pro, advanced, contract). plan is the same value under its older name and is kept so that code written against it keeps working; read tier in new code. They are always equal.

A limit of null means the plan has no number (Contract).


Error Format

Errors come back in the standard Verne error envelope:

{
  "error": {
    "code": "invalid_payload",
    "message": "Invalid cron schedule. Expected a 5-field POSIX expression with a future occurrence (e.g. \"0 * * * *\")",
    "request_id": "req_9f8a7c1b2d3e4f50"
  }
}

code is a string to branch on, together with the HTTP status: invalid_payload (400, and a body the API cannot accept), unauthorized (401), forbidden (403, the key has no clockwork scope), not_found (404, no such job or execution), conflict (409), internal_error (500), upstream_unavailable (502, retry). message is the sentence for a person. The plan-limit refusal described under Plan Limits has the same shape with a code of its own (UPGRADE_REQUIRED, JOB_LIMIT_REACHED) and extra members.

A schedule is rejected unless it parses and has a future occurrence, so an expression like 0 0 31 2 * — 31 February, which never comes — is refused at creation rather than accepted and silently never run.

A timezone that is not an IANA name is refused the same way, with the sentence naming it:

{
  "error": {
    "code": "invalid_payload",
    "message": "Unknown timezone \"Mars/Olympus\". Use an IANA timezone name such as \"Europe/Paris\" or \"America/New_York\", or \"UTC\".",
    "request_id": "req_9f8a7c1b2d3e4f50"
  }
}

Until October 2026 most Clockwork errors came as {"error": "<sentence>"}, with error a plain string and no request_id; code that reads error as a string should read error.message and error.code now. The HTTP statuses did not change.

Include the request_id when contacting support for faster resolution.