Authentication
The Clockwork API accepts three kinds of credential:
| Credential | For | Looks like | Scope-checked | Rate limited |
|---|---|---|---|---|
| API key | Server-side integrations | cw_live_…, cw_test_… (organization-owned), or legacy ck_live_… | yes | yes, per key |
| Session token | Browser apps where users sign in with Supabase | a JWT | no (full access to the user's own data) | no |
| Guest token | One person with no account, for one job | cg_live_… | by the actions it was minted with | yes, per token |
Planning v2 availability. Merged to
mainand deployed to production (platform.clockwork-co.com; F1 to F7 and F9 to F11 on 2026-10-09, F12 pool assignment and the security fixes on 2026-10-12). The platform flagCLOCKWORK_PLANNING_V2is currently on globally, in production and in preview, as of 2026-10-12: every organization gets planning-v2 behavior, and with the flag off the planning-v2 endpoints answer404. Per-organization gating (CLOCKWORK_PLANNING_V2=orgs) is merged but not enabled in production, so the global flag is the only access control in effect. Exact dates and commits: Changelog.
API keys
Organization-owned keys start with cw_live_ or cw_test_; the older user-owned keys start with ck_live_. You create them in the developer console (API Keys) or with POST /management/organizations/{id}/api-keys (see below). The raw key is shown once: only its SHA-256 hash is stored.
const client = new Clockwork({
apiKey: 'cw_live_abc123...',
})When making raw HTTP requests, pass the key as a Bearer token:
curl https://platform.clockwork-co.com/api/v1/tasks \
-H "Authorization: Bearer cw_live_abc123..."Or as the x-api-key header:
curl https://platform.clockwork-co.com/api/v1/tasks \
-H "x-api-key: cw_live_abc123..."A key that is unknown, revoked or expired answers 401 UNAUTHORIZED. Revocation takes effect on the next request (nothing is cached).
Project-bound keys
A key can be bound to one project (a tenant-app key). Everything it creates (jobs, tasks, resources, constraints, artifacts, and so on) goes into that project when you do not pass a projectId, instead of your personal project, so the tasks it creates are the tasks it can read and change. Passing a projectId other than the bound project answers 403 FORBIDDEN ("API key is bound to a different project"). Unbound keys and session tokens still default to your personal project.
Key security
- Store keys in environment variables, never in source code
- Keys are hashed before storage — Clockwork cannot retrieve the raw secret after creation
- Revoke keys immediately from the console if compromised
- Give each integration its own key with only the scopes it needs
Create a restricted key
An organization admin can mint a key with a narrow scope list, use it, and revoke it. This excerpt is from a script that runs against a real server:
const { data: me } = await client.me.get()
const orgId = me.organizations[0]?.id
if (!orgId) throw new Error('the test key owner needs an organization')
const created = await call('POST', `/management/organizations/${orgId}/api-keys`, { body: { name: 'docs-example read-only', environment: 'test', scopes: ['tasks:read'] } })
if (created.status !== 201) fail(`create key: ${created.status} ${JSON.stringify(created.json)}`)
const readOnly = created.json.data.raw as string
const keyId = created.json.data.id as string
console.log('created key prefix:', readOnly.slice(0, 8), '| stored rateLimit:', created.json.data.rateLimit, '| scopes:', JSON.stringify(created.json.data.scopes))
const ro = { authorization: `Bearer ${readOnly}` }
const canRead = await call('GET', '/tasks', { auth: ro })
const cannotWrite = await call('POST', '/tasks', { auth: ro, body: { name: 'nope' } })
const meScope = await call('GET', '/me', { auth: ro })
console.log('read-only key: GET /tasks ->', canRead.status, '| POST /tasks ->', cannotWrite.status, JSON.stringify(cannotWrite.json), '| GET /me ->', meScope.status)The read-only key can GET /tasks (200) but POST /tasks is refused with 403 FORBIDDEN and the message API key is missing required scope: tasks:write. GET /me always works for any authenticated key. The helpers call and fail are defined at the top of docs-examples/auth-keys-and-limits.mts in the Clockwork repository, which this page's snippets are copied from.
Scopes
Each API key has a list of scopes of the form {resource}:{action}. A request that needs a scope the key lacks returns 403 FORBIDDEN (API key is missing required scope: tasks:write). * grants everything. A scope is matched exactly: tasks:* is not a wildcard, and the table below writes x:* only as shorthand for x:read plus x:write. Scope names are not validated when a key is created, so a misspelled scope makes a key that can do nothing. Session tokens skip scope checks. GET /me is allowed for any authenticated caller.
| Scope | Grants |
|---|---|
tasks:read / tasks:write | Tasks, dependencies; also POST /schedule/simulate (read) and task batches (write) |
executions:read / executions:write | Jobs (/workflow-instances), including /workflow-instances/graph |
resources:read / resources:write | Resources, allocations, auto-assign |
resource-types:*, capabilities:*, task-requirements:* | Resource types, capabilities, capability requirements |
constraints:*, conflicts:*, opportunities:* | Constraints, conflicts, opportunities |
templates:read / templates:write | Workflow templates, versions, aliases, publishing |
workflows:* | Legacy /workflows |
events:read | GET /events, POST /events/replay, GET /events/stream |
webhooks:read / webhooks:write | Event subscriptions (/events/subscriptions) and webhook registrations |
tokens:read / tokens:write | Guest tokens: tokens:write mints (POST /tokens) and revokes (DELETE /tokens/{id}), tokens:read lists (GET /tokens). A project-bound key can only mint, list and revoke tokens in its own project; revoking a token someone else minted needs organization admin |
conversations:*, approvals:*, artifacts:*, roles:* | The matching resources |
organizations:*, api-keys:*, billing:read, projects:* | Management API (organizations, keys, usage, projects) |
me:read | GET /me — always allowed for any authenticated caller |
Each operation's required scope is listed as x-required-scope in the OpenAPI spec.
Check your scopes
const { data: me } = await client.me.get()
console.log(me.apiKey?.scopes) // ['tasks:read', 'tasks:write', ...]Session tokens
For browser apps where users are authenticated with Supabase, pass a session token instead of an API key:
const client = new Clockwork({
sessionToken: async () => {
const { data } = await supabase.auth.getSession()
return data.session?.access_token ?? null
},
})Session tokens bypass scope checks — authenticated users have full access to their own data — and are not rate limited.
Non-production deployments (previews) accept requests with no credential at all and run them as a shared preview identity. Production never does: a request without credentials is
401.
Guest tokens
A guest token (cg_live_…) lets one person with no Clockwork account — a helper who opens a link — see and act on a few tasks of one job. It is minted with POST /tokens (client.tokens.create, needs tokens:write), bound to one job and to either one resource's tasks or an explicit task list, limited to the actions you list (tasks:read, tasks:complete, tasks:delay, conversations:write), short-lived (60 seconds to 30 days, default 24 hours) and rate limited on its own (1 to 1,000 requests a minute, default 120).
const { data: guest } = await client.tokens.create({ workflowInstanceId: job.instance.id, taskIds: [job.refs.a!], actions: ['tasks:read'], ttlSeconds: 300 })
console.log('guest token prefix:', guest.token.slice(0, 8), '| rateLimit default:', guest.rateLimit, '| expires:', guest.expiresAt)
const g = { authorization: `Bearer ${guest.token}` }
const guestTasks = await call('GET', '/tasks', { auth: g })
const guestMe = await call('GET', '/me', { auth: g })
const guestWrite = await call('POST', '/tasks', { auth: g, body: { name: 'nope' } })The token string is returned once. A guest can read its own tasks (as a reduced view: no people, owners or metadata), but GET /me and POST /tasks answer 403: guests are refused on every route that has not opted in. The only routes that accept a guest are GET /tasks, GET /tasks/{id}, POST /tasks/{id}/complete, POST /tasks/{id}/delay, POST /conversations, POST /conversations/{id}/messages and GET /events/stream, each confined to the token's scope. Revoking a token (client.tokens.revoke(id)) stops it on its next request.
Guest tokens only exist while the platform has planning v2 enabled; with it off they answer 401. See the Guest Access guide for the full model.
Planning v2 access
Planning v2 endpoints (job graphs, batches, simulation, the event stream, guest tokens) are on for everyone, for allow-listed organizations only, or off, depending on how the platform is configured. Ask the platform whether they are available to you: GET /me (client.me.get()) returns planningV2 for your organization, and an endpoint that is not enabled answers 404. Responses from an enabled endpoint carry x-clockwork-planning: v2.
An organization admin can switch it on for the organization when the platform runs in per-organization mode:
curl -X PATCH "$CLOCKWORK_URL/api/v1/management/organizations/$ORG_ID" \
-H "Authorization: Bearer $CLOCKWORK_API_KEY" -H 'content-type: application/json' \
-d '{ "metadata": { "planningV2": true } }'The change applies at once on the server that handled it and within a minute everywhere else.
Rate limits
Every request is counted in a fixed 60-second window. The window is aligned to the clock (minute boundaries), not to your first request.
| Credential | Budget per window | Where the number comes from |
|---|---|---|
| API key | 1,000 by default | the key's own rate_limit value (api_keys.rate_limit, default 1000), checked per key id |
API key, POST /schedule/simulate | 5× the key's budget (5,000 by default), in its own bucket | SIMULATE_RATE_MULTIPLIER = 5; simulate does not consume the shared budget, and is not blocked while the shared budget is exhausted (observed) |
| Guest token | 120 by default, 1–1,000 chosen when minting | the token's rateLimit; its own bucket per token, never an API key's budget |
| Session token | not limited | no check runs for session callers |
The limit is a property of the key, not of a plan: nothing in the request path reads a plan or tier, so two keys differ only if their stored rate_limit differs. Legacy ck_live_ keys can be created with a custom rateLimit by the web app's key endpoint; organization keys created through POST /management/organizations/{id}/api-keys take the default (that request has no rateLimit field), and rotating a key keeps its limit. There is no self-service setting for the number: to raise it for a key, ask the Clockwork team and name the key (its id or cw_… prefix, never the secret).
What counts as a request
- One HTTP call is one request, whatever it does. A batch (
workflowInstances.createWithGraph,tasks.createBatch,dependencies.createBatch) that creates 150 tasks and 250 dependencies uses one request of your budget, not 400. Observed against a local server with a rate limiter: aPOST /tasks/batchof 50 tasks loweredRateLimit-Remainingby exactly 1. - Opening an SSE stream counts as one request; the events it delivers do not. The code sets no cap on concurrent streams per key or per organization.
- A request that is rejected with
429still counts against the window. - A window's limit can be hit twice in quick succession across a minute boundary (the counter resets at the boundary), so size your retries for a burst of up to twice the budget.
When the rate limiter is unavailable
The counters live in Redis (Upstash). If it is not configured, unreachable, or answers with an error:
| Caller | What happens |
|---|---|
| API key | Fails open: every request is allowed and the headers show the full limit as remaining. Observed on a local server with no Redis |
| Guest token | Falls back to a counter in the memory of the server instance. It still enforces the token's limit (observed: a token minted with rateLimit: 3 was refused on its fourth request with no Redis), but each warm instance counts separately, so the effective budget is the limit times the number of warm instances |
Whether the hosted platform has Redis configured is not visible from the API. Do not rely on a limit being enforced, or on it being absent.
Response headers
Responses to API-key and guest-token requests that reach a handler, and 429 responses, include three headers:
| Header | Meaning |
|---|---|
RateLimit-Limit | The budget for this window and this bucket (the simulate endpoint reports 5× the key's budget) |
RateLimit-Remaining | Requests left in the current window (never below 0) |
RateLimit-Reset | Seconds until the current window ends (not a Unix timestamp) |
These responses do not carry them: 401 for an unknown, revoked or expired key; 403 for a missing scope or a guest on a route that does not allow guests; errors the service layer raises as exceptions (for example a 404 for an unknown task, a 409, a 422 from a planner check, a 5xx); and everything a session-token caller receives. A validation 400 that the route returns itself does carry them (all observed). There is no Retry-After header.
This is what the headers look like (local test server, which has no rate limiter configured, so remaining equals limit):
const rl = (h: Headers) => ({ limit: h.get('ratelimit-limit'), remaining: h.get('ratelimit-remaining'), resetSeconds: h.get('ratelimit-reset') })
console.log('GET /me ->', JSON.stringify(rl(viaBearer.headers)))
const sim = await call('POST', '/schedule/simulate', { body: { tasks: [{ ref: 'a', name: 'a', durationMinutes: 5 }] } })
console.log('POST /schedule/simulate ->', sim.status, JSON.stringify(rl(sim.headers)), '(own bucket, 5x the key budget)')When you are limited: 429 and backing off
The API answers 429 Too Many Requests with { "code": "RATE_LIMITED", "message": "Rate limit exceeded" } and the three headers above (RateLimit-Remaining: 0). The limiter runs before the request is handled, so a 429 request has done nothing and is safe to retry, even a POST.
Wait RateLimit-Reset seconds, then retry: trying earlier cannot succeed and counts against the next window too. The SDK does not retry and ClockworkError carries only status, code and details, but the client takes your own fetch, which sees the headers:
export function retryOn429(inner: typeof fetch, opts: BackoffOptions = {}): typeof fetch {
const { maxAttempts = 5, baseMs = 500, maxWaitMs = 65_000, onRetry } = opts
return async (input, init) => {
for (let attempt = 1; ; attempt++) {
const res = await inner(input, init)
if (res.status !== 429 || attempt >= maxAttempts) return res
const resetSec = Number(res.headers.get('ratelimit-reset'))
const fallback = baseMs * 2 ** (attempt - 1) // 500, 1000, 2000, ...
const jitter = Math.random() * 250 // keeps many clients from hitting the next window together
const waitMs = Math.min(maxWaitMs, (Number.isFinite(resetSec) && resetSec > 0 ? resetSec * 1000 : fallback) + jitter)
onRetry?.({ attempt, waitMs: Math.round(waitMs), status: res.status })
await res.body?.cancel()
await new Promise((r) => setTimeout(r, waitMs))
}
}
}
const client = new Clockwork({ apiKey: process.env.CLOCKWORK_API_KEY!, fetch: retryOn429(fetch) })Beyond retrying: spread bulk work over time, prefer a batch call to many single ones, listen to the event stream instead of polling, and give each integration its own key so one cannot use up another's budget.
Runnable: docs-examples/rate-limit-backoff.mts. Run against a local stub that answers 429, it waited the advertised reset time ([1114, 1060] ms for RateLimit-Reset: 1) and, without the header, backed off 585, 1247 and 2185 ms. Run with BURST=1 against a local platform that had a rate limiter and a key limited to 25 a minute, 31 concurrent calls all succeeded after 9 retries that waited about 13 s (the time left in the window).
Error handling
All API errors throw a ClockworkError:
import { Clockwork, ClockworkError } from '@clockwork/sdk'
try {
await client.tasks.get('non-existent-id')
} catch (err) {
if (err instanceof ClockworkError) {
console.error(err.status) // 404
console.error(err.code) // 'NOT_FOUND'
console.error(err.message) // 'Task not found'
}
}Errors have the shape { "code": "...", "message": "..." }. A request body that fails validation is 400 with { "code": "VALIDATION_ERROR", "details": { "issues": [...] } }, and a plan that cannot be built (a dependency cycle, an unknown ref) is 422 with refs naming the offenders. See the OpenAPI spec for every operation's responses.
Related
- Guest Access guide — scoped tokens for people without an account
- Schedule Simulation — the 5× rate-limit bucket
- Batch Graphs — one request for a whole job
- Me (SDK) — inspect your key's scopes
- OpenAPI spec — scopes (
x-required-scope) and responses per operation