Getting started

Conventions

The five rules that hold across every endpoint. The restricted contract is the one most likely to bite you if you skip it.

Handles, not database ids

Every object is addressed by an opaque, prefixed handle. The prefix tells you what kind of thing you are holding, which makes a mismatched id a readable bug rather than a mysterious 404.

PrefixObject
wsp_Workspace
usr_Workspace user
job_Job
app_Application
apl_Applicant
cnd_Sourced profile
pool_Shared-pool profile
per_Person (the identity graph)
prj_Project
lst_Candidate list
stg_Pipeline stage
res_Resume (keyed by application)
mem_List membership
ci_An ATS connection, in external_refs
evt_Event
pk_API key (as reported by /me)
cur_Pagination cursor

Handles are stable for the life of the object, safe to store as your foreign key, and safe to log. They are not guessable and they are not sequential, so do not try to derive one, enumerate a range, or compare two for ordering.

List responses

Every list endpoint returns the same envelope:

List envelope
{
  "object": "list",
  "data": [ /* ... */ ],
  "has_more": true,
  "next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}
FieldMeaning
objectAlways "list". Each row carries its own object.
dataThe rows, newest first unless the endpoint says otherwise.
has_moreWhether anything remains behind this page.
next_cursorOpaque position to pass back, or null when the list is exhausted.

Page size

?limit= defaults to 25 and is capped at 100. A larger value is clamped rather than rejected, and /me reports the effective ceiling in rate_limits.max_page_size.

Cursors

Pagination is keyset, not offset. There is no ?page= and no ?offset=: rows are created and updated while you page, and offset paging over moving data both repeats and skips records.

A cursor is signed and bound to the exact query that produced it — the endpoint, the normalised filters, and your credential. Reusing a cursor with a different filter, on a different endpoint, or with a different key returns 400 invalid_cursor. Cursors also expire.

Correct paging
# First page: filters, no cursor.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100" -H "Authorization: Bearer $KEY"

# Every page after: the SAME filters, plus the cursor.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100&cursor=cur_..." \
  -H "Authorization: Bearer $KEY"

Timestamps

Every datetime is RFC 3339 in UTC with millisecond precision and a literal Z: 2026-09-09T10:14:22.510Z. There are no local times, no offsets other than Z, and no epoch integers. Date-only fields are YYYY-MM-DD.

Where an endpoint accepts a timestamp filter, send the same format. A value that is not a valid RFC 3339 instant is 400 invalid_timestamp.

Withheld fields: the restricted contract

This is the convention worth reading twice, because getting it wrong produces a bug you find out about from a customer months later.

What you seeWhat it means
The key is absentTahoe does not hold this value.
The key is present and null, or an empty arrayTahoe holds it and it is genuinely empty.
The key is absent and named in restrictedTahoe holds it and is not giving it to you. The reason says why.

A withheld field is never returned as a silent null. It is removed from the payload and named:

A response with a withheld field
{
  "object": "application_score",
  "application_id": "app_6Qm2xKd4Rp8v",
  "match_pct": 82,
  "gaps": ["No Kubernetes experience stated"],
  "restricted": ["rationale", "model_version"],
  "restricted_reason": {
    "rationale": "scope_required:applications:internal:read",
    "model_version": "scope_required:applications:internal:read"
  }
}

The reasons you can encounter:

ReasonMeaningWhat to do
scope_required:<scope>Your key was not granted that scope.Mint a key with the scope, if the customer agrees to grant it.
paywalledThe workspace has not purchased access to this document.Nothing, from the API. The customer unlocks it in Tahoe.
filtered:not_in_current_formSome form answers were dropped: retired fields, consent fields, and equal-opportunity questions are never returned.Nothing. The answers you did get are complete for the fields that remain.
never_exposed:consent_scopeThe data exists but is outside what the person consented to share. No scope unlocks it.Nothing. Do not model a field for it.

Exact and probable identity

Tahoe links profiles into people. A link made on a LinkedIn URL or a provider id is exact — it identifies one profile. A link made on an email address is probable, because shared, recycled and role addresses exist.

The distinction is enforced, not advisory: a probable match can appear in the list of profiles behind a person, but it never contributes a value to a union endpoint. So the identity graph cannot write one person’s phone number onto another person’s record. When you resolve an identity, read match.confidence and treat probable as a suggestion for a human to confirm.

Response headers

HeaderOnMeaning
Tahoe-Partner-Api-VersionEvery responseThe API version that served it.
Tahoe-Request-IdEvery responseQuote this in a support request. Also in every error body.
RateLimit-PolicyEvery responseThe advertised quotas, as an RFC-style policy string.
Retry-After429 responsesSeconds to wait. Honour it rather than guessing.

Unknown parameters

An unrecognised query parameter is ignored, not rejected. That keeps a client working when it sends a parameter a newer version added, but it also means a typo in a filter name silently returns unfiltered data. Verify a new filter against a known-small result set the first time you use it.