Skip to content

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.

Request
curl https://tahoe.workonward.com/api/partner/v1/projects \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "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.

Response
{
  "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.

Request
curl "https://tahoe.workonward.com/api/partner/v1/lists/lst_3Rp8vKd2mXq4/members?limit=100" \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "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}.

FieldTypeNotes
namestring, required1 to 120 characters. Whitespace around it is trimmed.
descriptionstringOptional, at most 500 characters.
project_idhandleOptional. A project of this workspace, or 404 not_found.
Request
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"
  }'
Response: 201
{
  "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-Key so that a retry cannot create a second one.
  • description is stored and returned by the reads. The Tahoe list screen does not show it today.
  • A new list has no members and candidate_count 0. It emits list.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.

FieldTypeNotes
membersarray, required1 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.
Request
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" }
    ]
  }'
Response: 200
{
  "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.

statusMeaning
addedA new membership was created.
already_in_listThe profile was already in this list. Nothing changed, and this is not an error.
not_foundThe 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.
unsupportedAn 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.
  • membership is present for added and already_in_list. Its id is 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_count is the list’s member count after the write, or null when nothing was added. The count and updated_at are recomputed once per request, only if something was added.
  • The same profile twice in one request is added once: added, then already_in_list.
  • Recruiters see the new members at once. No enrichment starts and no credits are spent.
  • It emits list_membership.added for each new member, then list.updated once 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.

FieldTypeNotes
stagestring, requiredA 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, pending and declined: the presets in the Tahoe list table.
  • interviewing and hired: older marks that the Interviews and Hires analytics still count.
  • none: clears the stage, so the member’s stage becomes null.
  • A custom label that a recruiter has already put on some member of this same list.
Request
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" }'
Response: 200
{
  "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_stage with param: "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 interviewing and hired as 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 emits list_membership.stage_changed, with the new and old state.
  • Two writers who set a stage at the same moment: the last write wins, as in the product. There is no expected_updated_at here, because the product has no such check for stages.

Errors from these calls

StatusCodeWhat to do
400invalid_requestThe body failed validation. The errors list names each field.
400invalid_stageStage call only. Use one of the keys in the message.
400invalid_idempotency_keyThe Idempotency-Key header is not 8 to 255 allowed characters.
403insufficient_scopeThe key lacks lists:write.
403write_requires_api_keyThe credential is a Sign in with Tahoe token, or is not tied to a Tahoe user.
404not_foundThe list, member or project is not in this workspace, or the member is not in that list.
409idempotency_key_reusedThe same key was used with a different body.
409idempotency_in_flightThe first request with this key is still running. Retry shortly.
429rate_limit_exceededWait for Retry-After.

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.