Projects and lists
Projects, lists and where each candidate is in them, and creating and filling lists.
Lists are the sourcing side’s pipeline. A project groups lists, a list holds sourced candidates, and a membership records where each candidate sits. Five endpoints read them and need lists:read. Three more create a list, fill it and set where a candidate sits, and need lists:write.
How the three fit together
A project is a piece of hiring work. It contains lists, and each list holds memberships. A membership points at a sourced profile and gives its stage in that list.
This runs alongside the applications pipeline and is separate from it. An applicant moves through a job’s pipeline stages; a sourced candidate moves through a list’s stages. Do not try to merge the two into one funnel: they are different people at different points in hiring.
GET/projectslists:read
The workspace’s projects, most recently updated first.
curl https://tahoe.workonward.com/api/partner/v1/projects \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "project",
"id": "prj_7Kd2mXq4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"name": "Ohio field operations hiring",
"description": "Field and site roles for the Columbus and Dayton depots.",
"created_at": "2026-07-01T09:00:00.000Z",
"updated_at": "2026-09-06T12:08:55.400Z",
"links": {
"self": "/api/partner/v1/projects/prj_7Kd2mXq4Rp8v",
"lists": "/api/partner/v1/projects/prj_7Kd2mXq4Rp8v/lists"
}
}
],
"has_more": false,
"next_cursor": null
}GET/projects/{project_handle}/listslists:read
The lists inside one project, in the same shape as the rows below.
GET/listslists:read
Every candidate list in the workspace, including lists that are not in a project. Those have project_id: null.
{
"object": "list",
"data": [
{
"object": "list",
"id": "lst_3Rp8vKd2mXq4",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"project_id": "prj_7Kd2mXq4Rp8v",
"name": "Field Coordinators, Columbus",
"description": null,
"created_at": "2026-07-02T11:20:00.000Z",
"updated_at": "2026-09-06T12:08:55.400Z",
"links": {
"self": "/api/partner/v1/lists/lst_3Rp8vKd2mXq4",
"members": "/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members"
}
}
],
"has_more": false,
"next_cursor": null
}Each row’s object is "list", which is also the object of the envelope around them. The envelope is the outer one; the rows are candidate lists.
GET/lists/{list_handle}lists:read
One list, in the same shape as a row above.
GET/lists/{list_handle}/memberslists:read
Who is in the list and at which stage. A membership points at a sourced profile instead of including it: read the profile for the person’s details.
curl "https://tahoe.workonward.com/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members?limit=100" \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "list_membership",
"id": "mem_9Xj4kQd7Rm2s",
"list_id": "lst_3Rp8vKd2mXq4",
"sourced_profile_id": "cnd_8Fj3kLm2Qd7s",
"stage": "contacted",
"stage_updated_at": "2026-09-04T08:12:44.201Z",
"added_at": "2026-08-19T07:41:02.115Z"
}
],
"has_more": false,
"next_cursor": null
}stage_updated_at lets you work out time in stage without keeping your own history, and added_at is when the candidate joined the list. To go the other way, from a candidate to every list they are in, use the profile’s list memberships.
Create and fill lists
Three POST calls change lists. They change the same records the Tahoe product shows: a list an integration fills looks to a recruiter exactly like a list a recruiter filled, with the same members, the same member count and the same stage column. All three need lists:write and an API key that belongs to a Tahoe user. A Sign in with Tahoe token gets 403 write_requires_api_key, unless writes for connected apps are switched on. They use the normal rate-limit tier, accept an optional Idempotency-Key, and emit events that carry origin.
A key that reaches several workspaces names one with ?workspace_id=wsp_..., as on the reads. A handle that is malformed, of the wrong type or from another workspace is always 404 not_found, never 403. Nothing here sends anything to a candidate, and nothing deletes: there is no call that removes a member.
POST/listslists:write
Creates an empty list and returns 201 with the list, in the shape of GET /lists/{list_handle}.
| Field | Type | Notes |
|---|---|---|
name | string, required | 1 to 120 characters. Whitespace around it is trimmed. |
description | string | Optional, at most 500 characters. |
project_id | handle | Optional. A project of this workspace, or 404 not_found. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/lists \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: create-list-platform-0001" \
-H "Content-Type: application/json" \
-d '{
"project_id": "prj_5Nx3jLm7Qd2s",
"name": "Platform shortlist",
"description": "Q4 backend hires"
}'{
"object": "list",
"id": "lst_3Rp8vKd2mXq4",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"project_id": "prj_5Nx3jLm7Qd2s",
"name": "Platform shortlist",
"description": "Q4 backend hires",
"created_at": "2026-10-08T12:00:00Z",
"updated_at": "2026-10-08T12:00:00Z",
"links": {
"self": "/api/partner/v1/lists/lst_3Rp8vKd2mXq4",
"members": "/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members"
}
}- Without
project_id, the list goes into the workspace’s oldest project. If the workspace has no project, Tahoe creates one named “Imported” first. This is what the product does when a contact is saved without choosing a list. - Names are not deduplicated. Two requests with the same name make two lists, as in the product. Send an
Idempotency-Keyso that a retry cannot create a second one. descriptionis stored and returned by the reads. The Tahoe list screen does not show it today.- A new list has no members and
candidate_count0. It emitslist.created.
POST/lists/{list_handle}/memberslists:write
Adds up to 50 sourced profiles to a list. The batch always completes and returns 200: what happened to each item is in the body.
| Field | Type | Notes |
|---|---|---|
members | array, required | 1 to 50 items. Each item has exactly one of sourced_profile_id or applicant_id. Items are identified by handle only, never by name or email. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"members": [
{ "sourced_profile_id": "cnd_8Lp3wNc5Tz1k" },
{ "sourced_profile_id": "cnd_2Wq7vHd4Rm9x" },
{ "applicant_id": "apl_5Nx3jLm7Qd2s" }
]
}'{
"object": "list_member_batch",
"list_id": "lst_3Rp8vKd2mXq4",
"requested": 3,
"added": 1,
"already_in_list": 1,
"not_found": 0,
"unsupported": 1,
"candidate_count": 12,
"results": [
{
"index": 0,
"status": "added",
"sourced_profile_id": "cnd_8Lp3wNc5Tz1k",
"applicant_id": null,
"membership": {
"object": "list_membership",
"id": "mem_6Qm2xKd4Rp8v",
"list_id": "lst_3Rp8vKd2mXq4",
"sourced_profile_id": "cnd_8Lp3wNc5Tz1k",
"stage": null,
"stage_updated_at": null,
"added_at": "2026-10-08T12:00:00Z"
}
},
{
"index": 1,
"status": "already_in_list",
"sourced_profile_id": "cnd_2Wq7vHd4Rm9x",
"applicant_id": null,
"membership": { "object": "list_membership", "id": "mem_9Hn4tWc7Lv2d", "...": "..." }
},
{
"index": 2,
"status": "unsupported",
"sourced_profile_id": null,
"applicant_id": "apl_5Nx3jLm7Qd2s",
"membership": null
}
]
}results has one entry per requested item, in request order. The counts at the top add up to requested.
| status | Meaning |
|---|---|
added | A new membership was created. |
already_in_list | The profile was already in this list. Nothing changed, and this is not an error. |
not_found | The handle is not a sourced profile of this workspace: it is malformed, from another workspace, or a record with no displayable data. The other items are not affected, and the response never says which of these it was. |
unsupported | An applicant_id was given. A list holds sourced profiles, and an applicant is not one. The schema accepts the field so it can be used later, and the item is never looked up. |
membershipis present foraddedandalready_in_list. Itsidis what the stage call takes.- Only profiles this workspace already holds can be added. The call cannot create a profile, and it never links anything from another workspace.
candidate_countis the list’s member count after the write, ornullwhen nothing was added. The count andupdated_atare recomputed once per request, only if something was added.- The same profile twice in one request is added once:
added, thenalready_in_list. - Recruiters see the new members at once. No enrichment starts and no credits are spent.
- It emits
list_membership.addedfor each new member, thenlist.updatedonce if anything was added. Repeating the request without a key is safe, because adding an existing member does nothing.
POST/lists/{list_handle}/members/{member_handle}/stagelists:write
Sets one member’s recruiter stage and returns 200 with the membership, the same object the read returns. {member_handle} is the mem_ handle from the members read or from the add response.
| Field | Type | Notes |
|---|---|---|
stage | string, required | A key the list can hold, 1 to 40 characters. Matching is exact and case sensitive: Hired is not hired. |
The keys a list can hold:
requested,connected,pendinganddeclined: the presets in the Tahoe list table.interviewingandhired: older marks that the Interviews and Hires analytics still count.none: clears the stage, so the member’sstagebecomesnull.- A custom label that a recruiter has already put on some member of this same list.
curl -X POST https://tahoe.workonward.com/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members/mem_6Qm2xKd4Rp8v/stage \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "stage": "interviewing" }'{
"object": "list_membership",
"id": "mem_6Qm2xKd4Rp8v",
"list_id": "lst_3Rp8vKd2mXq4",
"sourced_profile_id": "cnd_8Lp3wNc5Tz1k",
"stage": "interviewing",
"stage_updated_at": "2026-10-08T12:05:00Z",
"added_at": "2026-10-08T12:00:00Z"
}400 invalid_stagewithparam: "stage"means the value is not a key this list can hold. The message lists the valid keys and does not echo the rejected value.- The API cannot invent a new custom label. Recruiters create labels in the product, and a key can only reuse one already on the list, so free text from an integration never appears in front of recruiters unannounced.
- Stages are visible to the whole workspace, and the Interviews and Hires analytics count
interviewingandhiredas they count the same marks set in the product. - Setting the stage a member already has succeeds and refreshes
stage_updated_at, as the product does, and emits no event. Otherwise it emitslist_membership.stage_changed, with the new and oldstate. - Two writers who set a stage at the same moment: the last write wins, as in the product. There is no
expected_updated_athere, because the product has no such check for stages.
Errors from these calls
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body failed validation. The errors list names each field. |
| 400 | invalid_stage | Stage call only. Use one of the keys in the message. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is not 8 to 255 allowed characters. |
| 403 | insufficient_scope | The key lacks lists:write. |
| 403 | write_requires_api_key | The credential is a Sign in with Tahoe token, or is not tied to a Tahoe user. |
| 404 | not_found | The list, member or project is not in this workspace, or the member is not in that list. |
| 409 | idempotency_key_reused | The same key was used with a different body. |
| 409 | idempotency_in_flight | The first request with this key is still running. Retry shortly. |
| 429 | rate_limit_exceeded | Wait for Retry-After. |
Related events
list.created, list.updated, list.deleted, list_membership.added, list_membership.removed and list_membership.stage_changed, all under lists:read. list_membership.removed is a deletion event: if you keep a copy of memberships, remove that row. See the change feed.
The list_membership.* events name the row with its own mem_ handle, the one the members read returns, and carry list_id and sourced_profile_id. Each event caused by a write carries origin.