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.

ParameterTypeNotes
afterintegerResume after this sequence. Omit for the beginning of what you can see.
typestringComma-separated event types. Singular type, not types. An unknown name is 400 unknown_event_type rather than ignored.
workspace_idhandleNarrow to one workspace. Required in effect for a cross-workspace credential that wants a single stream.
limitintegerDefault 25, max 100.
Request
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"
Response
{
  "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

FieldMeaning
idEvent handle (evt_). Use it to deduplicate — you may see the same event twice.
typeOne of the 38 types below.
api_versionThe version the payload was shaped by, frozen at emission.
sequenceYour resumption point. Increasing, ordered, not contiguous.
workspace_idWhich workspace changed, or null for a notice that belongs to no workspace.
data.objectThin. Identifiers and a few scalars — enough to know what changed and go read it.
data.previous_attributesPresent 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.

EventScope
job.publishedjobs:read
job.updatedjobs:read
job.closedjobs:read
job.reopenedjobs:read
job.unpublishedjobs:read
job.deletedjobs:read
job.sections_updatedjobs:read
application.createdapplications:read
application.updatedapplications:read
application.status_changedapplications:read
application.stage_changedapplications:read
application.withdrawnapplications:read
application.deletedapplications:read
application.scoredapplications:read
application.resume_parsedresume:read
resume.access_changedresume:read
application.screening_completedscreening:metadata:read
applicant.createdapplicants:read
applicant.updatedapplicants:read
applicant.deletedapplicants:read
applicant.unsubscribedapplicants:read
sourced_profile.createdsourced_profiles:read
sourced_profile.updatedsourced_profiles:read
sourced_profile.deletedsourced_profiles:read
sourced_profile.contact_info_revealedcontact:read
sourced_profile.attachment_addedattachments:read
list.createdlists:read
list.updatedlists:read
list.deletedlists:read
list_membership.addedlists:read
list_membership.removedlists:read
list_membership.stage_changedlists:read
pool.batch_upsertedpool:read
person.identity_linkedpeople:resolve
person.canonical_id_changedpeople:resolve
data_subject.suppression_appliedevents:read
data_subject.erasure_completedevents: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.

A change-feed consumer that behaves
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"]:
            return

Or 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.