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 main and 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 flag CLOCKWORK_PLANNING_V2 is 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 answer 404. 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

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

typescript
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 secret
  • X-Clockwork-Event: <event-name> — the event name (the canonical name, e.g. task.delayed)
  • X-Clockwork-Delivery: <uuid> and X-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.

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

typescript
/** 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 aliasEarlier name (kept in legacy_event)
task.readytask_readytask.created
task.completedtask_completed—
task.delayedtask_delayedtask.updated
conflict.detectedconflict_detectedconstraint.violated
workflow.completedworkflow_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:

EventSSEWebhookEmitted when
task.delayedyesyesPOST /tasks/{id}/delay
task.completedyesyesPOST /tasks/{id}/complete
task.readyyesyesa direct successor of a completed task; also POST /tasks/{id}/assign
conflict.detectedyesyesa recompute finds conflicts for a task. It repeats on every recompute that still finds one, and carries no conflict details: read GET /conflicts
schedule.changedyesyesa recompute moved at least one task (once per recompute)
conversation.message_createdyesyesa message is posted to a conversation
workflow.completedyesyesonly 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.startedyesnoa job is created with POST /workflow-instances/graph
conversation.created, conversation.message_added, resource.created, artifact.createdyesnothe 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 /tasks and PATCH /tasks/{id} produced no task.created or task.updated event.

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.

typescript
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):

json
{
  "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:

json
{
  "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.

AttemptsThe 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 successAny 2xx. Anything else is a failed attempt.
Status codes410 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.
TimeoutsEach attempt is cut off after 10 seconds and counts as a failure.
IdempotencyThe 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.
HeadersX-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-disableA 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.
OrderingNot guaranteed, and a retried event arrives after later ones. Use sequence to order what you have received.
DuplicatesExpect 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).
GapsAfter 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 whatA 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-outPer 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:

  1. Request-triggered. After any /api/v1 response, 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.
  2. Daily cron. GET /api/v1/internal/webhooks/sweep is called once a day by a Vercel cron (apps/clockwork-platform/vercel.json). It needs CRON_SECRET to be set on the platform project (Vercel sends it as Authorization: Bearer ...); without it the endpoint answers 401. The Hobby plan allows a cron to run only once a day, so this is not a precise retry clock.
  3. 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

typescript
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

typescript
// 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 '*':

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