This is the full developer documentation for Chronos
# Overview
> Chronos is a managed scheduling API. Define when jobs run, we make sure they happen.
Chronos is a managed scheduling API. You define when jobs should run, chronos makes sure they actually happen, with retries, backoff, and execution tracking built in.
## The guarantee
[Section titled “The guarantee”](#the-guarantee)
| Layer | Guarantee | What it means |
| ---------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| Scheduling | Exactly-once job creation | Each schedule occurrence produces at most one job, no matter how many app instances you run |
| Delivery | At-least-once execution | Jobs retry with exponential backoff until they succeed or exhaust max attempts |
Tip
Your handlers should be idempotent. Chronos guarantees delivery, not uniqueness of execution.
## How it works
[Section titled “How it works”](#how-it-works)
```text
Schedule ──spawns──▶ Job ──creates──▶ Execution
```
* **Schedule:** A recurring pattern (cron or interval) that spawns jobs on a cadence.
* **Job:** A unit of work to execute. Created by a schedule (recurring) or directly via the API (one-off).
* **Execution:** A single attempt to run a job. If it fails and retries remain, a new execution is created.
## Example worker
[Section titled “Example worker”](#example-worker)
Install the SDK:
* pnpm
```bash
pnpm add @chronos.sh/sdk
```
* npm
```bash
npm install @chronos.sh/sdk
```
* yarn
```bash
yarn add @chronos.sh/sdk
```
Create a worker that processes jobs:
```ts
import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY });
chronos.worker.handle('sync-tenant', async (ctx) => {
const tenants = await db.tenants.findMany();
for (const tenant of tenants) {
await syncData(tenant.id, ctx.payload);
}
return { synced: tenants.length };
});
await chronos.worker.start();
```
Create a schedule via the API, and your worker picks up jobs automatically:
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Hourly tenant sync",
"handler": "sync-tenant",
"interval": { "value": 1, "unit": "hour" },
"payload": { "full": false }
}'
```
## Two ways to receive jobs
[Section titled “Two ways to receive jobs”](#two-ways-to-receive-jobs)
**Pull (worker):** Your process long-polls Chronos for jobs. Best for background processing where you control the compute. This is what the example above shows.
**Push (HTTP):** Chronos calls your HTTP endpoint when a job is due. Best when you already have a server listening, or want jobs delivered to serverless functions.
Both paths get the same retry guarantees. Choose based on your infrastructure, not reliability needs.
## Next step
[Section titled “Next step”](#next-step)
[Quick Start ](/docs/quick-start)Create and run your first job in under 5 minutes
[Agent Setup ](/docs/agents/setup)Hand a coding agent one prompt and an API key
# Set up Chronos with your agent
> Hand a coding agent one prompt and an API key. It reads the docs, wires up a worker, and gets a job running.
Coding agents such as Claude Code, Cursor, and Codex can set Chronos up in a project on their own. [chronos.sh/llms.txt](https://chronos.sh/llms.txt) gives them the facts and an index of these docs, and [llms-full.txt](https://chronos.sh/llms-full.txt) holds every page as plain text. The one thing an agent cannot do on the pull path is create an API key.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* A Chronos account with the admin role ([sign up free](https://app.chronos.sh/signup))
* A coding agent with shell access to the project
* Node.js 20 or later in the project
1. **Create an API key**
In the [dashboard](https://app.chronos.sh), open Settings → API Keys and create a key. Export it in the shell the agent runs in:
```bash
export CHRONOS_API_KEY=chrns_your_api_key
```
Keep the key out of the prompt and out of the repository. The agent reads it from the environment.
2. **Paste this prompt**
```text
Set up Chronos (https://chronos.sh) in this project so one job runs through it end to end.
Read https://chronos.sh/llms.txt first and follow the links you need. Create schedules and jobs over the REST API with curl or fetch. Use the Node SDK (@chronos.sh/sdk) only for the pull worker.
The API key is in the CHRONOS_API_KEY environment variable. Never print it, hardcode it, or commit it.
Use pull delivery: add a worker with one handler, start it as a background process, then create a one-off job for that handler. Confirm through the API that the job's execution reached "completed". Finish by telling me the one command that starts the worker again from a clean shell.
```
3. **Check the result**
The job and its execution appear in the dashboard under Jobs. The agent should also report the execution status it read from `GET /v1/executions?job_id=...`.
## What to expect
[Section titled “What to expect”](#what-to-expect)
* A one-off job runs as soon as the worker claims it, so the whole setup takes a few minutes.
* If the agent creates a recurring schedule instead, an interval schedule first runs one interval after creation (at least 10 minutes on the Free plan) and a cron schedule at its next matching time.
* Push delivery is a second option when the project already serves HTTP. Verifying push signatures needs your signing secret, which an API key cannot read, so you hand the agent that secret as well. See [Push Delivery](/docs/guides/push-delivery).
Note
An agent follows the docs literally. If one gets stuck on a Chronos step, email with the error it hit and the page it was reading.
## Next steps
[Section titled “Next steps”](#next-steps)
[Worker Setup ](/docs/guides/sdk-worker-setup)Prod config: graceful shutdown, multiple handlers, error handling
[Scheduling Patterns ](/docs/guides/scheduling-patterns)Cron and interval patterns for recurring schedules
[REST API ](/docs/reference/rest-api)Full endpoint documentation
# Quick Start
> Create and run your first job in under 5 minutes.
Chronos delivers jobs two ways. Pick the one that fits your setup.
[Pull (Worker) ](/docs/quick-start/pull)Run a worker that fetches jobs from Chronos. Best when you control the compute.
[Push (HTTP) ](/docs/quick-start/push)Chronos calls your HTTP endpoint. Best for serverless or existing servers.
Tip
Not sure? Start with **Pull**. You can always switch later.
Using a coding agent? [Agent Setup](/docs/agents/setup) has a prompt that does the quick start for you.
# Quick Start: Pull (Worker)
> Run a worker that fetches jobs from Chronos. Get your first job running in under 5 minutes.
Your worker long-polls Chronos for jobs, runs your handler, and reports the result. You control the compute.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* Node.js 20 or later
* A Chronos account ([sign up free](https://app.chronos.sh/signup))
* An API key (create one in the [dashboard](https://app.chronos.sh) under Settings → API Keys)
1. **Install the SDK**
* pnpm
```bash
pnpm add @chronos.sh/sdk
```
* npm
```bash
npm install @chronos.sh/sdk
```
* yarn
```bash
yarn add @chronos.sh/sdk
```
2. **Write your handler**
worker.ts
```ts
import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
});
chronos.worker.handle('hello', async (ctx) => {
console.log(`Job ${ctx.jobId} running (attempt ${ctx.attempt})`);
return { ok: true };
});
await chronos.worker.start();
```
The handler name `"hello"` is the routing key. Chronos uses it to match jobs to the right function.
3. **Start the worker**
* pnpm
```bash
CHRONOS_API_KEY=chrns_your_api_key pnpm dlx tsx worker.ts
```
* npm
```bash
CHRONOS_API_KEY=chrns_your_api_key npx tsx worker.ts
```
* yarn
```bash
CHRONOS_API_KEY=chrns_your_api_key yarn dlx tsx worker.ts
```
The worker connects to Chronos and waits for jobs.
4. **Create a job**
In a second terminal, create a one-off job:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "My first job",
"handler": "hello"
}'
```
Your running worker claims the job and runs the handler. You should see:
```text
Job a8f3e2b1-... running (attempt 1)
```
## What just happened
[Section titled “What just happened”](#what-just-happened)
1. You created a one-off job via the API
2. Your worker long-polled Chronos and claimed it
3. The handler ran and returned a result
4. Chronos recorded the execution as `completed`
If the handler had thrown an error, Chronos would have retried up to 3 times with exponential backoff.
## Make it recurring
[Section titled “Make it recurring”](#make-it-recurring)
Create a schedule and your worker picks up jobs automatically:
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Hello every 10 minutes",
"handler": "hello",
"interval": { "value": 10, "unit": "minute" }
}'
```
Your running worker now receives a new job every 10 minutes. To stop it, archive the schedule:
```bash
curl -X POST https://api.chronos.sh/v1/schedules/SCHEDULE_ID/archive \
-H "Authorization: Bearer chrns_your_api_key"
```
Note
The free tier supports intervals of 10 minutes or longer. See [Pricing & Limits](/docs/pricing-limits) for all plan limits.
## Next steps
[Section titled “Next steps”](#next-steps)
[Worker Setup ](/docs/guides/sdk-worker-setup)Prod config: graceful shutdown, multiple handlers, error handling
[Push Delivery ](/docs/guides/push-delivery)Switch to HTTP push delivery
[API Reference ](/docs/reference/rest-api)Full endpoint documentation
# Quick Start: Push (HTTP)
> Receive jobs via HTTP push from Chronos. Get your first job running in under 5 minutes.
Chronos sends HTTP requests to your endpoint when a job is due. No long-lived worker process needed.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* Node.js 20 or later
* A Chronos account ([sign up free](https://app.chronos.sh/signup))
* An API key (create one in the [dashboard](https://app.chronos.sh) under Settings → API Keys)
* A publicly reachable HTTPS endpoint (or a tunnel for local dev)
Tip
For local development, use a tunnel like [ngrok](https://ngrok.com): `ngrok http 3000`. Use the generated HTTPS URL as your push endpoint.
1. **Install Express**
* pnpm
```bash
pnpm add express
```
* npm
```bash
npm install express
```
* yarn
```bash
yarn add express
```
2. **Write your handler**
server.mjs
```js
import express from 'express';
const app = express();
app.use(express.json());
app.post('/hooks/chronos', (req, res) => {
const { handler, payload, job_id, attempt } = req.body;
if (handler !== 'hello') {
console.warn(`Unknown handler: ${handler}`);
return res.sendStatus(400);
}
console.log(`Job ${job_id} running (attempt ${attempt})`);
res.sendStatus(200);
});
app.listen(3000, () => console.log('Listening on :3000'));
```
Return 200 for success, 4xx for permanent failures, 5xx for transient failures you want retried.
3. **Start the server**
```bash
node server.mjs
```
Your server is now listening on port 3000.
4. **Create a job**
In a second terminal, create a one-off job with push delivery:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "My first job",
"handler": "hello",
"delivery": {
"type": "push",
"http": {
"url": "https://your-server.com/hooks/chronos",
"method": "POST"
}
}
}'
```
Chronos sends an HTTP request to your endpoint. You should see:
```text
Job a8f3e2b1-... running (attempt 1)
```
## What just happened
[Section titled “What just happened”](#what-just-happened)
1. You created a one-off job with push delivery
2. Chronos sent an HTTP POST to your endpoint
3. Your handler processed the request and returned 200
4. Chronos recorded the execution as `completed`
If your endpoint had returned 5xx or timed out, Chronos would have retried up to 3 times with exponential backoff.
## Make it recurring
[Section titled “Make it recurring”](#make-it-recurring)
Create a schedule with push delivery and your server receives jobs automatically:
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Hello every 10 minutes",
"handler": "hello",
"interval": { "value": 10, "unit": "minute" },
"delivery": {
"type": "push",
"http": {
"url": "https://your-server.com/hooks/chronos",
"method": "POST"
}
}
}'
```
Your running server now receives a new request every 10 minutes. To stop it, archive the schedule:
```bash
curl -X POST https://api.chronos.sh/v1/schedules/SCHEDULE_ID/archive \
-H "Authorization: Bearer chrns_your_api_key"
```
Note
The free tier supports intervals of 10 minutes or longer. See [Pricing & Limits](/docs/pricing-limits) for all plan limits.
## Next steps
[Section titled “Next steps”](#next-steps)
[Signature Verification ](/docs/guides/signature-verification)Verify that requests actually come from Chronos
[Push Delivery ](/docs/guides/push-delivery)Response rules, custom headers, debugging tips
[API Reference ](/docs/reference/rest-api)Full endpoint documentation
# Delivery Methods
> Push vs pull: how Chronos delivers jobs to your code.
Chronos supports two ways to deliver jobs to your code: push (HTTP) and pull (worker).
Every schedule or job uses exactly one delivery method. Both paths get identical retry guarantees and execution tracking.
## Push vs Pull
[Section titled “Push vs Pull”](#push-vs-pull)
| | Push (HTTP) | Pull (Worker) |
| --------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| How it works | Chronos sends an HTTP request to your endpoint | Your worker long-polls Chronos for jobs |
| You provide | A publicly reachable HTTPS URL | A running process (SDK or custom HTTP client) |
| Delivery config | `{ type: "push", http: { url, method } }` | `{ type: "pull" }` (or omit, the default) |
| Best for | Serverless, existing servers, event-driven architectures | Background workers, long-running processes, environments without public URLs |
Both paths get identical retry guarantees and execution tracking.
## Which to choose
[Section titled “Which to choose”](#which-to-choose)
**Use push if you:**
* Run serverless functions (Vercel, Netlify, Lambda, Cloudflare Workers)
* Have a long-running server that can expose an endpoint
* Want the least infrastructure (no worker process to manage)
**Use pull if you:**
* Are behind a firewall, NAT, or have no public URL
* Run a dedicated background worker process
* Want control over concurrency (one job at a time per worker)
Tip
You can mix delivery methods. Different schedules can use different types. You can also change a schedule’s delivery method after creation via `PATCH`.
## Push delivery flow
[Section titled “Push delivery flow”](#push-delivery-flow)
```text
Chronos Your endpoint
│ │
├── Job is due ────────────────────────▶│
│ POST https://your-app.com/hook │
│ + signature headers │
│ │
│◀──────────────────────── 200 OK ──────┤
│ │
└── Execution: completed │
```
1. Job becomes due at its `run_at` time
2. Chronos makes an HTTP request to your configured URL
3. Your endpoint does the work and returns a status code
4. 2xx = success, 5xx/timeout = retry, 4xx = terminal failure
See [Push Delivery](/docs/guides/push-delivery) for the full setup guide.
## Pull delivery flow
[Section titled “Pull delivery flow”](#pull-delivery-flow)
```text
Your worker Chronos
│ │
├── POST /v1/workers/jobs/claim ───────▶│
│ (long-poll, up to 20s) │
│ │
│◀──────────────────── Job claimed ─────┤
│ │
│ [handler runs] │
│ │
├── POST /v1/workers/executions/:id/result ─▶│
│ { status: "completed" } │
│ │
└── Poll again... │
```
1. Your worker calls the claim endpoint (SDK does this automatically)
2. Chronos holds the connection until a job is available (up to 20 seconds)
3. A job is returned, your handler runs
4. Your worker reports the result back
5. Loop continues
See [Worker Setup](/docs/guides/sdk-worker-setup) for the full setup guide.
## What each side handles
[Section titled “What each side handles”](#what-each-side-handles)
| Responsibility | Push | Pull |
| ------------------------- | --------------------------------------------------- | ------------------------------------- |
| Triggering execution | Chronos (HTTP request) | Your worker (claim request) |
| Running your code | Your server | Your worker process |
| Reporting success/failure | Your HTTP response code | SDK reports automatically |
| Retry scheduling | Chronos (on 5xx/timeout) | Chronos (on handler error or timeout) |
| Timeout detection | Chronos (HTTP timeout) | Chronos (lease expiry sweep) |
| Concurrency | Unlimited (Chronos makes requests as jobs come due) | One job at a time per worker instance |
| Signature verification | You verify `X-Chronos-Signature` | Not applicable (API key auth) |
## Pull job states
[Section titled “Pull job states”](#pull-job-states)
Pull delivery introduces an extra state compared to push:
```text
pending → admitting → queued → ready → running → completed/failed
```
The `ready` state means “available to be claimed.” A job sits in `ready` until a worker picks it up. Push jobs skip `ready` entirely. They go from `queued` directly to `running` when dispatched.
## Timeout handling
[Section titled “Timeout handling”](#timeout-handling)
**Push:** If your endpoint doesn’t respond within the job’s `timeout` (default 30 seconds), Chronos marks the execution as `timeout` and schedules a retry.
**Pull:** The SDK fires `ctx.signal` when the handler’s `timeout` elapses. Your handler should check this signal to cooperatively cancel work. On the server side, Chronos monitors a lease: if no result is reported within `timeout` seconds of claiming, Chronos marks the execution as `timeout` and schedules a retry.
In both cases: if you need your handler to run longer than 30 seconds, increase `timeout` on the schedule or job (up to your plan’s cap; Free is 30 seconds).
# Guarantees
> Exactly-once scheduling, at-least-once delivery, and what they mean.
Chronos provides two guarantees. Understanding what they cover, and what they don’t, is essential to building a correct integration.
## Exactly-once scheduling
[Section titled “Exactly-once scheduling”](#exactly-once-scheduling)
Each schedule occurrence produces at most one job, regardless of how many instances of the Chronos scheduler are running.
**How it works:** When a schedule is due, the job is inserted with a composite idempotency key (`{scheduleId}.{scheduledFor_ms}`) using a database `ON CONFLICT DO NOTHING` clause. Combined with row-level locking (`FOR UPDATE SKIP LOCKED`) during the spawn process, this ensures that even if multiple scheduler instances race to process the same schedule, only one job is created.
**What this means for you:** You don’t need to worry about duplicate jobs being created from a single schedule tick. If your schedule says “every hour,” each hourly occurrence produces at most one job. Never two.
**What this does NOT mean:** This is not exactly-once *execution*. A job may be executed more than once (retries, timeout + late completion, network partitions). That’s covered by the delivery guarantee below. It also doesn’t mean every occurrence is guaranteed to produce a job: see [missed runs](#missed-runs) below.
### Missed runs
[Section titled “Missed runs”](#missed-runs)
If a schedule falls behind by more than one full occurrence (for example during a Chronos outage), Chronos skips the missed occurrences rather than backfilling them. One job is spawned for the oldest missed occurrence, and the schedule fast-forwards to the next occurrence in the future.
```text
Schedule: every 1 hour, due 10:00
Outage: 09:55 to 13:20
Spawned: one job for 10:00 (when the scheduler recovers at 13:20)
Skipped: 11:00, 12:00, 13:00
Next run: 14:00
```
Interval schedules stay on their original grid when they fast-forward; cron schedules stay on their grid too, resuming at the next matching time. If your workload must cover every interval (billing periods, report windows), derive the window from your own state rather than assuming one job per occurrence.
A schedule is **paused automatically** when Chronos can no longer compute its next run from the stored cron expression, interval, or timezone, or when a plan change leaves it over the new plan’s limits. A paused schedule produces no jobs until you fix the cause (its cadence, its timeout, or the active-schedule count) and resume it. See the [schedule lifecycle](/docs/concepts/schedules-jobs-executions#schedule-lifecycle).
## At-least-once delivery
[Section titled “At-least-once delivery”](#at-least-once-delivery)
Every job will be executed at least once. If delivery fails, Chronos retries with exponential backoff until the job succeeds or exhausts its `max_retries`.
**How it works:**
* **Push delivery:** Chronos makes an HTTP request. If your endpoint returns 5xx, times out, or is unreachable, the execution is marked failed and a retry is scheduled.
* **Pull delivery:** Your worker claims a job and runs the handler. If the handler throws, the SDK reports failure. If the worker crashes without reporting (lease expires), a background sweep detects the timeout and schedules a retry.
**Retry formula:** Exponential backoff with jitter, capped at 1 hour:
```text
baseDelay = min(1000ms × 2^(attempt-1), 1 hour)
jitter = random(0, baseDelay)
delay = min(baseDelay + jitter, 1 hour)
```
| Attempt | Base delay | Actual delay range |
| ------- | ---------- | ------------------ |
| 1 | 1s | 1-2s |
| 2 | 2s | 2-4s |
| 3 | 4s | 4-8s |
| 4 | 8s | 8-16s |
| 5 | 16s | 16-32s |
Default `max_retries` is 3. Set to 0 to disable retries entirely.
**What this means for you:** If your handler is reachable, it will run. Transient failures (network blips, deploy windows, temporary overload) are handled automatically.
**What this does NOT mean:** Chronos doesn’t guarantee your handler succeeds. It only guarantees invocation. If your handler has a bug that always throws, it’ll fail on every attempt and exhaust retries.
## Your responsibility: idempotency
[Section titled “Your responsibility: idempotency”](#your-responsibility-idempotency)
Because delivery is at-least-once, your handler may run more than once for the same job. Scenarios where this happens:
1. **Timeout + late completion:** Your handler finishes but the result report fails or arrives after the lease expired. Chronos doesn’t know it succeeded, so it retries.
2. **Network partition:** Result reported successfully but the response is lost before the SDK receives confirmation. SDK retries the report (handled internally), but the handler itself won’t re-run in this case.
3. **Push: ambiguous timeout:** Your endpoint processed the job but responded too slowly. Chronos timed out and retried, but your first execution already had side effects.
**How to handle this:**
Use `job_id` as an idempotency key in downstream systems. It stays the same across retries, so a payment that succeeded on a timed-out attempt won’t be charged again.
```ts
chronos.worker.handle('charge-customer', async (ctx) => {
await stripe.charges.create({
amount: ctx.payload.amount,
currency: 'usd',
customer: ctx.payload.customerId,
idempotency_key: ctx.jobId,
});
});
```
For a complete guide on building idempotent handlers, see [Idempotent Handlers](/docs/guides/idempotent-handlers).
## What Chronos does NOT do
[Section titled “What Chronos does NOT do”](#what-chronos-does-not-do)
| Common expectation | Reality |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Run your code | Chronos *triggers* your code. You run it on your own infrastructure. |
| Replace your job queue | Chronos schedules and delivers. If you need complex queuing (priority, concurrency limits, dead-letter), run your own queue downstream. |
| Guarantee exactly-once execution | At-least-once delivery means your handler must be safe to run twice. |
| Store your data | Chronos stores payloads to deliver jobs and show execution history. Send identifiers, not records. |
| Force-kill your handler | The SDK fires `ctx.signal` when `timeout` elapses for cooperative cancellation. It does not forcibly terminate your handler. Server-side lease management handles retry decisions. |
# Schedules, Jobs & Executions
> The core mental model: how schedules spawn jobs and track executions.
Chronos has three primitives. Understanding how they relate is the key to using the API effectively.
## The mental model
[Section titled “The mental model”](#the-mental-model)
```text
Schedule ──spawns──▶ Job ──creates──▶ Execution
(pattern) (work) (attempt)
```
* **Schedule:** A recurring pattern that produces jobs on a cadence. Think of it as a standing instruction: “run this every hour” or “run this at 9am on weekdays.”
* **Job:** A unit of work to execute. Created by a schedule (recurring) or directly via the API (one-off).
* **Execution:** A single attempt to run a job. If a job fails and has retries remaining, a new execution is created for the next attempt.
One schedule produces many jobs. One job may have multiple executions (if retries are needed).
## Schedules
[Section titled “Schedules”](#schedules)
A schedule defines *when* and *how* work should happen.
### Fields that matter
[Section titled “Fields that matter”](#fields-that-matter)
| Field | Purpose |
| -------------------- | ----------------------------------------------------------------- |
| `cron` or `interval` | When to fire (exactly one required) |
| `handler` | Routing key. Matches your worker’s registered handler name |
| `delivery` | How to deliver: `pull` (worker) or `push` (HTTP) |
| `payload` | Data passed to every job this schedule creates |
| `timeout` | Seconds before an execution is considered timed out (default: 30) |
| `max_retries` | How many times to retry on failure (default: 3) |
| `runs` | Stop after this many jobs (optional) |
| `stop_at` | Stop after this time (optional) |
### Schedule lifecycle
[Section titled “Schedule lifecycle”](#schedule-lifecycle)
```text
active ◀─────▶ paused (POST /v1/schedules/:id/pause and /resume,
│ or automatic when the next run can't be computed
│ or a plan change leaves the schedule over its limits)
│
├──────────▶ completed (runs limit reached or stop_at passed)
│
└──────────▶ archived (manual, via POST /v1/schedules/:id/archive;
also reachable from paused and completed)
```
* **active:** Producing jobs on schedule.
* **paused:** Not producing jobs. Entered manually, or automatically in two cases: Chronos can’t compute the next run from the stored cron, interval, or timezone, or a plan change leaves the schedule over the new plan’s limits (see [downgrades](/docs/pricing-limits#faq)). Resume recomputes `next_run_at` from the current time: cron schedules keep their alignment, interval schedules re-anchor to the resume time (an hourly schedule resumed at 14:37 runs next at 15:37). Missed occurrences are not backfilled (see [missed runs](/docs/concepts/guarantees#missed-runs)). If the cadence is still invalid, resume fails with `schedule_cadence_invalid`. If the schedule still exceeds a plan limit, resume fails with `plan_limit_exceeded`.
* **completed:** Finished naturally. Reached its `runs` limit or passed `stop_at`.
* **archived:** Stopped manually, from any other status. All pending jobs for this schedule are cancelled. Archiving is permanent.
### Interval anchoring
[Section titled “Interval anchoring”](#interval-anchoring)
Interval schedules compute the next run from the *scheduled* time, not from wall-clock:
```text
Schedule: every 1 hour
First run scheduled for: 10:00:00
Job spawned at (actual): 10:00:03 (3s late due to system load)
Next run computed from: 10:00:00 (not 10:00:03)
Next run scheduled for: 11:00:00
```
This prevents drift. Jobs don’t slide later over time.
## Jobs
[Section titled “Jobs”](#jobs)
A job is a discrete unit of work. It’s either spawned by a schedule or created directly as a one-off.
### Job lifecycle
[Section titled “Job lifecycle”](#job-lifecycle)
```text
pending → admitting → queued → ready → running → completed
│ │
│ ├──▶ failed (retries exhausted)
│ │
│ └──▶ retrying → (back to pending)
│
└──▶ cancelled (schedule archived)
```
What each status means:
| Status | Meaning |
| ----------- | ------------------------------------------------------------------------------------------ |
| `pending` | Created, waiting to be processed by the internal queue |
| `admitting` | Being picked up by the internal dispatcher |
| `queued` | In the dispatch queue, waiting for its `run_at` time |
| `ready` | Due for execution and available to be claimed by a pull worker. Push jobs skip this state. |
| `running` | Actively being executed |
| `completed` | Finished successfully |
| `failed` | Failed and retries exhausted |
| `retrying` | Failed but has retries remaining. Waiting for next attempt |
| `cancelled` | Cancelled because its parent schedule was archived |
From your perspective as a developer, the statuses you’ll interact with are: `pending` (just created), `running` (your handler has it), `completed`, `failed`, and `retrying`.
### Recurring vs one-off
[Section titled “Recurring vs one-off”](#recurring-vs-one-off)
| | Recurring job | One-off job |
| ------------- | -------------------------------------------------------------- | -------------------------------------- |
| Created by | A schedule (automatically) | You (via `POST /v1/jobs`) |
| `origin` | `schedule` | `api` |
| `schedule_id` | UUID of parent schedule (`null` after the schedule is deleted) | `null` |
| Timing | Determined by schedule’s cron/interval | `scheduled_for`, `delay`, or immediate |
| `handler` | Inherited from schedule | Set on the job directly |
### The `attempt_count` field
[Section titled “The attempt\_count field”](#the-attempt_count-field)
Each time a job produces a new execution (first attempt or retry), `attempt_count` increments. You don’t need to track this yourself. `ctx.attempt` in your handler tells you which attempt this is.
## Executions
[Section titled “Executions”](#executions)
An execution is a single attempt to run a job. It’s the lowest-level primitive: the record of what actually happened.
### Execution lifecycle
[Section titled “Execution lifecycle”](#execution-lifecycle)
```text
pending → running → completed
→ failed
→ timeout
```
| Status | Meaning |
| ----------- | ------------------------------------------------------------------- |
| `pending` | Created, not yet started |
| `running` | In progress. Your handler is executing or push request is in-flight |
| `completed` | Handler returned successfully (or endpoint returned 2xx) |
| `failed` | Handler threw an error (or endpoint returned 4xx/5xx) |
| `timeout` | Execution exceeded the job’s `timeout` without reporting a result |
### Execution fields
[Section titled “Execution fields”](#execution-fields)
| Field | Description |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| `trigger` | Why this execution exists: `system` (first attempt), `system_retry` (automatic retry), `manual` (reserved) |
| `duration_ms` | How long the execution took (null if not completed) |
| `result` | Return value from your handler (if completed with data) |
| `error` | Error message (if failed, truncated to 4KB) |
| `response_code` | HTTP status code (push delivery only) |
### Retry creates a new execution
[Section titled “Retry creates a new execution”](#retry-creates-a-new-execution)
When a job fails and retries remain:
1. The current execution is marked `failed`
2. The job transitions to `retrying`
3. After the backoff delay, a new execution is created
4. The new execution has `trigger: "system_retry"`
Each retry is a distinct execution with its own `execution_id`. Use `job_id` for side effects that must happen once per job, and use `execution_id` for per-attempt logs, metrics, and correlation.
## How they connect
[Section titled “How they connect”](#how-they-connect)
```text
Schedule "Hourly sync" (cron: 0 * * * *)
│
├── Job (10:00) ── Execution #1 ✓ completed
│
├── Job (11:00) ── Execution #1 ✗ failed
│ └─ Execution #2 ✓ completed (retry succeeded)
│
└── Job (12:00) ── Execution #1 ✓ completed
One-off Job "Send welcome email"
│
└── Execution #1 ✓ completed
```
Each execution attempt counts toward your plan’s monthly execution limit.
# Graceful Shutdown
> Shut down workers without dropping in-flight jobs.
## The problem
[Section titled “The problem”](#the-problem)
When your process receives SIGTERM (deploy, restart, scale-down), you need to:
1. Stop claiming new jobs
2. Let the current handler finish
3. Report the result back to Chronos
4. Then exit
If you kill the process mid-handler, the execution’s lease expires on the server and Chronos schedules a retry, wasting time and potentially causing side effects if the handler wasn’t idempotent.
## Why the SDK doesn’t handle signals
[Section titled “Why the SDK doesn’t handle signals”](#why-the-sdk-doesnt-handle-signals)
The SDK doesn’t install `SIGTERM`/`SIGINT` handlers because your application owns its process lifecycle. You might:
* Run multiple services in one process
* Need cleanup beyond the Chronos worker (close DB connections, flush logs)
* Use a framework that manages shutdown itself
Signal handling is your responsibility.
## Basic shutdown
[Section titled “Basic shutdown”](#basic-shutdown)
worker.ts
```ts
import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
});
chronos.worker
.handle('sync-tenant', syncTenantHandler)
.handle('send-report', sendReportHandler);
const shutdown = async () => {
await chronos.worker.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await chronos.worker.start();
```
## What `stop()` does
[Section titled “What stop() does”](#what-stop-does)
1. **Aborts the long-poll immediately:** the current HTTP request to `/v1/workers/jobs/claim` is cancelled, so no new job is claimed
2. **Waits for in-flight work:** if a handler is mid-execution, `stop()` waits until it finishes (or hits its `timeout`) and reports its result. A handler that exceeds its `timeout` is abandoned: `ctx.signal` fires, the SDK reports `timeout`, and it does not wait for the handler to return
3. **Resolves:** the promise returned by `start()` resolves, your shutdown handler continues
`stop()` never throws synchronously and always resolves: immediately with no work in flight, otherwise once the in-flight handler’s report has succeeded or been rejected. A 401 before `stop()` surfaces on the promise returned by `start()` as `ChronosAuthenticationError`. A 401 on the final report after `stop()` is logged with the execution id, and both promises resolve. The shutdown handlers above need no error handling.
## Second-signal escape hatch
[Section titled “Second-signal escape hatch”](#second-signal-escape-hatch)
A handler that blocks the event loop (a synchronous infinite loop) never reaches its `timeout`, and a handler with a long `timeout` can keep the first SIGTERM waiting for minutes. Handle a second signal as a force-exit:
```ts
let stopping = false;
const shutdown = async () => {
if (stopping) {
process.exit(1);
}
stopping = true;
await chronos.worker.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```
First signal: graceful. Second signal: immediate exit. The abandoned execution will time out on the server and be retried.
## Worst-case shutdown time
[Section titled “Worst-case shutdown time”](#worst-case-shutdown-time)
The longest a graceful shutdown can take:
```text
remaining handler time + result report attempts
```
Once `stop()` is called, retry waits are skipped, so what remains is bounded by the handler’s `timeout` and by `timeoutMs` (default 30000ms) on each of up to 3 result-report attempts. Worst case at the defaults:
```text
handler timeout + (3 × 30000ms) = handler timeout + 90 seconds
```
The 90 seconds is the pathological case (the API unreachable and every request stalling to its deadline). In normal operation the result report returns in well under a second. Set your process manager’s grace period to at least your longest handler `timeout` plus `3 × timeoutMs`; lower `timeoutMs` if that budget is too long for your platform.
## With other cleanup
[Section titled “With other cleanup”](#with-other-cleanup)
If your process has more to clean up beyond the worker:
```ts
const shutdown = async () => {
// Stop accepting new work
await chronos.worker.stop();
// Clean up other resources
await db.disconnect();
await redis.quit();
await logger.flush();
process.exit(0);
};
```
Tip
Order matters. Stop the worker first so no new work arrives while you’re tearing down dependencies.
## Docker / container deployments
[Section titled “Docker / container deployments”](#docker--container-deployments)
Containers receive SIGTERM on shutdown. Ensure your `STOPSIGNAL` is SIGTERM (the default) and your grace period covers the budget above:
Dockerfile
```dockerfile
# Default: STOPSIGNAL SIGTERM (no change needed)
STOPSIGNAL SIGTERM
```
For Docker Compose or Swarm, set the stop grace period:
docker-compose.yml
```yaml
services:
worker:
stop_grace_period: 120s # 30s handler timeout (Free cap) + 3 × 30s timeoutMs
```
For other orchestrators (ECS, systemd, Caprover), configure the equivalent grace period to the same budget.
## Testing shutdown
[Section titled “Testing shutdown”](#testing-shutdown)
Verify graceful shutdown works by sending SIGTERM while a handler is running:
```bash
# Terminal 1: start your worker
CHRONOS_API_KEY=chrns_... npx tsx worker.ts
# Terminal 2: send SIGTERM
kill -TERM $(pgrep -f worker.ts)
```
Watch for the handler to complete and the process to exit cleanly. If it exits immediately (code 137/143 without completing), your signal wiring isn’t working.
# Idempotent Handlers
> Design job handlers that are safe to run more than once.
## Why this matters
[Section titled “Why this matters”](#why-this-matters)
Chronos guarantees at-least-once delivery. That means your handler may run more than once for the same job:
* A timeout occurs but your handler actually completed (late result report)
* A network partition prevents the result from reaching Chronos
* Push delivery: your endpoint processed the request but responded too slowly
If your handler charges a credit card, sends an email, or creates a record, running it twice without protection produces duplicates.
The idempotency test
If this handler runs twice with the same input, does it produce the same result with no extra side effects? If yes, it’s idempotent.
## The keys available to you
[Section titled “The keys available to you”](#the-keys-available-to-you)
| Field | Scope | Use when |
| ----------------- | ------------------- | ------------------------------------------------------------------------------ |
| `ctx.jobId` | Same across retries | Side effects that must happen once per job (payments, emails, record creation) |
| `ctx.executionId` | Unique per attempt | Per-attempt tracking (logging, metrics, attempt-specific records) |
Use `jobId` by default. It stays the same across retries, so if a retry fires after a side effect already succeeded, downstream systems detect the duplicate and skip it.
Use `executionId` only when each retry attempt should be treated as distinct (per-attempt logs, metrics, or audit records).
## Patterns
[Section titled “Patterns”](#patterns)
* Downstream key
Pass the job ID to APIs that support idempotency:
```ts
chronos.worker.handle<{ customerId: string; amount: number }>('charge', async (ctx) => {
await stripe.charges.create({
amount: ctx.payload.amount,
currency: 'usd',
customer: ctx.payload.customerId,
idempotency_key: ctx.jobId,
});
return { charged: true };
});
```
Stripe (and many other payment APIs) will reject duplicate charges with the same idempotency key. Because `jobId` stays the same across retries, a timed-out attempt that actually succeeded won’t cause a second charge.
* Database dedup
Use a unique constraint to prevent duplicate writes:
```ts
chronos.worker.handle<{ tenantId: string }>('sync-tenant', async (ctx) => {
const [row, created] = await db.syncRuns.upsert({
where: { jobId: ctx.jobId },
create: {
jobId: ctx.jobId,
tenantId: ctx.payload.tenantId,
status: 'running',
},
update: {},
});
if (!created && row.status === 'completed') {
return { synced: row.recordCount, deduplicated: true };
}
const count = await performSync(ctx.payload.tenantId);
await db.syncRuns.update({
where: { jobId: ctx.jobId },
data: { status: 'completed', recordCount: count },
});
return { synced: count };
});
```
* Cache (Redis)
Use Redis for lightweight dedup when you don’t need a persistent record:
```ts
chronos.worker.handle('send-notification', async (ctx) => {
const key = `chronos:processed:${ctx.jobId}`;
const alreadyProcessed = await redis.set(key, '1', 'NX', 'EX', 86400);
if (!alreadyProcessed) {
return { skipped: true, reason: 'already_processed' };
}
await sendNotification(ctx.payload);
return { sent: true };
});
```
`NX` ensures the key is only set if it doesn’t exist. If it already exists, another execution already processed this. Skip.
* Check-then-act
For state machine transitions, check the current state before acting:
```ts
chronos.worker.handle<{ trialId: string }>('expire-trial', async (ctx) => {
const trial = await db.trials.findById(ctx.payload.trialId);
if (trial.status === 'expired') {
return { alreadyExpired: true };
}
const updated = await db.trials.updateWhere(
{ id: ctx.payload.trialId, status: 'active' },
{ status: 'expired', expiredAt: new Date() },
);
if (updated === 0) {
return { raceCondition: true, currentStatus: trial.status };
}
return { expired: true };
});
```
The `WHERE status = 'active'` clause prevents double-expiration even under concurrent execution.
## When you don’t need idempotency
[Section titled “When you don’t need idempotency”](#when-you-dont-need-idempotency)
Not every handler needs dedup. If the operation is naturally idempotent, you’re already safe:
* **Read-only operations:** fetching data, generating reports from current state
* **Full-state overwrites:** `UPDATE users SET last_seen = now() WHERE id = ?`
* **Upserts:** `INSERT ... ON CONFLICT DO UPDATE` with the same data
* **Deleting by ID:** deleting something that’s already gone is a no-op
The rule: if running twice produces the same end state with no extra side effects, you’re fine without explicit dedup.
# One-Off Jobs
> Schedule delayed one-time jobs without a recurring pattern.
## When to use one-off jobs
[Section titled “When to use one-off jobs”](#when-to-use-one-off-jobs)
Use a one-off job when you need something to happen once at a specific time, not on a recurring cadence.
* **Delayed action:** send a follow-up email in 2 hours
* **Scheduled event:** expire a trial on a specific date
* **Deferred work:** process an upload after a 30-second buffer
* **Immediate background task:** offload work from the request path
If the task recurs, use a [schedule](/docs/concepts/schedules-jobs-executions) instead.
## Timing options
[Section titled “Timing options”](#timing-options)
* Immediate
Omit both `delay` and `scheduled_for`. The job runs as soon as possible:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Process upload",
"handler": "process-upload",
"payload": { "fileId": "file_abc123" }
}'
```
* Relative delay
Use `delay` to run after a duration from now:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Send follow-up",
"handler": "send-email",
"delay": { "value": 2, "unit": "hour" },
"payload": { "userId": "usr_123", "template": "follow-up" }
}'
```
Available units: `second`, `minute`, `hour`, `day`, `week`
* Absolute time
Use `scheduled_for` to run at a specific moment:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Expire trial",
"handler": "expire-trial",
"scheduled_for": "2027-02-15T00:00:00Z",
"payload": { "accountId": "acc_456" }
}'
```
The time must be in the future (ISO 8601 format).
Note
You can provide at most one of `delay` or `scheduled_for`. Providing both returns a validation error.
## Idempotency keys
[Section titled “Idempotency keys”](#idempotency-keys)
Prevent duplicate jobs with `idempotency_key`:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Charge invoice inv_789",
"handler": "charge-invoice",
"delay": { "value": 1, "unit": "hour" },
"idempotency_key": "charge-inv_789",
"payload": { "invoiceId": "inv_789" }
}'
```
If a job with the same `idempotency_key` already exists on your account, the API returns `409` with code `idempotency_key_conflict` instead of creating a duplicate.
Use this when the trigger for job creation might fire multiple times (webhook retries, user double-clicks, distributed systems with at-least-once delivery).
The key is scoped to your account and persists for the lifetime of the job. Max 255 characters.
## Push delivery for one-off jobs
[Section titled “Push delivery for one-off jobs”](#push-delivery-for-one-off-jobs)
One-off jobs support the same delivery configuration as schedules:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Send webhook notification",
"handler": "notify",
"delay": { "value": 5, "unit": "minute" },
"delivery": {
"type": "push",
"http": {
"url": "https://your-app.com/hooks/chronos",
"method": "POST"
}
},
"payload": { "event": "subscription.created", "data": { "id": "sub_123" } }
}'
```
If `delivery` is omitted, pull is the default. Your worker picks it up.
## Configuring retries and timeout
[Section titled “Configuring retries and timeout”](#configuring-retries-and-timeout)
Override defaults per job:
```json
{
"name": "Critical charge",
"handler": "charge-invoice",
"delay": { "value": 1, "unit": "hour" },
"timeout": 60,
"max_retries": 5,
"payload": { "invoiceId": "inv_789" }
}
```
| Field | Default | Description |
| ------------- | ------- | ------------------------------------------------------ |
| `timeout` | `30` | Seconds before execution is considered timed out |
| `max_retries` | `3` | Number of retry attempts on failure. `0` = no retries. |
## Querying one-off jobs
[Section titled “Querying one-off jobs”](#querying-one-off-jobs)
To list only one-off jobs, filter by `origin=api`:
```bash
curl "https://api.chronos.sh/v1/jobs?origin=api" \
-H "Authorization: Bearer chrns_your_api_key"
```
## Billing
[Section titled “Billing”](#billing)
Tip
One-off jobs are unlimited on every tier. Only *executions* (attempts) count toward your monthly limit, not job creation.
# Push Delivery
> Set up HTTP push delivery so Chronos calls your HTTP endpoint.
## When to use push delivery
[Section titled “When to use push delivery”](#when-to-use-push-delivery)
Use push if you already have a server listening or want jobs delivered to serverless functions. No long-lived worker process needed. Chronos makes the HTTP request to you.
If you’d rather pull jobs from your own process, see [Worker Setup](/docs/guides/sdk-worker-setup).
## Create a schedule with push delivery
[Section titled “Create a schedule with push delivery”](#create-a-schedule-with-push-delivery)
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Daily report",
"handler": "generate-report",
"cron": "0 9 * * *",
"delivery": {
"type": "push",
"http": {
"url": "https://your-app.com/hooks/chronos",
"method": "POST"
}
},
"payload": { "format": "pdf" }
}'
```
The `delivery.http` object supports:
| Field | Type | Required | Description |
| --------- | ------------------------ | -------- | --------------------------------------------------------------------- |
| `url` | `string` | Yes | Your endpoint. Must be HTTPS in production. No private/localhost IPs. |
| `method` | `string` | Yes | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE` |
| `headers` | `Record` | No | Custom headers sent with every delivery |
Custom headers example:
```json
"http": {
"url": "https://your-app.com/hooks/chronos",
"method": "POST",
"headers": {
"X-App-Secret": "your-shared-secret"
}
}
```
## What Chronos sends
[Section titled “What Chronos sends”](#what-chronos-sends)
When a job is due, Chronos makes an HTTP request to your endpoint.
### Request body
[Section titled “Request body”](#request-body)
```json
{
"job_id": "a1b2c3d4-...",
"handler": "generate-report",
"execution_id": "e5f6g7h8-...",
"scheduled_for": "2026-01-15T09:00:00.000Z",
"attempt": 1,
"payload": { "format": "pdf" }
}
```
| Field | Type | Description |
| --------------- | ---------------- | -------------------------------------------------------------------- |
| `job_id` | `string` | Stable job identifier. Same across retries. |
| `handler` | `string \| null` | The handler name from your schedule. `null` if not set. |
| `execution_id` | `string` | Unique to this attempt. Use `job_id` for idempotency across retries. |
| `scheduled_for` | `string` | ISO 8601 timestamp of when the job was scheduled. |
| `attempt` | `number` | Attempt number. `1` on first try, increments on retries. |
| `payload` | `object \| null` | The payload from your schedule or job. |
### Headers
[Section titled “Headers”](#headers)
Every delivery includes these system headers:
| Header | Value | Description |
| ----------------------- | ------------------ | --------------------------------------- |
| `Content-Type` | `application/json` | Always JSON |
| `User-Agent` | `chronos.sh/1.0` | Identifies Chronos |
| `X-Chronos-Signature` | `sha256=` | HMAC-SHA256 signature for verification |
| `X-Chronos-Delivery-Id` | `` | Same as `execution_id` in the body |
| `X-Chronos-Timestamp` | `` | Request timestamp (seconds since epoch) |
Your custom headers (from `delivery.http.headers`) are included but system headers take precedence on conflict.
Chronos does not follow redirects.
## Handle the delivery
[Section titled “Handle the delivery”](#handle-the-delivery)
A minimal Express handler:
server.ts
```ts
import express from 'express';
const app = express();
app.use(express.json());
app.post('/hooks/chronos', async (req, res) => {
const { handler, payload } = req.body;
switch (handler) {
case 'generate-report':
await generateReport(payload.format);
break;
case 'sync-tenant':
await syncTenant(payload.tenantId);
break;
default:
console.warn(`Unknown handler: ${handler}`);
return res.sendStatus(400);
}
res.sendStatus(200);
});
app.listen(3000);
```
Route by the `handler` field to dispatch different job types to a single endpoint. Returning 400 for unknown handlers prevents Chronos from recording work as completed when nothing ran. For handlers you expect to deploy soon, return 503 instead so Chronos retries.
## Response rules
[Section titled “Response rules”](#response-rules)
Your HTTP response tells Chronos what happened:
| Your response | Chronos records | Retries? |
| ----------------------------------------------- | --------------- | ---------------------------- |
| 2xx (any success) | `completed` | No |
| 4xx (client error) | `failed` | **No:** terminal |
| 5xx (server error) | `failed` | **Yes:** exponential backoff |
| Timeout (no response within limit) | `timeout` | **Yes** |
| Network error (connection refused, DNS failure) | `failed` | **Yes** |
Caution
Return 5xx for transient failures you want retried. Return 4xx for permanent failures (bad payload, invalid state). **4xx is terminal, Chronos won’t retry.** The response body is ignored. Only the status code matters.
## Verify the signature
[Section titled “Verify the signature”](#verify-the-signature)
Every delivery is signed with your account’s signing secret (found in the dashboard under Settings → Signing Key). Verify the `X-Chronos-Signature` header to confirm requests come from Chronos and haven’t been tampered with.
The SDK provides a `Webhook` class that handles verification:
```ts
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
// In your request handler (rawBody = the exact bytes received):
try {
const payload = await wh.verify(rawBody, req.headers);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
return res.sendStatus(401);
}
}
```
See [Signature Verification](/docs/guides/signature-verification) for how to capture the raw body in Express, key rotation, and replay protection options.
Caution
Pass the raw request body (exact bytes received) to `verify()`, not a re-serialized version. JSON key ordering must match exactly.
## One-off jobs with push delivery
[Section titled “One-off jobs with push delivery”](#one-off-jobs-with-push-delivery)
The same `delivery` config works on one-off jobs:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Send welcome email",
"handler": "send-email",
"delay": { "value": 30, "unit": "minute" },
"delivery": {
"type": "push",
"http": {
"url": "https://your-app.com/hooks/chronos",
"method": "POST"
}
},
"payload": { "userId": "usr_abc123", "template": "welcome" }
}'
```
## Debugging tips
[Section titled “Debugging tips”](#debugging-tips)
* **Check your response time.** Default timeout is 30 seconds. If your handler takes longer, increase `timeout` on the schedule/job (up to your plan’s cap) or optimize the handler.
* **4xx is terminal.** If you accidentally return 401/403 (e.g., middleware rejecting the request), Chronos won’t retry. Return 503 while you fix auth issues.
* **Correlate with `X-Chronos-Delivery-Id`.** This matches `execution_id`. Use it to find the delivery in your logs and in the Chronos dashboard.
* **`attempt` tells you which retry you’re on.** Use it to adjust behavior on later attempts (e.g., skip optional steps, use a simpler fallback).
# Retries & Error Handling
> How Chronos retries failed jobs with exponential backoff.
## Defaults
[Section titled “Defaults”](#defaults)
Every schedule and job has two retry-related fields:
| Field | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------- |
| `max_retries` | `3` | Number of retry attempts after first failure. `0` disables retries. |
| `timeout` | `30` | Seconds before an execution is considered timed out. |
Set these per schedule or per job at creation time, or update them on an existing schedule via `PATCH`.
## What triggers a retry
[Section titled “What triggers a retry”](#what-triggers-a-retry)
| Event | Retries? | Why |
| --------------------------------------------- | -------- | --------------------------------------- |
| Push: endpoint returns 5xx | Yes | Server error, likely transient |
| Push: endpoint times out | Yes | May be temporary overload |
| Push: network error (DNS, connection refused) | Yes | Infrastructure issue, likely transient |
| Push: endpoint returns 4xx | **No** | Client error. Won’t fix itself on retry |
| Pull: handler throws an error | Yes | Reported as failure, retry scheduled |
| Pull: lease expires (no result reported) | Yes | Worker may have crashed |
| Retries exhausted | **No** | Job transitions to `failed` permanently |
## Backoff formula
[Section titled “Backoff formula”](#backoff-formula)
Retries use exponential backoff with jitter, capped at 1 hour:
```text
baseDelay = min(1000ms × 2^(attempt - 1), 3,600,000ms)
jitter = random(0, baseDelay)
delay = min(baseDelay + jitter, 3,600,000ms)
```
| Attempt | Base delay | Actual range |
| ------- | ---------- | ----------------------- |
| 1 | 1s | 1-2s |
| 2 | 2s | 2-4s |
| 3 | 4s | 4-8s |
| 4 | 8s | 8-16s |
| 5 | 16s | 16-32s |
| 10 | \~17 min | 17-34 min |
| 13+ | 1 hour | exactly 1 hour (capped) |
The jitter prevents thundering-herd when many jobs fail simultaneously.
## The retry flow
[Section titled “The retry flow”](#the-retry-flow)
```text
Execution fails
│
├── Retries remaining? ──▶ Yes: schedule new execution after backoff delay
│ Job status → retrying
│ New execution created with trigger: system_retry
│
└── Retries exhausted? ──▶ Job status → failed (terminal)
```
Each retry creates a new execution. `ctx.attempt` tells your handler which attempt this is (1-indexed).
## Configuring retries
[Section titled “Configuring retries”](#configuring-retries)
### Disable retries entirely
[Section titled “Disable retries entirely”](#disable-retries-entirely)
```json
{
"name": "Fire-and-forget notification",
"handler": "notify",
"cron": "0 * * * *",
"max_retries": 0
}
```
### Increase retries for critical work
[Section titled “Increase retries for critical work”](#increase-retries-for-critical-work)
```json
{
"name": "Monthly billing",
"handler": "charge-customer",
"cron": "0 0 1 * *",
"max_retries": 8,
"timeout": 60
}
```
With 8 retries, the final attempt happens roughly 4–8 hours after the first failure (due to exponential backoff).
### Per-job override
[Section titled “Per-job override”](#per-job-override)
One-off jobs can have different retry settings than their schedule pattern:
```bash
curl -X POST https://api.chronos.sh/v1/jobs \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Critical charge",
"handler": "charge-customer",
"max_retries": 10,
"timeout": 120,
"payload": { "invoiceId": "inv_123" }
}'
```
## Timeout behavior
[Section titled “Timeout behavior”](#timeout-behavior)
### Push delivery
[Section titled “Push delivery”](#push-delivery)
If your endpoint doesn’t respond within `timeout` seconds, Chronos aborts the request and marks the execution as `timeout`. A retry is scheduled if attempts remain.
### Pull delivery
[Section titled “Pull delivery”](#pull-delivery)
Timeouts are enforced twice: cooperatively in the SDK, and authoritatively on the server.
In the SDK, the handler races against `timeout`. When it elapses, `ctx.signal` aborts, the SDK reports the execution as `timeout`, and it moves on to the next job. The handler itself is not killed; it keeps running until it honors the signal or returns on its own. Pass `ctx.signal` to `fetch`, database clients, and anything else that accepts an `AbortSignal` so a timed-out handler actually stops doing work.
On the server, Chronos tracks a lease independently of the SDK:
1. When a job is claimed, a lease is set: `lease_expires_at = now + timeout`
2. When `timeout` elapses without a result, the execution is marked `timeout`
3. A retry is scheduled if attempts remain
Enforcement is near-instant: a delayed job fires at `timeout`, with a sweep every 3 minutes as a fallback.
The lease covers the cases the SDK can’t: a crashed worker, or a result report that never arrives.
If your handler routinely takes longer than 30 seconds, increase `timeout` (up to your plan’s cap; Free is 30 seconds) so neither the SDK race nor the lease cuts it short.
## Handling errors in SDK handlers
[Section titled “Handling errors in SDK handlers”](#handling-errors-in-sdk-handlers)
Your handler’s behavior determines the execution outcome:
```ts
chronos.worker.handle('process-payment', async (ctx) => {
// Throw to trigger a retry (if attempts remain)
const result = await chargeCustomer(ctx.payload.customerId);
if (!result.success) {
throw new Error(`Charge failed: ${result.error}`);
}
// Return to mark as completed
return { chargeId: result.id };
});
```
* **Return a value** (or void) → execution `completed`
* **Throw an error** → execution `failed`, error message captured (truncated to 4KB), retry scheduled if attempts remain
### Distinguish retryable vs terminal failures
[Section titled “Distinguish retryable vs terminal failures”](#distinguish-retryable-vs-terminal-failures)
If you know a failure is permanent (bad data, invalid state), you might want to avoid wasting retries. Since Chronos always retries on handler failure (until exhausted), design your handler to handle terminal cases gracefully:
```ts
chronos.worker.handle('send-email', async (ctx) => {
const user = await db.users.findById(ctx.payload.userId);
// Terminal: user deleted — no point retrying
if (!user) {
console.warn(`User ${ctx.payload.userId} not found, skipping`);
return { skipped: true, reason: 'user_not_found' };
}
// This might throw on transient network issues → retry is appropriate
await emailService.send(user.email, ctx.payload.template);
return { sent: true };
});
```
Tip
Return successfully for terminal cases (preventing retries), throw for transient failures.
## SDK error classes
[Section titled “SDK error classes”](#sdk-error-classes)
When building around the SDK, these error types help you handle different failure modes:
| Error | When | Your action |
| ---------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `ChronosConfigError` | Invalid options at construction | Fix your config. Thrown at startup |
| `ChronosApiError` | API returned an error (non-2xx or `success: false`) | Check `.status` and `.code` |
| `ChronosAuthenticationError` | API returned 401: key missing, unknown or revoked (subclass of `ChronosApiError`) | Rotate the key. The worker stops and `start()` rejects with this error |
| `ChronosRateLimitError` | API returned 429 (subclass of `ChronosApiError`) | Check `.retryAfterSeconds`. Worker handles this automatically |
| `ChronosNetworkError` | Transport failed (DNS, TCP, TLS, connection dropped) | Transient. SDK retries poll automatically |
| `ChronosTimeoutError` | Request exceeded its transport timeout (subclass of `ChronosNetworkError`) | Transient. Raise `timeoutMs` if it recurs on a healthy network |
| `ChronosHandlerError` | Your handler threw | Logged internally, failure reported to API |
The SDK handles poll-loop errors internally (logs + retries after `retryDelayMs`). On 429 and 5xx responses that carry a `Retry-After` header, the worker waits that long instead (capped at 60 seconds, never less than `retryDelayMs`). You don’t need to catch these. The worker keeps running, with one exception: a 401 stops the worker and `start()` rejects with `ChronosAuthenticationError`. When the monthly execution quota is used up, claims return 403 `plan_limit_exceeded`; the worker logs it and waits at least 60 seconds between polls until the quota resets or you upgrade.
## Monitoring failures
[Section titled “Monitoring failures”](#monitoring-failures)
Use the executions list endpoint to find failed jobs:
```bash
curl "https://api.chronos.sh/v1/executions?status=failed" \
-H "Authorization: Bearer chrns_your_api_key"
```
Each failed execution includes:
* `error`: the error message (from your handler’s thrown error or the HTTP response)
* `response_code`: HTTP status code (push delivery only)
* `duration_ms`: how long the execution ran before failing
* `trigger`: `system` (first attempt) or `system_retry` (retry)
# Scheduling Patterns
> Cron expressions and interval patterns for recurring schedules.
## Cron expressions
[Section titled “Cron expressions”](#cron-expressions)
Use `cron` for calendar-based scheduling. Chronos uses standard 5-field cron syntax:
```text
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-7, Sun=0 or 7)
│ │ │ │ │
* * * * *
```
### Common patterns
[Section titled “Common patterns”](#common-patterns)
| Pattern | Cron | Description |
| ------------------ | -------------- | --------------------------------------------------- |
| Every minute | `*/1 * * * *` | Testing and high-frequency polling (Team and above) |
| Every 15 minutes | `*/15 * * * *` | Data sync, cache refresh |
| Every hour | `0 * * * *` | Hourly aggregation, health checks |
| Daily at 9am | `0 9 * * *` | Reports, digests |
| Weekdays at 9am | `0 9 * * 1-5` | Business-hours tasks |
| Monday at midnight | `0 0 * * 1` | Weekly cleanup, weekly reports |
| First of the month | `0 0 1 * *` | Billing, monthly reports |
| Every 6 hours | `0 */6 * * *` | Periodic sync without high frequency |
Each plan has a minimum interval, from 10 minutes on Free down to 1 minute on Team and Business. A cron expression or interval that runs more often is rejected with `plan_limit_exceeded`. See [Pricing & Limits](/docs/pricing-limits#plans).
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Daily tenant sync",
"handler": "sync-tenant",
"cron": "0 9 * * *",
"timezone": "Europe/London"
}'
```
## Intervals
[Section titled “Intervals”](#intervals)
Use `interval` for fixed-frequency scheduling without calendar alignment:
```bash
curl -X POST https://api.chronos.sh/v1/schedules \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Health check",
"handler": "health-check",
"interval": { "value": 10, "unit": "minute" }
}'
```
Available units: `second`, `minute`, `hour`, `day`, `week`
### Intervals vs cron
[Section titled “Intervals vs cron”](#intervals-vs-cron)
| Use interval when | Use cron when |
| ------------------------------------------- | ------------------------------------------------- |
| “Every N minutes/hours” regardless of clock | “At 9am” or “every Monday” |
| Fixed frequency matters more than alignment | Calendar alignment matters |
| Simple patterns | Complex patterns (weekdays only, specific months) |
### Anchor-based computation
[Section titled “Anchor-based computation”](#anchor-based-computation)
Intervals compute the next run from the *scheduled* time, not wall-clock. This prevents drift:
```text
interval: 1 hour
Scheduled for: 10:00:00
Actually ran: 10:00:03 (3s late)
Next run: 11:00:00 (computed from 10:00:00, not 10:00:03)
```
## Timezones
[Section titled “Timezones”](#timezones)
Set `timezone` to an IANA timezone string. This affects when cron expressions fire:
```json
{
"name": "Daily report",
"handler": "generate-report",
"cron": "0 9 * * *",
"timezone": "America/New_York"
}
```
If omitted, the schedule uses your account’s default timezone.
DST handling
Cron schedules respect DST transitions. A `0 9 * * *` schedule in `America/New_York` fires at 9am local time year-round: 14:00 UTC in winter, 13:00 UTC in summer.
The timezone must contain a `/` (e.g., `America/New_York`, `Europe/London`). `UTC` is normalized to `Etc/UTC`.
## Limited runs
[Section titled “Limited runs”](#limited-runs)
Set `runs` to stop a schedule after a fixed number of jobs:
```json
{
"name": "7-day onboarding sequence",
"handler": "onboarding-email",
"cron": "0 9 * * *",
"runs": 7
}
```
After the 7th job is created, the schedule automatically transitions to `completed`. No manual intervention needed.
You can update `runs` later via `PATCH`. If you reduce it below the current `run_count`, the schedule completes immediately.
## Stop conditions
[Section titled “Stop conditions”](#stop-conditions)
Set `stop_at` to end a schedule at a specific time:
```json
{
"name": "Holiday promotion",
"handler": "show-banner",
"interval": { "value": 1, "unit": "hour" },
"stop_at": "2026-12-26T00:00:00Z"
}
```
The schedule completes when the next computed run time would exceed `stop_at`. You can combine `runs` and `stop_at`. Whichever triggers first wins.
## Archiving
[Section titled “Archiving”](#archiving)
Archive a schedule to stop it permanently and cancel all its pending jobs:
```bash
curl -X POST https://api.chronos.sh/v1/schedules/SCHEDULE_ID/archive \
-H "Authorization: Bearer chrns_your_api_key"
```
Archiving:
* Sets status to `archived`
* Cancels all pending, queued, ready, and retrying jobs for this schedule
* Clears `next_run_at`
Currently running jobs are **not** affected. They finish execution normally.
## Updating a live schedule
[Section titled “Updating a live schedule”](#updating-a-live-schedule)
Update timing, payload, or delivery config on an active schedule:
```bash
curl -X PATCH https://api.chronos.sh/v1/schedules/SCHEDULE_ID \
-H "Authorization: Bearer chrns_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"cron": "0 */2 * * *",
"payload": { "full": true }
}'
```
When you change `cron` or `interval`, `next_run_at` is recomputed immediately. Changing `timezone` recomputes `next_run_at` for cron schedules. The next job will fire on the new schedule.
You cannot update an `archived` or `completed` schedule.
# Worker Setup
> Configure and run a Chronos worker for production pull delivery.
## Installation
[Section titled “Installation”](#installation)
* pnpm
```bash
pnpm add @chronos.sh/sdk
```
* npm
```bash
npm install @chronos.sh/sdk
```
* yarn
```bash
yarn add @chronos.sh/sdk
```
Requires Node.js 20+. The SDK has zero runtime dependencies.
## Configuration
[Section titled “Configuration”](#configuration)
```ts
import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
});
```
All options:
| Option | Type | Default |
| --------------------- | --------------- | ------------------------ |
| `apiKey` | `string` | required |
| `baseUrl` | `string` | `https://api.chronos.sh` |
| `pollWaitTimeSeconds` | `number` | `20` |
| `timeoutMs` | `number` | `30000` |
| `retryDelayMs` | `number` | `1000` |
| `workerId` | `string` | Auto-generated |
| `logger` | `ChronosLogger` | Console logger |
| `fetch` | `FetchLike` | `globalThis.fetch` |
* `apiKey` starts with `chrns_`
* `baseUrl` is stripped of trailing slashes. Override for local development or proxying
* `pollWaitTimeSeconds` must be an integer 0–20
* `timeoutMs` caps every HTTP request the SDK makes. A request that exceeds it fails with `ChronosTimeoutError` and is retried like any network error. Long-poll claims get `pollWaitTimeSeconds × 1000` of extra headroom, so the intentional wait never trips it
* `retryDelayMs` is the delay between poll retries on error (network failures, API errors). Must be non-negative. On 429 and 5xx responses that carry a `Retry-After` header, the SDK waits that long instead (capped at 60 seconds, never less than `retryDelayMs`). Only integer-seconds values are honored; an HTTP-date `Retry-After` falls back to `retryDelayMs`. Two errors are not retried this way: a 401 stops the worker and `start()` rejects with `ChronosAuthenticationError`, and a 403 `plan_limit_exceeded` on claim waits at least 60 seconds before the next poll
* `workerId` is the identity reported to the API on every claim. Set a stable value for long-lived deployments so the same worker identity survives restarts
Note
Invalid options throw `ChronosConfigError` at construction time. Fail fast, not at runtime.
### Custom logger
[Section titled “Custom logger”](#custom-logger)
The logger interface expects `(message, meta?)`: message first, metadata second:
```ts
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
logger: {
debug(message, meta) { /* ... */ },
info(message, meta) { /* ... */ },
warn(message, meta) { /* ... */ },
error(message, meta) { /* ... */ },
},
});
```
Tip
If you use pino, swap the argument order. pino uses `(obj, message)` by default. See the [SDK reference](/docs/reference/node-sdk#chronoslogger) for an adapter example.
## Registering handlers
[Section titled “Registering handlers”](#registering-handlers)
A handler is a function that processes one job type, identified by name:
```ts
chronos.worker.handle('sync-tenant', async (ctx) => {
await syncTenant(ctx.payload.tenantId);
return { synced: true };
});
```
The handler name must match the `handler` field on your schedules or jobs. That’s how Chronos routes work to the right function.
### Multiple handlers
[Section titled “Multiple handlers”](#multiple-handlers)
Register as many as you need. Calls are chainable:
```ts
chronos.worker
.handle('sync-tenant', syncTenantHandler)
.handle('send-report', sendReportHandler)
.handle('expire-trial', expireTrialHandler);
```
Handler names must be 1-255 characters. Duplicate names throw immediately at registration time.
## Type-safe payloads
[Section titled “Type-safe payloads”](#type-safe-payloads)
Use the generic parameter on `handle` to type the payload:
```ts
type SyncPayload = { tenantId: string; full: boolean };
chronos.worker.handle('sync-tenant', async (ctx) => {
// ctx.payload is typed as SyncPayload
const { tenantId, full } = ctx.payload;
await syncTenant(tenantId, { full });
});
```
## The context object
[Section titled “The context object”](#the-context-object)
Every handler receives a `ChronosContext`:
| Field | Type | Description |
| -------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `jobId` | `string` | Stable job identifier. Same across retries. Use for idempotency keys in downstream calls. |
| `executionId` | `string` | Unique to this attempt. Use for per-attempt logs, metrics, and correlation. |
| `handler` | `string` | The handler name that matched this job. |
| `payload` | `TPayload` | Job payload. |
| `scheduledFor` | `Date` | When the job was originally scheduled to run. |
| `attempt` | `number` | Attempt number. `1` on first try, increments on retries. |
| `timeout` | `number` | Timeout for the handler in seconds. The SDK races the handler against this duration and aborts `ctx.signal` when it elapses. |
| `signal` | `AbortSignal` | Fires when `timeout` elapses. Pass to `fetch`, `AbortSignal.any()`, etc. |
| `schedule` | `{ id, name } \| null` | The schedule that produced this job, or `null` for one-off jobs. |
## Return values
[Section titled “Return values”](#return-values)
What your handler returns determines the execution outcome:
| Return | Effect |
| ------------------------ | ------------------------------------------------------------------------------------------------- |
| `{ ... }` (plain object) | Recorded as the execution result. Must be JSON-serializable. |
| `undefined` / no return | Execution marked `completed` with no result data. |
| Throw an error | Execution marked `failed`. Error message captured (truncated to 4KB). Retries if attempts remain. |
Arrays, class instances, and non-JSON-serializable values are rejected at runtime.
## Starting the worker
[Section titled “Starting the worker”](#starting-the-worker)
```ts
await chronos.worker.start();
```
The returned promise resolves only when `stop()` is called and any in-flight work completes. This means your process stays alive as long as the worker is running.
Constraints:
* Register at least one handler before calling `start()`, or it throws
* Calling `start()` twice throws. A worker instance runs one loop at a time
## Graceful shutdown
[Section titled “Graceful shutdown”](#graceful-shutdown)
Caution
The SDK does not install signal handlers. You own your process lifecycle. See [Graceful Shutdown](/docs/guides/graceful-shutdown) for production patterns.
```ts
const shutdown = async () => {
await chronos.worker.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```
When `stop()` is called:
1. The current long-poll is aborted immediately (no waiting for the poll to time out)
2. If a handler is mid-execution, it runs to completion and reports its result
3. The promise returned by `start()` resolves
This preserves at-least-once delivery: a job claimed is a job completed (or explicitly failed), never silently dropped.
## What the SDK handles vs what you handle
[Section titled “What the SDK handles vs what you handle”](#what-the-sdk-handles-vs-what-you-handle)
| Responsibility | SDK | You |
| -------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
| Long-polling for jobs | Yes | — |
| Claiming jobs | Yes | — |
| Reporting results to the API | Yes (up to 3 attempts) | — |
| Capturing handler errors | Yes (truncated to 4KB) | — |
| Retry on network errors | Yes (waits `retryDelayMs`, resumes poll) | — |
| Credential rejection (401) | Stops; `start()` rejects with `ChronosAuthenticationError` | Rotate the key, restart |
| Execution quota exhausted (403) | Yes (waits at least 60s, resumes poll) | Upgrade the plan |
| Rate-limit backoff | Yes (honors `Retry-After` on 429 and 5xx, capped at 60s) | — |
| Request timeouts | Yes (`timeoutMs`, default 30s, per request) | — |
| Handler logic | — | Yes |
| Idempotency | — | Yes (use `ctx.jobId` for dedup across retries) |
| Signal handling (SIGTERM/SIGINT) | — | Yes |
| Timeout abort signal | Yes (fires `ctx.signal` when `timeout` elapses) | Honor `ctx.signal` in your handler |
| Process lifecycle | — | Yes |
## Complete production example
[Section titled “Complete production example”](#complete-production-example)
worker.ts
```ts
import { Chronos } from '@chronos.sh/sdk';
type SyncPayload = { tenantId: string; full: boolean };
type ReportPayload = { reportId: string; format: 'pdf' | 'csv' };
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
});
chronos.worker
.handle('sync-tenant', async (ctx) => {
const result = await syncTenant(ctx.payload.tenantId, {
full: ctx.payload.full,
});
return { records: result.count };
})
.handle('generate-report', async (ctx) => {
const url = await generateReport(ctx.payload.reportId, ctx.payload.format);
return { url };
});
const shutdown = async () => {
await chronos.worker.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await chronos.worker.start();
```
# Signature Verification
> Verify push delivery signatures to ensure requests come from Chronos.
## Why verify
[Section titled “Why verify”](#why-verify)
Without signature verification, anyone who discovers your endpoint URL can send fake job payloads. Verification proves the request came from Chronos and hasn’t been tampered with.
## The signature headers
[Section titled “The signature headers”](#the-signature-headers)
Every push delivery includes three headers for verification:
| Header | Value | Purpose |
| ----------------------- | -------------- | ---------------------------------- |
| `X-Chronos-Signature` | `sha256=` | HMAC-SHA256 signature |
| `X-Chronos-Timestamp` | Unix seconds | When Chronos sent the request |
| `X-Chronos-Delivery-Id` | UUID | Same as `execution_id` in the body |
## Signed payload format
[Section titled “Signed payload format”](#signed-payload-format)
The signature covers three values joined by dots:
```text
{execution_id}.{timestamp}.{raw_body}
```
* `execution_id`: the `execution_id` field from the request body (also in `X-Chronos-Delivery-Id`)
* `timestamp`: the `X-Chronos-Timestamp` header value
* `raw_body`: the raw JSON string of the request body (not parsed, not re-serialized)
## Your signing secret
[Section titled “Your signing secret”](#your-signing-secret)
Find your signing secret in the dashboard under **Settings → Signing Key**.
Caution
Keep this secret secure. Treat it like an API key.
## Verify with the SDK
[Section titled “Verify with the SDK”](#verify-with-the-sdk)
The `@chronos.sh/sdk` package includes a `Webhook` class that handles signature verification, timestamp validation, and key rotation.
* pnpm
```bash
pnpm add @chronos.sh/sdk
```
* npm
```bash
npm install @chronos.sh/sdk
```
* yarn
```bash
yarn add @chronos.sh/sdk
```
### Basic usage
[Section titled “Basic usage”](#basic-usage)
```ts
import { Webhook } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
const payload = await wh.verify(rawBody, headers);
```
`verify()` returns the parsed JSON body on success and throws `ChronosWebhookVerificationError` on failure. It is `async` because it uses the Web Crypto API internally, which works in Node.js 18+, Bun, Deno, and edge runtimes.
Headers are matched case-insensitively. You can pass a plain object (`req.headers` in Express) or a Web standard `Headers` instance (Hono, Cloudflare Workers, Deno).
Danger
Pass the raw request body (the exact bytes received), not a re-serialized version. JSON key ordering and whitespace must match exactly.
### Express setup
[Section titled “Express setup”](#express-setup)
Express parses the body by default, so you need to capture the raw bytes. Use the `verify` callback on `express.json()` to store the original buffer alongside the parsed body:
server.ts
```ts
import express from 'express';
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
const app = express();
app.post('/hooks/chronos', express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString('utf-8');
},
}), async (req, res) => {
try {
const payload = await wh.verify((req as any).rawBody, req.headers);
// Signature valid — process the job
const { handler } = payload as { handler: string };
// ...
res.sendStatus(200);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
return res.sendStatus(401);
}
throw err;
}
});
```
Tip
If you use `express.raw()` or `bodyParser.raw()` instead, the body is already a `Buffer`. Call `.toString('utf-8')` before passing it to `verify()`.
### Key rotation
[Section titled “Key rotation”](#key-rotation)
Pass an array of secrets to verify against multiple keys during rotation. Chronos keeps both the old and new keys valid for **24 hours** after rotation.
```ts
const wh = new Webhook([
process.env.CHRONOS_SIGNING_SECRET!,
process.env.CHRONOS_SIGNING_SECRET_PREVIOUS!,
]);
```
The SDK tries each key in order and succeeds on the first match. Pass all secrets at construction time; keys are cached after the first `verify()` call.
Chronos blocks another rotation until the 24-hour grace window closes (`409 signing_key_grace_window_active`).
### Replay protection
[Section titled “Replay protection”](#replay-protection)
By default, `verify()` rejects requests with timestamps more than 5 minutes from the current time (in either direction). Adjust the window or disable it entirely:
```ts
// Tighter window (60 seconds)
const wh = new Webhook(secret, { toleranceSeconds: 60 });
```
```ts
// Disable timestamp check entirely
const wh = new Webhook(secret, { toleranceSeconds: 0 });
```
Caution
`toleranceSeconds: 0` disables replay protection entirely. Any timestamp (past or future) is accepted. Only use this for testing.
### Error handling
[Section titled “Error handling”](#error-handling)
All failures throw `ChronosWebhookVerificationError`. The `message` indicates what failed.
**Constructor errors:**
| Message | Cause |
| ------------------------------------------------------- | ------------------------------------------------------ |
| `At least one non-empty signing secret is required` | No valid secret provided (empty string or empty array) |
| `toleranceSeconds must be a finite non-negative number` | Negative, `Infinity`, or `NaN` tolerance value |
**Verification errors** (thrown by `verify()`):
| Message | Cause |
| ---------------------------------------------------- | --------------------------------------------- |
| `Missing required header: ` | One of the three signature headers is missing |
| `Invalid timestamp` | Timestamp is not a positive integer |
| `Timestamp outside tolerance` | Request is too old or too far in the future |
| `Invalid signature format: expected sha256=` | Signature header doesn’t start with `sha256=` |
| `Invalid signature: expected 32-byte SHA-256 digest` | Hex digest is empty or wrong length |
| `Invalid signature: malformed hex` | Non-hex characters in digest |
| `Signature verification failed` | Valid format, wrong secret or tampered body |
| `Verified payload is not valid JSON` | Signature matched but body isn’t valid JSON |
```ts
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
try {
const payload = await wh.verify(rawBody, headers);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
console.error('Verification failed:', err.message);
return new Response('Invalid signature', { status: 401 });
}
}
```
Return 401 on verification failure. 4xx responses are terminal; Chronos won’t retry.
## Checklist
[Section titled “Checklist”](#checklist)
Before you ship
* Verify against the raw body bytes, not re-serialized JSON
* Return 401 on verification failure (4xx = terminal, Chronos won’t retry)
* Handle key rotation with an array of secrets during the 24-hour grace period
* Keep the default 5-minute replay protection in production
## Manual verification
[Section titled “Manual verification”](#manual-verification)
If you can’t use the SDK or want to understand the protocol, here’s a standalone implementation using Node.js `crypto`:
```ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
import type { Request } from 'express';
const SIGNING_SECRET = process.env.CHRONOS_SIGNING_SECRET!;
const MAX_AGE_SECONDS = 300;
function verifyChronosSignature(req: Request, rawBody: string): boolean {
const signature = getHeader(req, 'x-chronos-signature');
const timestamp = getHeader(req, 'x-chronos-timestamp');
const executionId = getHeader(req, 'x-chronos-delivery-id');
if (!signature || !timestamp || !executionId) {
return false;
}
const signatureDigest = parseChronosSignature(signature);
if (!signatureDigest) {
return false;
}
if (!/^\d+$/.test(timestamp)) {
return false;
}
const requestTime = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(requestTime) || Math.abs(now - requestTime) > MAX_AGE_SECONDS) {
return false;
}
const signedPayload = `${executionId}.${timestamp}.${rawBody}`;
const expectedDigest = createHmac('sha256', SIGNING_SECRET)
.update(signedPayload)
.digest();
return timingSafeEqual(signatureDigest, expectedDigest);
}
function getHeader(req: Request, name: string): string | null {
const value = req.headers[name];
return typeof value === 'string' ? value : null;
}
function parseChronosSignature(signature: string): Buffer | null {
const match = /^sha256=([a-f0-9]{64})$/i.exec(signature);
const digest = match?.[1];
return digest ? Buffer.from(digest, 'hex') : null;
}
```
Key implementation details:
* Use `timingSafeEqual` (not `===`) to prevent timing attacks
* Validate the `sha256=` prefix and 64-character hex digest before comparing
* Check timestamp freshness to prevent replay attacks
### Key rotation (manual)
[Section titled “Key rotation (manual)”](#key-rotation-manual)
To handle rotation, verify against both keys:
```ts
function verifyWithRotation(req: Request, rawBody: string): boolean {
const signature = getHeader(req, 'x-chronos-signature');
const timestamp = getHeader(req, 'x-chronos-timestamp');
const executionId = getHeader(req, 'x-chronos-delivery-id');
if (!signature || !timestamp || !executionId) return false;
const signatureDigest = parseChronosSignature(signature);
if (!signatureDigest) return false;
if (!/^\d+$/.test(timestamp)) return false;
const requestTime = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(requestTime) || Math.abs(now - requestTime) > 300) return false;
const keys = [
process.env.CHRONOS_SIGNING_SECRET!,
process.env.CHRONOS_SIGNING_SECRET_PREVIOUS!,
].filter(Boolean);
const signedPayload = `${executionId}.${timestamp}.${rawBody}`;
return keys.some((secret) => {
const expectedDigest = createHmac('sha256', secret)
.update(signedPayload)
.digest();
return timingSafeEqual(signatureDigest, expectedDigest);
});
}
```
# Pricing & Limits
> Plan tiers, rate limits, and usage quotas.
## Plans
[Section titled “Plans”](#plans)
| | Free | Solo | Team | Business |
| -------------------- | ------- | -------- | --------- | --------- |
| **Price** | $0 | $9.99/mo | $29.99/mo | $79.99/mo |
| **Schedules** | 3 | 25 | 100 | 500 |
| **Executions/month** | 2,500 | 25,000 | 250,000 | 1,000,000 |
| **Timeout** | 30s | 2 min | 5 min | 10 min |
| **Min interval** | 10 min | 5 min | 1 min | 1 min |
| **Retention** | 7+ days | 30+ days | 90+ days | 180+ days |
| **Seats** | 1 | 1 | Unlimited | Unlimited |
* **Timeout:** Maximum duration before an execution is considered timed out. Set per schedule or job via the `timeout` field. Values above your plan’s limit are rejected at create and update; after a plan change, active schedules above the new limit are paused.
* **Min interval:** Shortest allowed gap between recurring schedule runs. Cron expressions or intervals that resolve below this are rejected at create and update; after a plan change, active schedules below the new minimum are paused.
* **Seats:** Members plus pending invites. Free and Solo have one seat, so inviting a colleague needs Team or Business. A downgrade removes nobody, but no one else can join until the account is back within its seat cap.
Tip
The free tier is real. No credit card required, no time limit.
## What counts as a “schedule”
[Section titled “What counts as a “schedule””](#what-counts-as-a-schedule)
Only active recurring schedules count toward your limit. One-off jobs (created via `POST /v1/jobs`) are **unlimited on every tier:** only their executions count toward the monthly limit.
Archived and completed schedules do not count.
## What counts as an “execution”
[Section titled “What counts as an “execution””](#what-counts-as-an-execution)
Every attempt counts, including retries. A job with `max_retries: 3` that fails on the first two attempts and succeeds on the third uses 3 executions.
Both push and pull executions count equally.
## Limits
[Section titled “Limits”](#limits)
### Payload size
[Section titled “Payload size”](#payload-size)
Maximum payload size is **64KB** (serialized JSON). This applies to both schedules and jobs. Exceeding it returns a validation error:
```text
"payload must be 64KB or smaller"
```
### Rate limiting
[Section titled “Rate limiting”](#rate-limiting)
Approximately **100 requests per 10 seconds** per client IP. The exact ceiling can vary, so treat the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers on every response as authoritative. Exceeding the limit returns `429` with a `Retry-After` header indicating how many seconds to wait.
This applies to all endpoints in the API reference. Long-poll requests (`POST /v1/workers/jobs/claim`) count as one request regardless of hold time. The SDK worker honors the `Retry-After` header automatically when it hits a rate limit.
### Retention
[Section titled “Retention”](#retention)
Execution history is retained while your account is active, and for at least the window specified by your plan. Job and schedule records are not affected by retention.
| Tier | Retention |
| -------- | --------- |
| Free | 7+ days |
| Solo | 30+ days |
| Team | 90+ days |
| Business | 180+ days |
## FAQ
[Section titled “FAQ”](#faq)
**Can I upgrade or downgrade at any time?** Yes. Plan changes take effect immediately. When you downgrade, or a paid plan ends, Chronos pauses any active schedule that runs more often than the new minimum interval or has a `timeout` above the new maximum. If you have more active schedules than the new cap, Chronos pauses the newest ones until the count fits. Nothing is deleted. To resume a paused schedule, make it fit the current plan: edit it, free a slot by pausing or archiving another schedule, or upgrade again. Chronos also refuses a manual trigger while the schedule’s `timeout` exceeds the plan maximum.
**What happens if I exceed my execution limit?** Limits are hard caps. Once you hit your monthly execution limit, further executions are declined until you upgrade or the billing period resets. You’ll never get a surprise bill.
**Do retries count toward my execution limit?** Yes. Each retry creates a new execution that counts toward your monthly total. Set `max_retries` appropriately for your workload.
**Are one-off jobs really unlimited?** Creating one-off jobs is unlimited. However, when they execute, those executions count toward your monthly limit like any other execution.
# Error Codes
> All error codes returned by the Chronos API and SDK.
Every error response uses a consistent envelope:
```json
{
"success": false,
"message": "Human-readable description",
"code": "machine_readable_code",
"errors": []
}
```
The `code` field is stable. Use it for programmatic error handling. The `message` field may change between versions.
***
## HTTP status codes
[Section titled “HTTP status codes”](#http-status-codes)
| Status | Meaning | Retryable? |
| ------ | ---------------------------------------------------------------------------------- | --------------------------------- |
| 400 | Bad request. Invalid input | No |
| 401 | Unauthorized. Missing or invalid API key | No |
| 403 | Forbidden. Access denied, plan limit exceeded, or CSRF failure (session auth only) | No (check `code`) |
| 404 | Not found. Resource doesn’t exist | No |
| 409 | Conflict. Resource state prevents the action | No (fix state first) |
| 422 | Validation error. Request body/query failed schema validation | No (fix input) |
| 429 | Rate limited. Too many requests | Yes (after `Retry-After` seconds) |
| 500 | Internal error. Unexpected server failure | Yes |
***
## Global errors
[Section titled “Global errors”](#global-errors)
These can occur on any endpoint.
| Code | Status | Description |
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `bad_request` | 400 | Request is malformed or semantically invalid. |
| `unauthorized` | 401 | No valid API key provided, or key has been revoked. |
| `forbidden` | 403 | Access denied. |
| `not_found` | 404 | Resource does not exist or URL does not match any route. |
| `conflict` | 409 | Resource state prevents the requested action. |
| `validation_error` | 422 | Request body, query, or path params failed schema validation. See `errors` array for details. |
| `rate_limit_exceeded` | 429 | Exceeded the rate limit (approximately 100 requests per 10 seconds per client IP). The `Retry-After` header tells you when to retry. |
| `internal_error` | 500 | Unexpected server error. |
| `error` | 500 | Fallback code when no specific code applies. |
***
## Schedule errors
[Section titled “Schedule errors”](#schedule-errors)
| Code | Status | When |
| --------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schedule_not_found` | 404 | Schedule ID doesn’t exist or doesn’t belong to your account. |
| `schedule_archived` | 409 | Attempting to update, trigger, pause, resume, or re-archive an archived schedule. |
| `schedule_completed` | 409 | Attempting to update, trigger, pause, or resume a completed schedule (reached `runs` limit or `stop_at`). |
| `schedule_already_paused` | 409 | Pausing a schedule that is already paused. |
| `schedule_already_active` | 409 | Resuming a schedule that is already active. |
| `schedule_cadence_invalid` | 422 | Resuming a schedule whose stored cron, interval, or timezone can no longer be computed. Update the schedule, then resume. |
| `handler_required_for_pull` | 400 | Updating a schedule to pull delivery without setting a `handler`. |
| `schedule_status_changed` | 409 | Concurrent update detected. The schedule’s status changed between your read and write. Retry the operation. |
| `plan_limit_exceeded` | 403 | The request exceeds a plan limit: the active-schedule count (create, resume), the timeout cap (create, update, resume, trigger), or the minimum interval (create, update, resume). The `message` names the limit and your tier; see [Pricing & Limits](/docs/pricing-limits#plans). |
***
## Job errors
[Section titled “Job errors”](#job-errors)
| Code | Status | When |
| -------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `job_not_found` | 404 | Job ID doesn’t exist or doesn’t belong to your account. |
| `idempotency_key_conflict` | 409 | A job with the same `idempotency_key` already exists on your account. |
| `job_not_cancellable` | 409 | Cancelling a job that isn’t `pending`, `admitting`, `queued`, `ready`, or `retrying`. |
| `job_not_scheduled` | 409 | Skipping a one-off job. Only schedule-spawned jobs can be skipped. |
| `job_not_skippable` | 409 | Skipping a job that isn’t `pending` or `queued`. |
| `job_not_failed` | 409 | Retrying a job that hasn’t failed. |
| `job_status_changed` | 409 | Concurrent update detected. The job’s status changed between your read and write. Retry the operation. |
| `plan_limit_exceeded` | 403 | `timeout` exceeds your plan’s cap. The `message` names the cap and your tier; see [Pricing & Limits](/docs/pricing-limits#plans). |
***
## Execution errors
[Section titled “Execution errors”](#execution-errors)
| Code | Status | When |
| ----------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execution_not_found` | 404 | Execution ID doesn’t exist or doesn’t belong to your account. |
| `execution_not_running` | 409 | Reporting a result for an execution that isn’t in `running` status. The execution may have already timed out or been reported by another process. Reporting the status the execution already has returns `200` instead. |
| `plan_limit_exceeded` | 403 | Claiming a job after your plan’s monthly execution quota is used up. See [Pricing & Limits](/docs/pricing-limits#plans). |
***
## Signing key errors
[Section titled “Signing key errors”](#signing-key-errors)
| Code | Status | When |
| --------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- |
| `signing_key_not_found` | 404 | No signing key exists for your account. Create one in the dashboard. |
| `signing_key_grace_window_active` | 409 | A key rotation is already in progress. Wait 24 hours for the grace window to close before rotating again. |
***
## Validation error details
[Section titled “Validation error details”](#validation-error-details)
When the code is `validation_error`, the `errors` array contains per-field details:
```json
{
"success": false,
"message": "request body failed validation",
"code": "validation_error",
"errors": [
{
"field": "cron",
"message": "cron must be a valid cron expression"
},
{
"field": "payload",
"message": "payload must be 64KB or smaller"
}
]
}
```
Common validation messages:
| Message | Cause |
| -------------------------------------------------------- | ------------------------------------------------ |
| `provide either cron or interval, not both` | Schedule creation/update with both timing fields |
| `provide either delay or scheduled_for, not both` | Job creation with both timing options |
| `payload must be 64KB or smaller` | Payload exceeds size limit |
| `cron must be a valid cron expression` | Unparseable cron syntax |
| `must be a valid IANA timezone` | Invalid timezone string |
| `url must use https in production` | HTTP URL (HTTPS required in production) |
| `url must not point to a private or reserved IP address` | Localhost or private network URL |
| `stop_at must be a future date` | Past date for schedule termination |
| `scheduled_for must be a future date` | Past date for job execution |
| `size must be between 1 and 100` | Pagination page size out of range |
| `at least one field must be provided` | Empty update body |
***
## Handling errors in code
[Section titled “Handling errors in code”](#handling-errors-in-code)
### With the SDK
[Section titled “With the SDK”](#with-the-sdk)
The SDK wraps API errors in typed error classes. The worker retries 429 and 5xx responses itself, so the one rejection you catch from `start()` is a 401:
```ts
import { Chronos, ChronosAuthenticationError } from '@chronos.sh/sdk';
const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY! });
try {
await chronos.worker.start();
} catch (err) {
if (err instanceof ChronosAuthenticationError) {
console.error('Chronos rejected the API key; rotate CHRONOS_API_KEY', { requestId: err.requestId });
process.exit(1);
}
throw err;
}
```
See [Node SDK reference](/docs/reference/node-sdk#error-hierarchy) for every error class.
### With direct API calls
[Section titled “With direct API calls”](#with-direct-api-calls)
Check the `code` field for programmatic handling:
```ts
const res = await fetch('https://api.chronos.sh/v1/schedules', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(schedule),
});
if (!res.ok) {
const error = await res.json();
switch (error.code) {
case 'validation_error':
console.error('Invalid input:', error.errors);
break;
case 'rate_limit_exceeded':
const retryAfter = res.headers.get('Retry-After');
await sleep(Number(retryAfter) * 1000);
break;
case 'unauthorized':
throw new Error('Check your API key');
break;
}
}
```
# Node.js SDK
> Complete API reference for the @chronos.sh/sdk package.
## Installation
[Section titled “Installation”](#installation)
* pnpm
```bash
pnpm add @chronos.sh/sdk
```
* npm
```bash
npm install @chronos.sh/sdk
```
* yarn
```bash
yarn add @chronos.sh/sdk
```
- Requires Node.js 20+
- Dual module: ESM (`import`) and CJS (`require`) both supported
- Zero runtime dependencies
## `Chronos`
[Section titled “Chronos”](#chronos)
The top-level client. The only class you construct directly.
```ts
import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
});
```
### Constructor
[Section titled “Constructor”](#constructor)
```ts
new Chronos(options: ChronosOptions)
```
Throws `ChronosConfigError` if any option fails validation.
### Properties
[Section titled “Properties”](#properties)
| Property | Type | Description |
| -------- | -------- | ----------------------------------------------------------------------- |
| `worker` | `Worker` | Read-only. The long-poll worker instance. All functionality lives here. |
### Methods
[Section titled “Methods”](#methods)
None. `Chronos` is a composition root. Use `chronos.worker` for all operations.
## `Worker`
[Section titled “Worker”](#worker)
Accessed via `chronos.worker`. Not directly importable.
### `.handle(name, handler)`
[Section titled “.handle\(name, handler)”](#handletpayloadname-handler)
```ts
handle(name: string, handler: ChronosHandler): this
```
Register a handler for a named job type. Returns `this` for chaining.
| Parameter | Type | Description |
| --------- | -------------------------- | ------------------------------------------------------------ |
| `name` | `string` | Job type name. Trimmed internally. Must be 1–255 characters. |
| `handler` | `ChronosHandler` | Async or sync function that processes the job. |
**Throws `ChronosError`:**
* `'Handler name is required'`: empty or whitespace-only name
* `'Handler name must be 255 characters or fewer'`
* `'Handler "{name}" is already registered'`: duplicate name
* `'Handler "{name}" must be a function'`: non-function passed
### `.start()`
[Section titled “.start()”](#start)
```ts
start(): Promise
```
Begin long-polling for jobs. The returned promise resolves when `stop()` is called and all in-flight work completes, or rejects with `ChronosAuthenticationError` if any request gets a 401. A 401 that lands after `stop()` was called is logged and the promise resolves.
**Throws `ChronosError` (synchronously):**
* `'Chronos worker is already started'`
* `'Register at least one handler before starting Chronos'`
### `.stop()`
[Section titled “.stop()”](#stop)
```ts
stop(): Promise
```
Request graceful shutdown.
1. Aborts the current long-poll immediately
2. Waits for any in-flight handler to finish (or hit its `timeout`) and report its result
3. Resolves when complete
Safe to call at any time and never rejects: a credential rejection surfaces on `start()` as `ChronosAuthenticationError` instead, or is only logged when it lands after this call. Bounded by the handler `timeout` plus `timeoutMs` per report attempt; see [Graceful Shutdown](/docs/guides/graceful-shutdown#worst-case-shutdown-time) for the arithmetic.
## Types
[Section titled “Types”](#types)
### `ChronosOptions`
[Section titled “ChronosOptions”](#chronosoptions)
```ts
type ChronosOptions = {
apiKey: string;
baseUrl?: string;
fetch?: FetchLike;
logger?: ChronosLogger;
pollWaitTimeSeconds?: number;
timeoutMs?: number;
retryDelayMs?: number;
workerId?: string;
};
```
| Option | Type | Default | Validation |
| --------------------- | --------------- | -------------------------- | ------------------------------------------------------------------ |
| `apiKey` | `string` | — (required) | Trimmed. Throws `ChronosConfigError` if empty. |
| `baseUrl` | `string` | `'https://api.chronos.sh'` | Trimmed, trailing slashes stripped. Throws if empty after trim. |
| `fetch` | `FetchLike` | `globalThis.fetch` | None. |
| `logger` | `ChronosLogger` | Console-backed logger | None. |
| `pollWaitTimeSeconds` | `number` | `20` | Integer, 0–20 inclusive. Throws `ChronosConfigError`. |
| `timeoutMs` | `number` | `30000` | Positive integer up to `2147483647`. Throws `ChronosConfigError`. |
| `retryDelayMs` | `number` | `1000` | Finite, non-negative. Throws `ChronosConfigError`. |
| `workerId` | `string` | Auto-generated | Trimmed. 1–255 characters after trim. Throws `ChronosConfigError`. |
`timeoutMs` bounds every HTTP request the SDK makes; a request that exceeds it fails with `ChronosTimeoutError`. Long-poll claim requests get `pollWaitTimeSeconds × 1000` of extra headroom so the intentional wait never counts against the bound. `workerId` is the identity reported to the API on every claim; set it to a stable value when you want the same worker identity across restarts.
### `ChronosContext`
[Section titled “ChronosContext\”](#chronoscontexttpayload)
```ts
type ChronosContext = {
jobId: string;
executionId: string;
handler: string;
payload: TPayload;
scheduledFor: Date;
attempt: number;
timeout: number;
signal: AbortSignal;
schedule: ChronosSchedule | null;
};
```
| Field | Type | Description |
| -------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `jobId` | `string` | Stable job identifier. Same across retries. Use for idempotency keys in downstream calls. |
| `executionId` | `string` | Unique to this execution attempt. Use for per-attempt logs, metrics, and correlation. |
| `handler` | `string` | Handler name that matched this job. |
| `payload` | `TPayload` | Job payload. Typed via the generic on `.handle()`. |
| `scheduledFor` | `Date` | When the job was originally scheduled. Parsed from ISO string. |
| `attempt` | `number` | Attempt number. `1` on first try, increments on retries. |
| `timeout` | `number` | Timeout for the handler in seconds. The SDK races the handler against this duration and aborts `ctx.signal` when it elapses. |
| `signal` | `AbortSignal` | Fires when `timeout` elapses. Pass to `fetch`, `AbortSignal.any()`, etc. |
| `schedule` | `ChronosSchedule \| null` | Parent schedule, or `null` for one-off jobs. |
### `ChronosSchedule`
[Section titled “ChronosSchedule”](#chronosschedule)
```ts
type ChronosSchedule = {
id: string;
name: string;
};
```
### `RateLimitInfo`
[Section titled “RateLimitInfo”](#ratelimitinfo)
```ts
type RateLimitInfo = {
limit: number;
remaining: number;
reset: number;
};
```
| Field | Type | Description |
| ----------- | -------- | ----------------------------------------- |
| `limit` | `number` | Maximum requests allowed per window. |
| `remaining` | `number` | Requests remaining in the current window. |
| `reset` | `number` | Seconds until the current window resets. |
Attached to `ChronosApiError.rateLimit` on non-429 error responses. All three `X-RateLimit-*` headers must be present; if any is missing, `rateLimit` is `undefined`.
### `ChronosHandler`
[Section titled “ChronosHandler\”](#chronoshandlertpayload)
```ts
type ChronosHandler = (
ctx: ChronosContext,
) => ChronosHandlerResult | Promise;
```
Can be sync or async.
### `ChronosHandlerResult`
[Section titled “ChronosHandlerResult”](#chronoshandlerresult)
```ts
type ChronosHandlerResult = Record | void;
```
| Return value | Effect |
| ------------------ | -------------------------------------------------------- |
| Plain object | Recorded as execution result. Must be JSON-serializable. |
| `undefined` / void | Execution marked completed, no result data. |
Rejected at runtime (throws `ChronosError`):
* Arrays
* Class instances (non-plain-object prototypes)
* Non-JSON-serializable values
### `ChronosLogger`
[Section titled “ChronosLogger”](#chronoslogger)
```ts
type ChronosLogger = {
debug(message: string, meta?: Record): void;
info(message: string, meta?: Record): void;
warn(message: string, meta?: Record): void;
error(message: string, meta?: Record): void;
};
```
Signature is `(message, meta?)`: message first, metadata second.
Adapting pino (which uses `obj, message` order):
```ts
import pino from 'pino';
const log = pino();
const chronos = new Chronos({
apiKey: process.env.CHRONOS_API_KEY!,
logger: {
debug: (msg, meta) => log.debug(meta ?? {}, msg),
info: (msg, meta) => log.info(meta ?? {}, msg),
warn: (msg, meta) => log.warn(meta ?? {}, msg),
error: (msg, meta) => log.error(meta ?? {}, msg),
},
});
```
### `FetchLike`
[Section titled “FetchLike”](#fetchlike)
```ts
type FetchLike = (input: string | URL, init?: RequestInit) => Promise;
```
Compatible with `globalThis.fetch`. Use cases: inject tracing headers, test mocking. Implementations must forward `init.signal` to the underlying transport; dropping it disables the SDK’s transport timeout and graceful abort.
## `Webhook`
[Section titled “Webhook”](#webhook)
Verifies signatures on push deliveries. Standalone: does not require a `Chronos` client or API key.
```ts
import { Webhook } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
const payload = await wh.verify(rawBody, req.headers);
```
### Constructor
[Section titled “Constructor”](#constructor-1)
```ts
new Webhook(secret: string | string[], options?: WebhookOptions)
```
| Parameter | Type | Description |
| --------- | -------------------- | ----------------------------------------------------------------------------------------------- |
| `secret` | `string \| string[]` | Signing secret or array of secrets for key rotation. Blank/whitespace strings are filtered out. |
| `options` | `WebhookOptions` | Optional configuration. |
Throws `ChronosWebhookVerificationError` if no valid (non-empty) secret is provided, or if `toleranceSeconds` is invalid.
### `.verify(body, headers)`
[Section titled “.verify(body, headers)”](#verifybody-headers)
```ts
verify(body: string, headers: Record | Headers): Promise
```
Verifies the signature and returns the parsed JSON body on success. Throws `ChronosWebhookVerificationError` on any failure.
| Parameter | Type | Description |
| --------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `body` | `string` | Raw request body. Must be the exact bytes received, not re-serialized. |
| `headers` | `Record \| Headers` | Request headers. Keys are matched case-insensitively. Accepts plain objects or the Web standard `Headers` class. |
The method is `async` because it uses the Web Crypto API (`crypto.subtle`). Works in Node.js 18+, Bun, Deno, and edge runtimes.
Crypto keys are lazily imported on the first `verify()` call and cached for subsequent calls.
### `WebhookOptions`
[Section titled “WebhookOptions”](#webhookoptions)
```ts
type WebhookOptions = {
toleranceSeconds?: number;
};
```
| Option | Type | Default | Description |
| ------------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `toleranceSeconds` | `number` | `300` | Timestamp tolerance in seconds. Rejects requests with timestamps more than this many seconds from now (in either direction). Set to `0` to disable replay protection entirely. Must be finite and non-negative. |
### `DEFAULT_TIMESTAMP_TOLERANCE_SECONDS`
[Section titled “DEFAULT\_TIMESTAMP\_TOLERANCE\_SECONDS”](#default_timestamp_tolerance_seconds)
```ts
const DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300;
```
The default tolerance applied when `toleranceSeconds` is not specified.
## Error hierarchy
[Section titled “Error hierarchy”](#error-hierarchy)
```text
Error
└── ChronosError
├── ChronosConfigError
├── ChronosApiError
│ ├── ChronosAuthenticationError
│ └── ChronosRateLimitError
├── ChronosNetworkError
│ └── ChronosTimeoutError
├── ChronosHandlerError
└── ChronosWebhookVerificationError
```
### `ChronosError`
[Section titled “ChronosError”](#chronoserror)
```ts
class ChronosError extends Error {
readonly cause?: unknown;
constructor(message: string, options?: { cause?: unknown });
}
```
Base class for all SDK errors. Catch this to handle any SDK error generically.
Thrown for operational issues: duplicate handler names, invalid return values, calling `start()` twice.
### `ChronosConfigError`
[Section titled “ChronosConfigError”](#chronosconfigerror)
```ts
class ChronosConfigError extends ChronosError {
constructor(message: string);
}
```
Invalid options passed to `new Chronos()`. Only thrown during construction. If the constructor succeeds, config is valid.
Messages:
* `'Chronos apiKey is required'`
* `'Chronos baseUrl is required'`
* `'pollWaitTimeSeconds must be an integer between 0 and 20'`
* `'retryDelayMs must be a non-negative number'`
* `'timeoutMs must be a positive integer no greater than 2147483647'`
* `'workerId must be 1-255 characters'`
### `ChronosApiError`
[Section titled “ChronosApiError”](#chronosapierror)
```ts
class ChronosApiError extends ChronosError {
readonly status: number;
readonly code?: string;
readonly body?: unknown;
readonly requestId?: string;
readonly retryAfterSeconds?: number;
readonly rateLimit?: RateLimitInfo;
constructor(message: string, options: ChronosApiErrorOptions);
}
```
| Property | Type | Description |
| ------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | `number` | HTTP status code. Can be `200` if the API returned `{ success: false }`. |
| `code` | `string \| undefined` | Application error code (e.g., `'rate_limit_exceeded'`). |
| `body` | `unknown` | Full parsed response body. |
| `requestId` | `string \| undefined` | Value of the `X-Request-Id` response header. |
| `retryAfterSeconds` | `number \| undefined` | Seconds to wait before retrying. Parsed from the `Retry-After` header on 429 and 5xx responses. Integer seconds only; `undefined` for HTTP-date values. |
| `rateLimit` | `RateLimitInfo \| undefined` | Rate-limit budget from `X-RateLimit-*` headers. Present on non-429 error responses when all three headers exist. |
Thrown when the API responds with a non-2xx status or a 2xx response with `{ success: false }`. For 401 the SDK throws the `ChronosAuthenticationError` subclass and for 429 the `ChronosRateLimitError` subclass.
```ts
try {
await chronos.worker.start();
} catch (err) {
if (err instanceof ChronosApiError) {
console.error(`API error ${err.status}: ${err.code}`);
}
}
```
### `ChronosAuthenticationError`
[Section titled “ChronosAuthenticationError”](#chronosauthenticationerror)
```ts
class ChronosAuthenticationError extends ChronosApiError {
constructor(message: string, options: ChronosApiErrorOptions);
}
```
Thrown on any HTTP 401: the API key is missing, malformed, unknown, or revoked, or a proxy in front of the API rejected the request. Subclass of `ChronosApiError`. The worker stops on this error and `start()` rejects with it, unless `stop()` was already called, in which case the worker only logs it. When the 401 arrives on a result report, the log line carries the execution id and that result is not recorded.
```ts
import { ChronosAuthenticationError } from '@chronos.sh/sdk';
try {
await chronos.worker.start();
} catch (err) {
if (err instanceof ChronosAuthenticationError) {
console.error('Rotate CHRONOS_API_KEY', { requestId: err.requestId });
process.exit(1);
}
throw err;
}
```
### `ChronosRateLimitError`
[Section titled “ChronosRateLimitError”](#chronosratelimiterror)
```ts
class ChronosRateLimitError extends ChronosApiError {
constructor(message: string, options: ChronosApiErrorOptions);
}
```
Thrown on `429 Too Many Requests`. Subclass of `ChronosApiError`, so existing `instanceof ChronosApiError` catches still work.
`retryAfterSeconds` is set when the response includes a `Retry-After` header; otherwise `undefined`. `rateLimit` is always `undefined` on 429.
```ts
import { ChronosRateLimitError } from '@chronos.sh/sdk';
function handleSdkError(err: unknown) {
if (err instanceof ChronosRateLimitError && err.retryAfterSeconds) {
console.warn(`Rate limited. Retry after ${err.retryAfterSeconds}s`);
}
}
```
The worker handles 429s automatically and never rejects `start()` with this error. See [Retries & Error Handling](/docs/guides/retries-error-handling#sdk-error-classes) for details.
### `ChronosNetworkError`
[Section titled “ChronosNetworkError”](#chronosnetworkerror)
```ts
class ChronosNetworkError extends ChronosError {
readonly cause: unknown;
constructor(message: string, options: { cause: unknown });
}
```
Thrown when the request fails at the transport layer: DNS resolution failure, TCP connection refused, TLS errors, or a connection dropped mid-response.
`cause` contains the original error from `fetch`.
Not thrown for abort signals. If `stop()` aborts a poll, the raw abort error propagates (not wrapped in `ChronosNetworkError`). The SDK’s own transport timeout throws the `ChronosTimeoutError` subclass.
### `ChronosTimeoutError`
[Section titled “ChronosTimeoutError”](#chronostimeouterror)
```ts
class ChronosTimeoutError extends ChronosNetworkError {
constructor(message: string, options: { cause: unknown });
}
```
Thrown when a request exceeds `timeoutMs` before the response completes, including a stall during the body read. Subclass of `ChronosNetworkError`, so generic transport-failure handling still catches it. `cause` is the underlying abort rejection (a `TimeoutError` `DOMException` on Node).
Message: `'Chronos API request timed out after {timeoutMs}ms'`, where the value is the effective timeout for that request. Long-poll claims report `timeoutMs + pollWaitTimeSeconds × 1000` (50000ms at the defaults).
The worker treats it like any other transport failure: the poll loop waits `retryDelayMs` and resumes, and result reports get up to 3 attempts.
### `ChronosHandlerError`
[Section titled “ChronosHandlerError”](#chronoshandlererror)
```ts
class ChronosHandlerError extends ChronosError {
readonly cause: unknown;
constructor(message: string, options: { cause: unknown });
}
```
Wraps any exception thrown inside your handler. The `message` is copied from the original error (or stringified). If empty, defaults to `'Chronos handler failed'`.
This error is never rethrown to your code. It’s logged internally and the failure is reported to the API. The error message (truncated to 4KB) is sent as the execution failure reason.
### `ChronosWebhookVerificationError`
[Section titled “ChronosWebhookVerificationError”](#chronoswebhookverificationerror)
```ts
class ChronosWebhookVerificationError extends ChronosError {
constructor(message: string);
}
```
Thrown by `Webhook` when any verification step fails: missing headers, invalid timestamp, bad signature format, wrong secret, or non-JSON body. Also thrown by the `Webhook` constructor if no valid secret is provided.
The `message` indicates the specific failure. See [Signature Verification](/docs/guides/signature-verification#error-handling) for the full list of messages.
```ts
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
try {
const payload = await wh.verify(rawBody, headers);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
console.error('Verification failed:', err.message);
}
}
```
## Exports
[Section titled “Exports”](#exports)
Every named export from `@chronos.sh/sdk`:
| Export | Kind | Description |
| ------------------------------------- | -------- | ---------------------------------------------------------------- |
| `Chronos` | Class | Top-level client. The only class you construct. |
| `ChronosError` | Class | Base error class for all SDK errors. |
| `ChronosConfigError` | Class | Invalid constructor options. |
| `ChronosApiError` | Class | API responded with an error. |
| `ChronosAuthenticationError` | Class | API responded with 401. Subclass of `ChronosApiError`. |
| `ChronosRateLimitError` | Class | API responded with 429. Subclass of `ChronosApiError`. |
| `ChronosApiErrorOptions` | Type | Options for constructing `ChronosApiError`. |
| `RateLimitInfo` | Type | Rate-limit budget (`limit`, `remaining`, `reset`). |
| `ChronosNetworkError` | Class | Network/transport failure. |
| `ChronosTimeoutError` | Class | Request exceeded `timeoutMs`. Subclass of `ChronosNetworkError`. |
| `ChronosHandlerError` | Class | Handler threw an exception. |
| `ChronosWebhookVerificationError` | Class | Webhook signature verification failed. |
| `Webhook` | Class | Webhook signature verifier for push deliveries. |
| `ChronosContext` | Type | Context object passed to handlers. |
| `ChronosHandler` | Type | Handler function signature. |
| `ChronosHandlerResult` | Type | Valid handler return types. |
| `ChronosLogger` | Type | Logger interface. |
| `ChronosOptions` | Type | Constructor options. |
| `ChronosSchedule` | Type | Schedule reference on context. |
| `FetchLike` | Type | Custom fetch signature. |
| `WebhookOptions` | Type | Options for `Webhook` constructor. |
| `DEFAULT_BASE_URL` | Constant | `'https://api.chronos.sh'` |
| `DEFAULT_POLL_WAIT_TIME_SECONDS` | Constant | `20` |
| `DEFAULT_TIMEOUT_MS` | Constant | `30000` |
| `SDK_VERSION` | Constant | Package version string, reported to the API on every claim. |
| `DEFAULT_TIMESTAMP_TOLERANCE_SECONDS` | Constant | `300` |
# REST API
> Full HTTP API reference. Endpoints, request/response shapes, and errors.
Complete reference for the Chronos developer-facing API.
## Conventions
[Section titled “Conventions”](#conventions)
| Convention | Value |
| -------------- | ---------------------------------------------------------------------- |
| Base URL | `https://api.chronos.sh` |
| Authentication | `Authorization: Bearer chrns_your_api_key` |
| Content type | `application/json` |
| Field casing | `snake_case` |
| IDs | UUID |
| Timestamps | ISO 8601 |
| Pagination | `page` + `size` query params (defaults: page 1, size 20, max size 100) |
### Response envelope
[Section titled “Response envelope”](#response-envelope)
All responses use a consistent envelope:
```json
{
"success": true,
"message": "Human-readable message",
"data": { ... }
}
```
Paginated responses add:
```json
{
"meta": {
"paging": { "page": 1, "pages": 5, "size": 20, "total": 100 }
}
}
```
Error responses:
```json
{
"success": false,
"message": "What went wrong",
"code": "error_code",
"errors": [{ "field": "name", "message": "is required" }]
}
```
Any endpoint can also return:
* `unauthorized` (401): the API key is missing, malformed, unknown, or revoked.
* `validation_error` (422): a path parameter, query parameter, or body field failed validation.
### Rate limiting
[Section titled “Rate limiting”](#rate-limiting)
Approximately 100 requests per 10 seconds per client IP. Every response includes rate-limit headers; treat them as the authoritative view of your budget:
| Header | Description |
| ----------------------- | ----------------------------------------- |
| `X-RateLimit-Limit` | Maximum requests allowed per window. |
| `X-RateLimit-Remaining` | Requests remaining in the current window. |
| `X-RateLimit-Reset` | Seconds until the current window resets. |
When the limit is exceeded, the API returns `429` with code `rate_limit_exceeded` and a `Retry-After` header indicating how many seconds to wait before retrying.
***
## Schedules
[Section titled “Schedules”](#schedules)
### Create Schedule
[Section titled “Create Schedule”](#create-schedule)
```http
POST /v1/schedules
```
Creates a recurring schedule. Exactly one of `cron` or `interval` is required.
**Request body:**
| Field | Type | Required | Default | Description |
| ----------------------- | ------- | -------------------------- | ------------------ | --------------------------------------------------------------------------------- |
| `name` | string | Yes | — | Display name. Max 255 chars. |
| `description` | string | No | `null` | Max 1000 chars. |
| `handler` | string | Required for pull delivery | — | Routing key. Max 255 chars. |
| `cron` | string | XOR with `interval` | — | Cron expression. |
| `interval` | object | XOR with `cron` | — | `{ value: int, unit: string }` |
| `interval.value` | integer | Yes (if interval) | — | Positive integer. |
| `interval.unit` | string | Yes (if interval) | — | `second`, `minute`, `hour`, `day`, `week` |
| `timezone` | string | No | Account timezone | IANA timezone (e.g., `America/New_York`). |
| `runs` | integer | No | `null` (unlimited) | Max jobs to create before completing. |
| `stop_at` | string | No | `null` | ISO 8601 date. Must be in the future. |
| `delivery` | object | No | `{ type: "pull" }` | Delivery configuration. |
| `delivery.type` | string | Yes (if delivery) | — | `pull` or `push` |
| `delivery.http` | object | Required if push | — | HTTP delivery config. |
| `delivery.http.url` | string | Yes | — | HTTPS URL. No private/localhost IPs. |
| `delivery.http.method` | string | Yes | — | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
| `delivery.http.headers` | object | No | — | Custom headers (`Record`). |
| `payload` | object | No | `null` | Job payload. Max 64KB serialized. Must be non-empty if provided. |
| `timeout` | integer | No | `30` | Seconds. Positive integer. Above your plan’s limit returns `plan_limit_exceeded`. |
| `max_retries` | integer | No | `3` | Min 0. |
`cron` and `interval` must not resolve below your plan’s minimum interval. See [Pricing & Limits](/docs/pricing-limits#plans).
**Response:** `201 Created`
```json
{
"success": true,
"message": "Schedule created",
"data": {
"id": "uuid",
"account_id": "uuid",
"name": "string",
"description": "string | null",
"handler": "string | null",
"cron": "string | null",
"interval": "{ value, unit } | null",
"timezone": "string",
"runs": "number | null",
"run_count": 0,
"stop_at": "ISO 8601 | null",
"delivery": { "type": "pull | push", "http": "{ url, method, headers? } | undefined" },
"payload": "object | null",
"timeout": 30,
"max_retries": 3,
"status": "active",
"next_run_at": "ISO 8601 | null",
"last_run_at": "ISO 8601 | null",
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}
}
```
**Errors:**
* `validation_error` (422): invalid or missing fields
* `plan_limit_exceeded` (403): active-schedule limit reached, `timeout` over your plan’s cap, or cadence below your plan’s minimum interval
* `rate_limit_exceeded` (429)
***
### List Schedules
[Section titled “List Schedules”](#list-schedules)
```http
GET /v1/schedules
```
**Query params:**
| Param | Type | Default | Description |
| -------- | ------- | ------- | --------------------------------------------------------------- |
| `page` | integer | `1` | Page number. |
| `size` | integer | `20` | Items per page. Max 100. |
| `status` | string | — | Filter: `active`, `paused`, `completed`, `archived` |
| `search` | string | — | Case-insensitive partial match on schedule name. Max 255 chars. |
Returns all schedules including archived unless filtered by status. Ordered newest first: `created_at` descending, then `id` descending.
**Response:** `200 OK`. paginated array of schedule objects.
***
### Get Schedule
[Section titled “Get Schedule”](#get-schedule)
```http
GET /v1/schedules/:id
```
**Response:** `200 OK`. single schedule object.
**Errors:**
* `schedule_not_found` (404)
***
### Update Schedule
[Section titled “Update Schedule”](#update-schedule)
```http
PATCH /v1/schedules/:id
```
All fields optional. At least one field required.
| Field | Type | Description |
| ------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Max 255 chars. |
| `description` | string \| null | `null` clears. Max 1000 chars. |
| `handler` | string \| null | `null` clears. Max 255 chars. The API returns `400 handler_required_for_pull` if the schedule uses pull delivery after the update. |
| `cron` | string | Cannot combine with `interval` in same request. |
| `interval` | object | Cannot combine with `cron` in same request. |
| `timezone` | string | IANA timezone. |
| `runs` | integer \| null | `null` removes run limit. |
| `stop_at` | string \| null | `null` removes stop time. |
| `delivery` | object | Same shape as create. |
| `payload` | object \| null | `null` clears. Max 64KB. |
| `timeout` | integer | Positive. Above your plan’s limit returns `plan_limit_exceeded`. |
| `max_retries` | integer | Min 0. |
If `cron` or `interval` changes, `next_run_at` is recomputed. If `timezone` changes, `next_run_at` is recomputed for cron schedules.
**Response:** `200 OK`. updated schedule object.
**Errors:**
* `schedule_not_found` (404)
* `schedule_archived` (409): cannot update archived schedules
* `schedule_completed` (409): cannot update completed schedules
* `handler_required_for_pull` (400): pull delivery requires a handler
* `schedule_status_changed` (409): optimistic concurrency conflict
* `plan_limit_exceeded` (403): `timeout` over your plan’s cap, or cadence below your plan’s minimum interval
* `validation_error` (422)
***
### Trigger Schedule
[Section titled “Trigger Schedule”](#trigger-schedule)
```http
POST /v1/schedules/:id/trigger
```
No request body. Creates a job from the schedule immediately, bypassing its timing. Increments `run_count`: a manual trigger consumes a run, and completes the schedule if it reaches the `runs` limit.
**Response:** `201 Created`. the created job object.
**Errors:**
* `schedule_not_found` (404)
* `schedule_archived` (409): cannot trigger archived schedules
* `schedule_completed` (409): cannot trigger completed schedules
* `plan_limit_exceeded` (403): the stored `timeout` exceeds your plan’s cap (after a downgrade)
***
### Pause Schedule
[Section titled “Pause Schedule”](#pause-schedule)
```http
POST /v1/schedules/:id/pause
```
No request body. No new jobs are spawned while paused. Jobs already spawned are unaffected.
**Response:** `200 OK`. schedule object with `status: "paused"`.
**Errors:**
* `schedule_not_found` (404)
* `schedule_already_paused` (409)
* `schedule_archived` (409)
* `schedule_completed` (409)
***
### Resume Schedule
[Section titled “Resume Schedule”](#resume-schedule)
```http
POST /v1/schedules/:id/resume
```
No request body. Recomputes `next_run_at` from the current time; interval schedules re-anchor to the resume time, cron schedules keep their alignment. If the schedule’s `runs` limit is reached or `stop_at` falls before the next run, the request fails with `schedule_completed`.
**Response:** `200 OK`. schedule object with `status: "active"`.
**Errors:**
* `schedule_not_found` (404)
* `schedule_already_active` (409)
* `schedule_archived` (409)
* `schedule_completed` (409)
* `schedule_cadence_invalid` (422): the stored cron, interval, or timezone can no longer be computed; update the schedule before resuming
* `plan_limit_exceeded` (403): resuming would exceed your plan’s active-schedule limit, or the stored `timeout` or cadence no longer fits your plan (after a downgrade)
***
### Archive Schedule
[Section titled “Archive Schedule”](#archive-schedule)
```http
POST /v1/schedules/:id/archive
```
No request body. Cancels all pending/queued/ready/retrying jobs for this schedule.
**Response:** `200 OK`. schedule object with `status: "archived"`.
**Errors:**
* `schedule_not_found` (404)
* `schedule_archived` (409): already archived
***
### Delete Schedule
[Section titled “Delete Schedule”](#delete-schedule)
```http
DELETE /v1/schedules/:id
```
Permanently deletes the schedule. Associated jobs have their `schedule_id` set to `null`.
**Response:** `204 No Content`
**Errors:**
* `schedule_not_found` (404)
***
## Jobs
[Section titled “Jobs”](#jobs)
### Create Job
[Section titled “Create Job”](#create-job)
```http
POST /v1/jobs
```
Creates a one-off job. Runs immediately unless `delay` or `scheduled_for` is set.
**Request body:**
| Field | Type | Required | Default | Description |
| ----------------- | ------- | ----------------- | ------------------ | --------------------------------------------------------------------------------- |
| `name` | string | Yes | — | Display name. Max 255 chars. |
| `handler` | string | Required for pull | — | Routing key. Max 255 chars. |
| `delay` | object | No | — | Relative timing. XOR with `scheduled_for`. |
| `delay.value` | integer | Yes (if delay) | — | Positive integer. |
| `delay.unit` | string | Yes (if delay) | — | `second`, `minute`, `hour`, `day`, `week` |
| `scheduled_for` | string | No | — | Absolute ISO 8601 time. Must be in the future. XOR with `delay`. |
| `delivery` | object | No | `{ type: "pull" }` | Same shape as schedules. |
| `payload` | object | No | `null` | Max 64KB. Must be non-empty if provided. |
| `timeout` | integer | No | `30` | Seconds. Positive integer. Above your plan’s limit returns `plan_limit_exceeded`. |
| `max_retries` | integer | No | `3` | Min 0. |
| `idempotency_key` | string | No | `null` | Dedup key. Max 255 chars. Scoped to your account. |
Timing options:
* Neither `delay` nor `scheduled_for` → runs immediately
* `delay` → runs after the specified duration
* `scheduled_for` → runs at the specified time
**Response:** `201 Created`
```json
{
"success": true,
"message": "Job created",
"data": {
"id": "uuid",
"account_id": "uuid",
"schedule_id": null,
"origin": "api",
"name": "string",
"handler": "string | null",
"idempotency_key": "string | null",
"scheduled_for": "ISO 8601",
"run_at": "ISO 8601",
"delivery": { "type": "pull | push", "http": "..." },
"payload": "object | null",
"timeout": 30,
"max_retries": 3,
"attempt_count": 0,
"status": "pending",
"last_error": "string | null",
"started_at": "ISO 8601 | null",
"completed_at": "ISO 8601 | null",
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}
}
```
**Errors:**
* `idempotency_key_conflict` (409): duplicate key on same account
* `plan_limit_exceeded` (403): `timeout` over your plan’s cap
* `validation_error` (422)
***
### List Jobs
[Section titled “List Jobs”](#list-jobs)
```http
GET /v1/jobs
```
**Query params:**
| Param | Type | Default | Description |
| ------------------ | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
| `page` | integer | `1` | Page number. |
| `size` | integer | `20` | Max 100. |
| `schedule_id` | string | — | UUID. |
| `origin` | string | — | `api` for one-off jobs, `schedule` for schedule-spawned jobs. A spawned job keeps `schedule` after its schedule is deleted. |
| `search` | string | — | Case-insensitive partial match on job name. Max 255 chars. |
| `handler` | string | — | Filter by handler name. |
| `status` | string | — | `pending`, `admitting`, `queued`, `ready`, `running`, `retrying`, `completed`, `failed`, `cancelled`, `skipped` |
| `scheduled_before` | string | — | ISO 8601 upper bound. |
| `scheduled_after` | string | — | ISO 8601 lower bound. Must be ≤ `scheduled_before`. |
Ordered newest first: `created_at` descending, then `id` descending.
**Response:** `200 OK`. paginated array of job objects.
***
### Get Job
[Section titled “Get Job”](#get-job)
```http
GET /v1/jobs/:id
```
**Response:** `200 OK`. single job object.
**Errors:**
* `job_not_found` (404)
***
### Cancel Job
[Section titled “Cancel Job”](#cancel-job)
```http
POST /v1/jobs/:id/cancel
```
No request body. The job record is preserved for audit.
**Response:** `200 OK`. job object with `status: "cancelled"`.
**Errors:**
* `job_not_found` (404)
* `job_not_cancellable` (409): job must be `pending`, `admitting`, `queued`, `ready`, or `retrying`
* `job_status_changed` (409): concurrent update detected; retry the operation
***
### Skip Job
[Section titled “Skip Job”](#skip-job)
```http
POST /v1/jobs/:id/skip
```
No request body. Only schedule-spawned jobs can be skipped; cancel one-off jobs instead.
**Response:** `200 OK`. job object with `status: "skipped"`.
**Errors:**
* `job_not_found` (404)
* `job_not_scheduled` (409): the job is a one-off job
* `job_not_skippable` (409): job must be `pending` or `queued`
* `job_status_changed` (409): concurrent update detected; retry the operation
***
### Retry Job
[Section titled “Retry Job”](#retry-job)
```http
POST /v1/jobs/:id/retry
```
No request body. Re-queues a failed job to run immediately; a new execution is created when it runs. The retry budget is not reset: `attempt_count` carries over, so a job that exhausted `max_retries` gets one more attempt, not a fresh set.
**Response:** `200 OK`. job object with `status: "pending"`.
**Errors:**
* `job_not_found` (404)
* `job_not_failed` (409): only failed jobs can be retried
* `job_status_changed` (409): concurrent update detected; retry the operation
***
## Executions
[Section titled “Executions”](#executions)
### List Executions
[Section titled “List Executions”](#list-executions)
```http
GET /v1/executions
```
**Query params:**
| Param | Type | Default | Description |
| ---------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `page` | integer | `1` | Page number. |
| `size` | integer | `20` | Max 100. |
| `job_id` | string | — | UUID. |
| `schedule_id` | string | — | UUID, or `"null"` for executions of jobs with no linked schedule (one-off jobs, and spawned jobs whose schedule was deleted). |
| `handler` | string | — | Filter by handler name. |
| `status` | string | — | `pending`, `running`, `completed`, `failed`, `timeout` |
| `created_before` | string | — | ISO 8601 upper bound. |
| `created_after` | string | — | ISO 8601 lower bound. Must be ≤ `created_before`. |
Ordered newest first: `created_at` descending, then `id` descending.
**Response:** `200 OK`
```json
{
"success": true,
"message": "Executions fetched",
"data": [{
"id": "uuid",
"account_id": "uuid",
"job_id": "uuid",
"status": "pending | running | completed | failed | timeout",
"trigger": "system | system_retry | manual",
"started_at": "ISO 8601 | null",
"completed_at": "ISO 8601 | null",
"duration_ms": "number | null",
"result": "object | null",
"error": "string | null",
"response_code": "number | null",
"created_at": "ISO 8601"
}],
"meta": { "paging": { ... } }
}
```
***
### Get Execution
[Section titled “Get Execution”](#get-execution)
```http
GET /v1/executions/:id
```
**Response:** `200 OK`. single execution object.
**Errors:**
* `execution_not_found` (404)
***
## Workers
[Section titled “Workers”](#workers)
### List Workers
[Section titled “List Workers”](#list-workers)
```http
GET /v1/workers
```
Returns registered workers for your account with derived online/offline status. A worker is `online` if it polled within `max(poll_wait_seconds * 3, 90)` seconds or holds a running execution whose lease has not expired. Otherwise it is `offline`. Chronos deletes workers not seen for 7 days. Ordered newest first: `created_at` descending, then `id` descending.
**Query params:**
| Param | Type | Default | Description |
| --------- | ------- | ------- | --------------------------- |
| `page` | integer | `1` | Page number. |
| `size` | integer | `20` | Items per page. Max 100. |
| `status` | string | — | Filter: `online`, `offline` |
| `handler` | string | — | Filter by handler name. |
**Response:** `200 OK`
```json
{
"success": true,
"message": "Workers fetched",
"data": [
{
"id": "uuid",
"worker_id": "w_a1b2c3",
"sdk_version": "0.0.2",
"runtime": "node/v22.1.0",
"handlers": ["echo", "slow-job"],
"poll_wait_seconds": 20,
"uptime_ms": 3600000,
"status": "online",
"last_seen_at": "2026-06-20T12:00:00.000Z",
"created_at": "2026-06-20T11:00:00.000Z"
}
],
"meta": {
"paging": { "page": 1, "pages": 1, "size": 20, "total": 1 }
}
}
```
***
### Get Worker
[Section titled “Get Worker”](#get-worker)
```http
GET /v1/workers/:id
```
**Response:** `200 OK`. Single worker object with derived `status`.
**Errors:**
* `worker_not_found` (404)
***
### Claim Job
[Section titled “Claim Job”](#claim-job)
These endpoints are used by the SDK internally. You can also call them directly if building a custom worker.
```http
POST /v1/workers/jobs/claim
```
Claims the next available job for your registered handlers.
**Request body:**
| Field | Type | Required | Default | Description |
| -------------------- | --------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `wait_time_seconds` | integer | No | `0` | Long-poll duration. 0–20. |
| `handlers` | string\[] | No | — | Filter by handler names. Min 1 item if provided. Each max 255 chars. |
| `worker` | object | No | — | Identifies the polling worker. Each claim that sends it records the worker in [List Workers](#list-workers). |
| `worker.id` | string | Yes (if worker) | — | Stable worker identifier you choose. Max 255 chars. |
| `worker.sdk_version` | string | No | — | Max 50 chars. |
| `worker.runtime` | string | No | — | Max 100 chars. |
| `worker.uptime_ms` | integer | No | — | Min 0. |
When `wait_time_seconds > 0`, the request holds open until a job is available or the timeout expires. The SDK uses 20 seconds by default.
**Response (job claimed):** `200 OK`
```json
{
"success": true,
"message": "Job claimed",
"data": {
"job_id": "uuid",
"execution_id": "uuid",
"handler": "string",
"scheduled_for": "ISO 8601",
"attempt": 1,
"timeout": 30,
"payload": "object | null",
"schedule": "{ id: uuid, name: string } | null"
}
}
```
**Response (no job available):** `200 OK`
```json
{
"success": true,
"message": "No job available",
"data": null
}
```
**Errors:**
* `plan_limit_exceeded` (403): a job is ready but your plan’s execution quota is used up
***
### Report Result
[Section titled “Report Result”](#report-result)
```http
POST /v1/workers/executions/:id/result
```
Reports the outcome of a claimed job’s execution.
**Request body:**
| Field | Type | Required | Description |
| -------- | ------ | ---------------------- | ----------------------------------------------------------------------- |
| `status` | string | Yes | `completed`, `failed`, or `timeout` |
| `result` | object | No | Execution result data. Allowed only when `completed`. |
| `error` | string | Required when `failed` | Error message. Max 4096 chars. Forbidden for `completed` and `timeout`. |
**Business rules:**
* Same-status re-report is idempotent (returns 200, no-op)
* On `failed` with retries remaining: job transitions to `retrying`, new execution scheduled with exponential backoff
* On `failed` with retries exhausted: job transitions to `failed`
**Response:** `200 OK`
```json
{
"success": true,
"message": "Result recorded",
"data": null
}
```
**Errors:**
* `execution_not_found` (404)
* `execution_not_running` (409): execution is not in `running` status and the reported status differs from its current one
***
## Dashboard-Only Endpoints
[Section titled “Dashboard-Only Endpoints”](#dashboard-only-endpoints)
Some of the API surface is session-authenticated and reserved for the dashboard; API keys cannot call it. This covers account and team management, billing, alert settings, signing key and API key management, the activity feed, and webhook URL testing.
Manage these through the [dashboard](https://app.chronos.sh). In particular, API-key clients cannot read, create, rotate, or revoke keys. See the [Signature Verification](/docs/guides/signature-verification) guide for using your signing secret.