Staying in sync
The change feed
38 event types on one ordered log. Read it from where you left off, and never re-read a workspace on a timer again.
GET/eventsevents:read
One page of events, oldest first — the opposite of every other list in this API, because a feed you replay has to be read forwards.
| Parameter | Type | Notes |
|---|---|---|
after | integer | Resume after this sequence. Omit for the beginning of what you can see. |
type | string | Comma-separated event types. Singular type, not types. An unknown name is 400 unknown_event_type rather than ignored. |
workspace_id | handle | Narrow to one workspace. Required in effect for a cross-workspace credential that wants a single stream. |
limit | integer | Default 25, max 100. |
curl -s "https://tahoe.workonward.com/api/partner/v1/events?after=48210&type=job.published,job.updated&limit=100" \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "event",
"id": "evt_7Kd2mXq4Rp8v",
"type": "job.updated",
"api_version": "2026-09-09",
"created_at": "2026-09-08T11:20:45.331Z",
"sequence": 48214,
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"data": {
"object": {
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"status": "published",
"updated_at": "2026-09-08T11:20:45.331Z"
},
"previous_attributes": { "status": "draft" }
},
"links": { "self": "/api/partner/v1/events/evt_7Kd2mXq4Rp8v" }
}
],
"has_more": true,
"next_cursor": null,
"next_after": 48214
}The envelope
| Field | Meaning |
|---|---|
id | Event handle (evt_). Use it to deduplicate — you may see the same event twice. |
type | One of the 38 types below. |
api_version | The version the payload was shaped by, frozen at emission. |
sequence | Your resumption point. Increasing, ordered, not contiguous. |
workspace_id | Which workspace changed, or null for a notice that belongs to no workspace. |
data.object | Thin. Identifiers and a few scalars — enough to know what changed and go read it. |
data.previous_attributes | Present on updates: the old values of the fields that changed. |
GET/events/{event_handle}events:read
One event, by handle. Useful for re-examining a webhook delivery you logged, or for a dead-letter queue that stores handles rather than whole bodies.
The 38 event types
Reading an event requires the scope that would let you read the resource it describes. That mapping is applied in the query, not filtered afterwards — otherwise the feed would become a way to enumerate things your scopes deny you.
| Event | Scope |
|---|---|
job.published | jobs:read |
job.updated | jobs:read |
job.closed | jobs:read |
job.reopened | jobs:read |
job.unpublished | jobs:read |
job.deleted | jobs:read |
job.sections_updated | jobs:read |
application.created | applications:read |
application.updated | applications:read |
application.status_changed | applications:read |
application.stage_changed | applications:read |
application.withdrawn | applications:read |
application.deleted | applications:read |
application.scored | applications:read |
application.resume_parsed | resume:read |
resume.access_changed | resume:read |
application.screening_completed | screening:metadata:read |
applicant.created | applicants:read |
applicant.updated | applicants:read |
applicant.deleted | applicants:read |
applicant.unsubscribed | applicants:read |
sourced_profile.created | sourced_profiles:read |
sourced_profile.updated | sourced_profiles:read |
sourced_profile.deleted | sourced_profiles:read |
sourced_profile.contact_info_revealed | contact:read |
sourced_profile.attachment_added | attachments:read |
list.created | lists:read |
list.updated | lists:read |
list.deleted | lists:read |
list_membership.added | lists:read |
list_membership.removed | lists:read |
list_membership.stage_changed | lists:read |
pool.batch_upserted | pool:read |
person.identity_linked | people:resolve |
person.canonical_id_changed | people:resolve |
data_subject.suppression_applied | events:read |
data_subject.erasure_completed | events:read |
New types are added without an API version bump. Ignore a type you do not recognise rather than failing on it — and do not enumerate types in a ?type= filter unless you specifically want only those, because a filter written today will silently exclude tomorrow’s.
At-least-once, and what that means for you
You may see the same event more than once. Deduplicate on id and make your handlers idempotent — an event handler that increments a counter or appends a row will drift.
def consume(session, store):
"""Drain the feed from the stored watermark. Safe to run repeatedly."""
after = store.load_sequence() # int or None on a cold start
while True:
params = {"limit": 100}
if after is not None:
params["after"] = after
body = session.get(f"{BASE}/events", params=params, timeout=30).json()
for event in body["data"]:
# At-least-once delivery: the same event id can arrive twice, and a
# gap in `sequence` is another credential's event rather than a lost
# one. Both facts are handled here and nowhere else.
if store.already_processed(event["id"]):
continue
handle(event)
store.mark_processed(event["id"])
# Persist the watermark only AFTER the page is durably handled. Saving
# it first turns a crash mid-page into silently skipped events.
if body["data"]:
after = body["next_after"]
store.save_sequence(after)
if not body["has_more"]:
returnOr have them pushed to you
Polling this feed is the simplest thing that works and is genuinely fine for a nightly sync. If you want changes pushed instead, see webhooks — same events, same envelope, signed and retried.
