Events

The Events resource gives you access to everything that happens in Clockwork as an ordered, append-only log. You can stream events in real time, replay past events for recovery, or subscribe a webhook endpoint to receive push notifications.

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.

The stream (GET /events/stream), schedule.changed, the shared event envelope, delivery filters and conversation.message_created need the platform flag CLOCKWORK_PLANNING_V2 (the stream answers 404 with it off). events.list, events.replay and subscriptions work without it. Required scopes: events:read for list, replay and stream; webhooks:read / webhooks:write for subscriptions.

When to use client.events

  • Building a real-time dashboard that reacts to task completions and delays
  • Triggering downstream systems when a task finishes or a schedule moves
  • Replaying events to rebuild state after an outage
  • Auditing workflow history for compliance

Methods

MethodDescription
list(opts?)Fetch recent events, newest first, optionally filtered
stream(opts?)Open a real-time SSE stream
replay(input?)Replay events in order from a sequence cursor
subscribe(input)Register a webhook endpoint
listSubscriptions()List active subscriptions
unsubscribe(id)Deactivate a subscription

events.stream()

Returns an async generator that yields events as they occur. The connection is a Server-Sent Events (SSE) stream. This script follows one job and receives the delay and the schedule change it caused; it starts the stream after the delay and still gets both, because it resumes from a sequence number:

typescript
// Where the log is now. Anything after this sequence is "new" to us.
const head = (await client.events.list({ limit: 1 })).data[0]?.sequence ?? 0
 
// Cause something to happen before we even connect: the stream replays it from `sinceSequence`.
await client.tasks.delay(job.refs.prepare!, { delayMinutes: 120, reason: 'plates late' })
 
let lastSeen: number = head
const received: StreamEvent[] = []
 
// One connection. The server ends every stream after about 4.5 minutes, so real consumers loop (see Event-Driven Patterns).
for await (const event of client.events.stream({
  workflowInstanceId: job.instance.id, // only this job
  types: ['task_delayed', 'schedule.changed'], // canonical names or underscore aliases
  sinceSequence: lastSeen, // resume after this event (replays what you missed)
})) {
  lastSeen = event.sequence!
  received.push(event)
  console.log(event.sequence, event.type, event.type === 'schedule.changed' ? `${(event.data.changes as unknown[]).length} tasks moved` : `task ${event.task_id}`)
  if (received.length === 2) break
}

Output (a real run):

text
336 schedule.changed 2 tasks moved
337 task.delayed task 120e6316-942c-413c-8146-60bcdef0a046

Events arrive in log order: a delay writes schedule.changed just before task.delayed, so do not depend on a particular order between event types.

Stream options

OptionMeaning
workflowInstanceIdOnly events about this job
typesOnly these events: canonical names (task.delayed) or underscore aliases (task_delayed); the names earlier releases delivered (task.updated) match too
sinceSequenceResume after this sequence: events strictly after it are replayed, then it goes live. Without it the stream is live only, starting from the log's current position
projectIdThe project to stream; defaults to your personal project

How the stream behaves

