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.
| Prefix | Object |
|---|---|
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:
{
"object": "list",
"data": [ /* ... */ ],
"has_more": true,
"next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}| Field | Meaning |
|---|---|
object | Always "list". Each row carries its own object. |
data | The rows, newest first unless the endpoint says otherwise. |
has_more | Whether anything remains behind this page. |
next_cursor | Opaque 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.
# 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 see | What it means |
|---|---|
| The key is absent | Tahoe does not hold this value. |
The key is present and null, or an empty array | Tahoe holds it and it is genuinely empty. |
The key is absent and named in restricted | Tahoe 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:
{
"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:
| Reason | Meaning | What 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. |
paywalled | The workspace has not purchased access to this document. | Nothing, from the API. The customer unlocks it in Tahoe. |
filtered:not_in_current_form | Some 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_scope | The 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
| Header | On | Meaning |
|---|---|---|
Tahoe-Partner-Api-Version | Every response | The API version that served it. |
Tahoe-Request-Id | Every response | Quote this in a support request. Also in every error body. |
RateLimit-Policy | Every response | The advertised quotas, as an RFC-style policy string. |
Retry-After | 429 responses | Seconds 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.
