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
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.
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
| Method | Description |
|---|---|
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:
// 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):
336 schedule.changed 2 tasks moved
337 task.delayed task 120e6316-942c-413c-8146-60bcdef0a046Events 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
| Option | Meaning |
|---|---|
workflowInstanceId | Only events about this job |
types | Only these events: canonical names (task.delayed) or underscore aliases (task_delayed); the names earlier releases delivered (task.updated) match too |
sinceSequence | Resume 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 |
projectId | The project to stream; defaults to your personal project |
How the stream behaves
| Transport | Server-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. |
| Latency | Polling-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. |
| Heartbeat | A : ping comment line after 25 seconds without an event (observed on an idle stream). The SDK ignores comment lines. |
| Lifetime | The server ends every stream after 270 seconds. Reconnect with the last sequence you saw: nothing is lost. |
| Resume | sinceSequence (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 carries | The 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 gaps | Events 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 limits | Opening 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 connect | 404 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. |
| Cancelling | The 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):
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:
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)
}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
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | yes | The 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 |
events | WebhookEvent[] | yes (at least one) | Event names to subscribe to, or '*'. A name that does not exist is 400 VALIDATION_ERROR |
workflowInstanceId | string | no | Only events about this job (planning v2) |
resourceId | string | no | Only events about tasks that use this resource (planning v2) |
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).
| Option | Type | Description |
|---|---|---|
limit | number | 1 to 200, default 50 |
cursor | string | nextCursor 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.
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.
const { data: events } = await client.events.list({
workflowInstanceId: 'wi_abc123',
type: 'task.completed', // or an alias: 'task_completed'
limit: 50,
})| Option | Meaning |
|---|---|
workflowInstanceId | Only events about this job |
type | One event name; canonical, alias or earlier name. Log rows written before names were unified are matched too |
sinceSequence | Only events with a greater sequence |
limit | 1–500, default 100 (501 is 400 VALIDATION_ERROR; checked) |
projectId | Defaults 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.
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
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.
Related
- Guides: Webhooks — the receiver, the event table, and what delivery guarantees exist
- Guides: Event-Driven Patterns
- Guides: Guest Access — a guest token's stream is limited to its tasks
- Conflicts — conflicts also surface as
conflict.detectedevents - Authentication — the
events:readandwebhooks:*scopes