Webhooks
Webhooks let Clockwork push events to your server instead of you polling the API. When a task is completed or delayed, a conflict is detected, or a schedule moves, Clockwork sends a signed HTTP POST to your endpoint.
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.
Most of what is on this page (the shared envelope, schedule.changed, the canonical names, delivery filters) needs the platform setting CLOCKWORK_PLANNING_V2; it is on in production and preview. See Behind the flag for what you receive with it off. The examples on this page were run against a platform with it on.
1. Register an endpoint
const { data: sub } = await client.events.subscribe({
url: 'https://yourapp.com/webhooks/clockwork',
events: ['task.completed', 'task.delayed', 'conflict.detected', 'schedule.changed'],
})
// Save the secret — it's only shown once at creation
console.log('Secret:', sub.secret) // whsec_abc123...Store sub.secret in your environment variables. You'll use it to verify every incoming request. Use https:// in production. The URL must be http or https and must not contain credentials (user:pass@host); otherwise the request fails with 400 INVALID_WEBHOOK_URL. On the production platform the host must also not be localhost, *.localhost, *.local, *.internal, a single-word name, or an IP address in a loopback, private, link-local or cloud-metadata range (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10, IPv4-mapped IPv6 and the decimal/hex/octal spellings of those IPv4 addresses). Non-production platforms accept local receivers. The check is repeated when an event is delivered, and redirects are never followed. It looks at the host as written: a public hostname that resolves to a private address (DNS rebinding) is not caught by it.
A subscription receives only events about tasks you are connected to (you are the task's assignee, creator or owner). Narrow it further with workflowInstanceId (one job) and/or resourceId (tasks that use one resource). In the test scenario a subscription filtered to one job received events about that job only:
subs.filtered = await client.events.subscribe({ url: `${receiverUrl}/filtered`, events: ['*'], workflowInstanceId: job.instance.id })2. Verify the signature
Every webhook request includes:
X-Clockwork-Signature: sha256=<hex>— HMAC-SHA256 of the raw body, keyed with your subscription secretX-Clockwork-Event: <event-name>— the event name (the canonical name, e.g.task.delayed)X-Clockwork-Delivery: <uuid>andX-Clockwork-Attempt: <n>— the delivery id and the 1-based attempt number (planning v2; see Delivery guarantees)
Always verify before processing. An invalid signature means the request is not from Clockwork.
export function verifyClockworkSignature(signature: string, secret: string, rawBody: string): boolean {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
// timingSafeEqual throws on different lengths, so compare lengths first.
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature, 'utf8'), Buffer.from(expected, 'utf8'))
}Sign over the exact bytes you received: parse the JSON only after verifying.
3. Handle events
This receiver verifies the signature, acknowledges immediately, ignores a repeated event id, and routes on the event name. eventName() works with the planning v2 envelope and with the original payload (see Behind the flag).
/** The names earlier releases delivered these events under. */
const EARLIER_NAMES: Record<string, string> = {
'task.updated': 'task.delayed',
'task.created': 'task.ready',
'constraint.violated': 'conflict.detected',
}
/** The canonical event name, whichever payload shape arrived. */
export function eventName(payload: ClockworkEvent): string {
return payload.type ?? EARLIER_NAMES[payload.event] ?? payload.event
}
const seen = new Set<string>() // use a database or Redis in production
export const handled: string[] = []
async function processEvent(payload: ClockworkEvent) {
if (payload.id) {
if (seen.has(payload.id)) return // already handled (a delivery can repeat)
seen.add(payload.id)
}
switch (eventName(payload)) {
case 'task.delayed':
handled.push(`task.delayed ${payload.task_id}`)
break
case 'task.completed':
handled.push(`task.completed ${payload.task_id}`)
break
case 'conflict.detected':
handled.push(`conflict.detected ${payload.task_id}`)
break
case 'schedule.changed':
// One event per recompute, listing every task that moved.
for (const change of payload.data?.changes ?? []) handled.push(`moved ${change.taskId}`)
break
}
}
const secret = 'whsec_example'
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8')
const signature = String(req.headers['x-clockwork-signature'] ?? '')
if (!verifyClockworkSignature(signature, secret, rawBody)) return void res.writeHead(401).end('Invalid signature')
// Respond immediately: do the work after acknowledging.
res.writeHead(200).end('OK')
processEvent(JSON.parse(rawBody)).catch(console.error)
})
})The complete, runnable file (including the ClockworkEvent type and a check that it handles both payload shapes, duplicates and bad signatures) is docs-examples/events-webhook-handler.mts in the Clockwork repository.
Event names
Events use one canonical dot-notation name on webhooks, SSE and the event log. The original underscore names (task_delayed, …) are accepted anywhere you give a name (subscriptions, stream and list filters), and so are the names earlier releases delivered the event under, so an existing subscription keeps receiving its events:
Canonical (type, event, X-Clockwork-Event, SSE event:) | Underscore alias | Earlier name (kept in legacy_event) |
|---|---|---|
task.ready | task_ready | task.created |
task.completed | task_completed | — |
task.delayed | task_delayed | task.updated |
conflict.detected | conflict_detected | constraint.violated |
workflow.completed | workflow_completed | — |
schedule.changed | — | — |
conversation.message_created | — | — |
This is verified by running every one of them: subscribing with ['task_delayed'] and with ['task.updated'] each delivered the task.delayed event, with X-Clockwork-Event: task.delayed, and a subscription to * got all of them.
What is actually emitted, and where
SSE streams the whole project's event log; webhooks carry only the events below that are pushed to subscribers. Observed by running one scenario (a three-step job, a delay past a deadline, three completions, a conversation message, a new resource and artifact, a cancelled job, and a legacy /workflows workflow) and reading both transports:
| Event | SSE | Webhook | Emitted when |
|---|---|---|---|
task.delayed | yes | yes | POST /tasks/{id}/delay |
task.completed | yes | yes | POST /tasks/{id}/complete |
task.ready | yes | yes | a direct successor of a completed task; also POST /tasks/{id}/assign |
conflict.detected | yes | yes | a recompute finds conflicts for a task. It repeats on every recompute that still finds one, and carries no conflict details: read GET /conflicts |
schedule.changed | yes | yes | a recompute moved at least one task (once per recompute) |
conversation.message_created | yes | yes | a message is posted to a conversation |
workflow.completed | yes | yes | only for legacy /workflows workflows: once for each task of the workflow when all are done. It is not emitted for jobs (/workflow-instances): completing every task of a job produced none |
workflow.started | yes | no | a job is created with POST /workflow-instances/graph |
conversation.created, conversation.message_added, resource.created, artifact.created | yes | no | the matching write |
Not emitted at all: task.created, task.updated and constraint.violated never appear as an event name, they exist only as the earlier names in legacy_event. workflow.failed (a cancelled job) never appeared on SSE or in events.list, and a job created with POST /workflow-instances produced no event about it at all (the code writes both log rows without a project id).
In the scenario, creating and renaming a task with
POST /tasksandPATCH /tasks/{id}produced notask.createdortask.updatedevent.
SSE sees schedule.changed before task.delayed for the same delay (sequence numbers show it): do not assume the order of events that come from one request.
Webhook payload shape
Every webhook body is the same event envelope an SSE stream sends for that event. The SSE data: line and the webhook body were compared byte for byte for every event in the scenario above and were identical.
interface ClockworkEvent {
id: string // unique event id — use it for idempotency
sequence: number // position in the event log (increasing, not consecutive)
type: string // canonical dot-notation name: 'task.delayed', 'schedule.changed', ...
event: string // same as `type` (the original webhook payload key)
ts: string // ISO 8601 UTC (also `timestamp`)
timestamp: string
projectId: string | null
workflowInstanceId: string | null
subjectType: string | null
subjectId: string | null
data: Record<string, unknown> // event-specific fields
task_id?: string // task events
workflow_id?: string | null
executor?: { platform_user_id: string | null }
legacy_event?: string // the name earlier releases delivered this event under
}A real task.delayed delivery (trimmed):
{
"id": "f4b1bcac-6e37-4fc4-9fcf-7f8b2fd63da0",
"sequence": 248,
"type": "task.delayed",
"event": "task.delayed",
"ts": "2026-10-07T13:05:37.407Z",
"timestamp": "2026-10-07T13:05:37.407Z",
"workflowInstanceId": "527981db-cf4d-40d2-adf1-e3f546ebf871",
"subjectType": "task",
"subjectId": "e0daa685-9485-429b-afe7-3c28d6c47b79",
"data": {
"reason": "press under repair",
"taskId": "e0daa685-9485-429b-afe7-3c28d6c47b79",
"taskName": "Run press",
"previousStart": "2026-10-07T13:35:36.000Z",
"newStart": "2026-10-07T18:35:36.000Z",
"assigneePlatformUserId": null
},
"task_id": "e0daa685-9485-429b-afe7-3c28d6c47b79",
"workflow_id": null,
"executor": { "platform_user_id": null },
"legacy_event": "task.updated"
}task.completed, task.ready and conflict.detected have the same shape; their data is { taskId, taskName, workflowId, workflowInstanceId, assigneePlatformUserId } (the task.delayed extras previousStart, newStart and reason are added for a delay).
schedule.changed
Emitted once per schedule recompute (coalesced), whatever caused it, listing every task whose planned window moved. This one is the same delay as above:
{
"id": "c46fd4ab-6e61-46c5-8064-e2bb931a94e1",
"sequence": 247,
"type": "schedule.changed",
"subjectType": "workflow_instance",
"workflowInstanceId": "527981db-cf4d-40d2-adf1-e3f546ebf871",
"data": {
"reason": "task_delayed",
"workflowInstanceId": "527981db-cf4d-40d2-adf1-e3f546ebf871",
"changes": [
{ "taskId": "e0daa685-9485-429b-afe7-3c28d6c47b79", "reason": "task_delayed",
"previous": { "start": "2026-10-07T13:35:36.000Z", "end": "2026-10-07T14:35:36.000Z" },
"current": { "start": "2026-10-07T18:35:36.000Z", "end": "2026-10-07T19:35:36.000Z" } },
{ "taskId": "2c61ab00-f497-4ad3-b171-088ce2d11495", "reason": "upstream_change",
"previous": { "start": "2026-10-07T14:35:36.000Z", "end": "2026-10-07T15:05:36.000Z" },
"current": { "start": "2026-10-07T19:35:36.000Z", "end": "2026-10-07T20:05:36.000Z" } }
]
}
}reason is task_delayed, task_completed, task_updated, task_created, dependency_changed, requirements_changed, constraint_changed, recompute (an explicit workflowInstances.recompute) or resource_changed (a resource's capacity, availability, status, shared state or pool membership changed); on a change entry it is the trigger for the task that was edited and upstream_change (or replanned, for planner-managed jobs) for tasks that moved because of it. Tasks that were only just created, or are being planned for the first time, are not listed, so previous is never null; creation is announced by task.ready and the instance events.
Delivery guarantees
With planning v2 on, delivery is at-least-once. Everything below is implemented and covered by tests that run a local receiver; see How often retries really run for the part that depends on your plan and traffic.
| Attempts | The first attempt is made right after the event is written and the response is sent. A delivery is a database row (webhook_deliveries, an outbox), so it survives the instance being frozen. If the attempt fails, the row is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (each measured from the previous attempt): 8 attempts in all, then the delivery is dead. |
| What counts as success | Any 2xx. Anything else is a failed attempt. |
| Status codes | 410 Gone: dead at once, and the subscription is disabled. Other 4xx except 408 and 429 (including a 3xx, because redirects are not followed): retried 3 times (4 attempts), then dead. 408, 429, 5xx, a timeout or a network error: the full schedule above. A Retry-After header (seconds or a date, capped at 24 h) can only make the wait longer. |
| Timeouts | Each attempt is cut off after 10 seconds and counts as a failure. |
| Idempotency | The body is byte-identical on every attempt, and its id is stable: use it to ignore repeats. A receiver that answered 200 after doing the work but timed out receives the event again. |
| Headers | X-Clockwork-Event, X-Clockwork-Signature (over the exact body), X-Clockwork-Delivery (the delivery id, the same on every attempt) and X-Clockwork-Attempt (1-based). |
| Auto-disable | A subscription whose deliveries have been dead for 7 days (WEBHOOK_AUTO_DISABLE_DAYS) with no success in between, and that is still failing, is disabled (isActive: false). Re-subscribe after fixing the endpoint. |
| Ordering | Not guaranteed, and a retried event arrives after later ones. Use sequence to order what you have received. |
| Duplicates | Expect them: at-least-once. Dedupe on the event id for exact repeats, and on your own key (task and conflict type) for repeated conditions (conflict.detected is written again on every recompute that still has the conflict; workflow.completed is written once per task). |
| Gaps | After a delivery is dead (or before the subscription existed) use the event log to catch up: every event has a sequence; call events.replay({ fromSequence }) or events.list({ sinceSequence }). sequence is shared across the platform, so it is not consecutive in your stream. |
| Who receives what | A subscription gets an event only if its owner is the assignee, creator or owner of the task concerned (for schedule.changed, of any task that moved). |
| Fan-out | Per event, at most 20 first attempts run right away; further subscriptions are delivered by the next sweep. |
How often retries really run
There is no queue and no scheduler dedicated to retries: Postgres holds the due deliveries and a sweeper delivers them. It runs from three places:
- Request-triggered. After any
/api/v1response, at most once every 30 seconds per server instance, if a delivery is due the instance delivers up to 20 of them in the background. Under steady API traffic retries run close to their schedule. - Daily cron.
GET /api/v1/internal/webhooks/sweepis called once a day by a Vercel cron (apps/clockwork-platform/vercel.json). It needsCRON_SECRETto be set on the platform project (Vercel sends it asAuthorization: Bearer ...); without it the endpoint answers401. The Hobby plan allows a cron to run only once a day, so this is not a precise retry clock. - By hand. Call the same endpoint with
curl -H "Authorization: Bearer $CRON_SECRET" "$PLATFORM_URL/api/v1/internal/webhooks/sweep?limit=200"(any scheduler you own can do this every minute).
So the schedule above is the earliest a retry can run, not a promise. On a quiet platform on the Hobby plan, a retry that falls due at 2 a.m. may wait until the next request or the daily cron. The 24-hour horizon still holds in the sense that a delivery is attempted again on the first sweep after it is due, and goes dead only after its eighth attempt. A first attempt that is lost because its instance was frozen is picked up by the sweeper two minutes later or on the next sweep.
A receiver written for this (verify over the raw bytes, record the work before answering 2xx, ignore a repeated event id) is docs-examples/webhook-retries.mts. It runs without inbound network by simulating the sender: it shows the receiver side only, the retry schedule itself is covered by the platform's database-backed tests.
Reading the delivery log
const { data: rows, nextCursor } = await client.events.listDeliveries(sub.id, { status: 'failed', limit: 20 })
// [{ id, eventId, event, attempt, status, responseStatus, nextAttemptAt, lastError, createdAt }]GET /api/v1/events/subscriptions/{id}/deliveries (newest first, one row per event) is available to the subscription's owner only; another user's subscription is a 404. status is pending, delivered, failed (the last attempt failed and nextAttemptAt is the retry), or dead. lastError is a short reason with URL credentials and query strings removed. Rows older than the switch to planning v2 show as a single finished attempt.
Behind the flag
With CLOCKWORK_PLANNING_V2 off (the code default; it is on in production and preview as of 2026-10-12) deliveries keep the original shape: { event, task_id, workflow_id, executor, timestamp } with no id, no sequence, no type and no data. event carries the earlier name (task.updated for a delay, constraint.violated for a conflict, task.created for a ready task), only the five underscore subscription names (and *) are accepted, and schedule.changed, conversation.message_created and the delivery filters do not exist. Once it is on, event is the canonical name and the full envelope is sent; a handler that switched on payload.event === 'task.updated' should use eventName() above (or payload.type, or payload.legacy_event).
The original-shape payload above is taken from the code (it could not be observed live because the test platform runs with the flag on). The eventName() handler was run against both shapes.
Managing subscriptions
// List active subscriptions
const { data: subs } = await client.events.listSubscriptions()
// Delivery log of one subscription (planning v2)
const { data: deliveries } = await client.events.listDeliveries(subs[0].id)
// Delete a subscription
await client.events.unsubscribe('sub_abc123')(The older POST /management/webhooks endpoint still exists. It accepts only the five underscore names and *, with no filters: use events.subscribe for everything on this page.)
Wildcard subscription
Subscribe to all events with '*':
await client.events.subscribe({
url: 'https://yourapp.com/webhooks/clockwork',
events: ['*'],
})* delivers every event in the table above that has a webhook column of "yes".
Available events
Names you can put in events: *, task.ready, task.completed, task.delayed, conflict.detected, workflow.completed, schedule.changed, conversation.message_created, the underscore aliases (task_ready, task_completed, task_delayed, conflict_detected, workflow_completed) and the earlier names (task.created, task.updated, constraint.violated).
Related
- Events SDK reference — the SSE stream,
listandreplay - Event-Driven Patterns — using the stream for real-time UIs
- Guest Access — a guest's stream sees only its own tasks
- Authentication — scopes (
webhooks:read/webhooks:write) and rate limits - Changelog — what is merged and what is deployed