TransportServer-Sent Events, content-type: text/event-stream. Each event is a frame id: <sequence>, event: <type>, data: <envelope JSON>. The stream opens with retry: 2000.
LatencyPolling-backed. The server reads the log once a second (pollMs 1000), so an event arrives within about a second of being written. Measured locally (same machine, 10 events, docs-examples/delivery-semantics.mts): median 517 ms, worst 978 ms; a second run: median 882 ms, worst 1,029 ms. A deployed platform adds network time.
HeartbeatA : ping comment line after 25 seconds without an event (observed on an idle stream). The SDK ignores comment lines.
LifetimeThe server ends every stream after 270 seconds. Reconnect with the last sequence you saw: nothing is lost.
ResumesinceSequence (query) or the Last-Event-ID header (what EventSource sends when it reconnects). If both are sent the header wins. Both were checked: after a break at sequence n, each returned exactly the events after n and no others.
What it carriesThe project's whole event log, not just the events that are also webhooked: it includes workflow.started, conversation.created, resource.created, artifact.created and more. The table in the Webhooks guide lists what is emitted.
Order and gapsEvents are sent in sequence order (checked for 20 and 100 events: never out of order on SSE). sequence is a platform-wide counter shared by every project, so it is increasing but not consecutive in your stream.
Cost and limitsOpening a stream counts as one request toward your rate limit; the events do not. Needs the events:read scope. The code sets no cap on concurrent streams per key, project or organization; each open stream runs one small query per second for up to 270 seconds. See rate limits.
Errors on connect404 if planning v2 is off, 400 VALIDATION_ERROR for a bad query (a non-UUID workflowInstanceId, a negative sinceSequence), 401/403 for a bad credential or missing scope. An unknown name in types is not an error: it matches nothing.
CancellingThe SDK's stream() has no way to cancel: leaving the for await loop (break) does not close the connection, which stays open until the server ends it. End your process explicitly, or use the raw endpoint with an AbortController.

Every event is the same envelope as a webhook body, byte for byte (compared for every event in a test scenario):

typescript
interface StreamEvent {
  id?: string               // unique event id
  sequence?: number         // position in the event log; pass it as `sinceSequence` to resume
  type: string              // canonical name, e.g. 'task.delayed'
  event?: string            // same as `type`
  data: Record<string, unknown>
  ts?: string               // also `timestamp`
  projectId?: string | null
  workflowInstanceId?: string | null
  subjectType?: string | null
  subjectId?: string | null
  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 typed schedule.changed

data is a plain record on StreamEvent; for schedule.changed the SDK exports the shape as ScheduleChangedData:

typescript
import type { ScheduleChangedData, ScheduleChangeEntry } from '@clockwork/sdk'
 
for await (const event of client.events.stream({ types: ['schedule.changed'] })) {
  const { workflowInstanceId, reason, changes } = event.data as unknown as ScheduleChangedData
  for (const c of changes) console.log(c.taskId, c.previous.start, '->', c.current.start, c.reason)
}
typescript
interface ScheduleChangeEntry {
  taskId: string
  previous: { start: string; end: string }   // ISO 8601
  current: { start: string; end: string }
  reason: string   // the trigger for the task that was edited; 'upstream_change' or 'replanned' for tasks that moved because of it
}
 
interface ScheduleChangedData {
  workflowInstanceId: string | null
  reason: string   // 'task_delayed' | 'task_completed' | 'task_updated' | 'task_created' | 'dependency_changed' | 'requirements_changed' | 'constraint_changed' | 'recompute'
  changes: ScheduleChangeEntry[]
}

One event is written per recompute and per job; its envelope has subjectType: 'workflow_instance' (or 'project' for tasks outside a job). Tasks that were only just created are not listed. A subscription filtered by resourceId receives only the entries for tasks that use that resource.

Stream event types

The names and what actually emits them, verified by running a scenario that exercised each, are in the Webhooks guide. In short, on the stream you will see task.delayed, task.completed, task.ready, conflict.detected, schedule.changed, conversation.message_created and workflow.completed (the events that are also pushed to webhooks), plus log-only events such as workflow.started, conversation.created, conversation.message_added, resource.created and artifact.created.

task.created, task.updated and constraint.violated are not emitted: they are only the earlier names kept in legacy_event.

events.subscribe(input)

Registers a webhook endpoint to receive signed POST requests when events occur. With planning v2 on delivery is at-least-once: a failed attempt is retried (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead), each request carries X-Clockwork-Delivery and X-Clockwork-Attempt, and the body is identical every time, so dedupe on its id. With planning v2 off it is a single best-effort attempt with no retries. Retry cadence on a quiet platform is bounded by request-triggered sweeps and a daily cron; see Delivery guarantees.

Parameters

ParameterTypeRequiredDescription
urlstringyesThe endpoint that will receive events: http or https, no embedded credentials; on the production platform also not localhost, a private or link-local IP, or an internal-looking host (400 INVALID_WEBHOOK_URL). Use HTTPS
eventsWebhookEvent[]yes (at least one)Event names to subscribe to, or '*'. A name that does not exist is 400 VALIDATION_ERROR
workflowInstanceIdstringnoOnly events about this job (planning v2)
resourceIdstringnoOnly events about tasks that use this resource (planning v2)
typescript
const { data: sub } = await client.events.subscribe({
  url: 'https://yourapp.com/webhooks/clockwork',
  events: ['task.completed', 'task.delayed', 'conflict.detected', 'schedule.changed'],
})
 
// Store the secret — it's only shown once
console.log('Webhook secret:', sub.secret)

Available webhook events

task.ready, task.completed, task.delayed, conflict.detected, workflow.completed, schedule.changed, conversation.message_created, and * for all. The underscore spellings (task_ready, task_completed, task_delayed, conflict_detected, workflow_completed) are accepted as aliases, and so are the earlier names task.created, task.updated and constraint.violated.

events.listDeliveries(subscriptionId, opts?)

The delivery log of one of your subscriptions, newest first (planning v2; 404 with it off or for a subscription that is not yours).

OptionTypeDescription
limitnumber1 to 200, default 50
cursorstringnextCursor of the previous page
status'pending' | 'delivered' | 'failed' | 'dead'Only deliveries in this state

Returns { data: WebhookDelivery[], nextCursor: string | null }. A WebhookDelivery is { id, eventId, event, attempt, status, responseStatus, nextAttemptAt, lastError, createdAt }; lastError has URL credentials and query strings removed. The MCP tool is list_webhook_deliveries.

typescript
let cursor: string | undefined
do {
  const page = await client.events.listDeliveries(sub.id, { status: 'dead', cursor })
  for (const d of page.data) console.log(d.event, d.attempt, d.lastError)
  cursor = page.nextCursor ?? undefined
} while (cursor)

Verifying webhook signatures

Every webhook request includes X-Clockwork-Signature: sha256=<hex>, the HMAC-SHA256 of the raw body with your subscription secret. Always verify it before processing. The full receiver (signature check, duplicates, routing) is in the Webhooks guide.

events.list(opts?)

Fetches events from the log, newest first.

typescript
const { data: events } = await client.events.list({
  workflowInstanceId: 'wi_abc123',
  type: 'task.completed', // or an alias: 'task_completed'
  limit: 50,
})
OptionMeaning
workflowInstanceIdOnly events about this job
typeOne event name; canonical, alias or earlier name. Log rows written before names were unified are matched too
sinceSequenceOnly events with a greater sequence
limit1–500, default 100 (501 is 400 VALIDATION_ERROR; checked)
projectIdDefaults to your personal project

events.replay(input?)

Returns events in ascending sequence order starting strictly after fromSequence. Useful for rebuilding state after an outage or catching up on missed webhooks.

typescript
const { data: events } = await client.events.replay({
  fromSequence: 0,
  limit: 500,
})

fromSequence defaults to 0, limit to 500 (maximum 1000: 1001 is 400 VALIDATION_ERROR; checked). To page through history, call again with the last sequence you received.

Type reference

typescript
interface Event {
  id: string
  sequence: number
  projectId: string | null
  type: string
  subjectType: string | null
  subjectId: string | null
  payload?: Record<string, unknown> | null
  correlationId: string | null
  createdAt: string
}
 
interface EventSubscription {
  id: string
  url: string
  events: WebhookEvent[]
  isActive: boolean
  createdAt: string
  workflowInstanceId?: string | null // planning v2
  resourceId?: string | null         // planning v2
  secret?: string // Only on creation
}

events.list and events.replay return Event rows (the log), whose payload is the envelope's data; the raw row also has workspaceId and actorId columns that the SDK type leaves out. list is newest first, replay oldest first. The stream and webhooks send the envelope described above.