Conventions
IDs, pagination, timestamps, withheld fields, idempotency and the origin of an event.
A few rules hold across every endpoint: how objects are named, how lists are paged, how times are written, and how the API tells you it is holding something back. Read this page once before you design your own storage. The section on withheld fields is the one most likely to cause a quiet bug if you skip it.
IDs
Every object has an ID made of a short prefix and an opaque string, such as job_7Kd2mXq4Rp8v. The prefix tells you what kind of object you are holding, so a mixed-up ID is easy to spot.
| Prefix | Object |
|---|---|
wsp_ | Workspace |
usr_ | Workspace user |
job_ | Job posting |
app_ | Application |
apl_ | Applicant (a person who applied) |
cnd_ | Sourced profile (a candidate the workspace found and saved) |
pool_ | Shared pool profile |
per_ | Person (one human, linked across profiles) |
prj_ | Project |
lst_ | Candidate list |
mem_ | List membership |
stg_ | Pipeline stage |
res_ | Resume |
msg_ | Message sent through POST /messages |
upl_ | Resume upload reserved through POST /uploads |
ci_ | ATS connection, inside external_refs |
evt_ | Event |
ers_ | Erasure notice |
pk_ | API key, as reported by /me |
cur_ | Pagination cursor |
IDs stay the same for the life of the object, so you can store them as your own foreign keys and write them to logs. They are not sequential and cannot be guessed. Do not try to build one, count through a range, or sort by them.
Lists
Every list endpoint returns the same envelope:
{
"object": "list",
"data": [
{ "object": "job", "id": "job_7Kd2mXq4Rp8v", "title": "Field Coordinator" },
{ "object": "job", "id": "job_3Hn8vLc2Wq5t", "title": "Site Safety Lead" }
],
"has_more": true,
"next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}| Field | Meaning |
|---|---|
object | Always "list". Each row has its own object. |
data | The rows on this page. |
has_more | Whether there is anything after this page. |
next_cursor | The position to send back for the next page, or null when the list is done. |
?limit= sets the page size. It defaults to 25 and is capped at 100. A larger value is lowered to 100 rather than refused, and /me reports the cap in rate_limits.max_page_size.
Cursors
Pages are linked by cursors, not page numbers. There is no ?page= and no ?offset=: records are added and changed while you read, and numbered pages over moving data repeat some rows and skip others.
A cursor is signed and tied to the request that produced it: the endpoint, the filters and your key. The order of your query parameters does not matter, and you may change limit between pages. Anything else returns 400 invalid_cursor: a cursor used with different filters, on another endpoint or with another key. Cursors also expire after one hour.
# First page: your filters, no cursor.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100" \
-H "Authorization: Bearer $TAHOE_API_KEY"
# Every later page: the SAME filters, plus the cursor from the last response.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100&cursor=cur_..." \
-H "Authorization: Bearer $TAHOE_API_KEY"How deep you can page
One listing can be paged up to 10,000 rows deep. Past that you get 400 result_window_exceeded. To keep a copy current, filter with ?updated_after= from your last successful sync, or follow the change feed, which has no depth limit. The change feed has its own position marker: a sequence number sent as ?after=, not a cursor.
Timestamps
Every date and time is RFC 3339 in UTC, with milliseconds and a literal Z: 2026-09-09T10:14:22.510Z. There are no local times and no epoch numbers. Fields that hold only a date use YYYY-MM-DD.
Send the same format in time filters such as updated_after. A value without a time zone is read as UTC. A value that is not a valid timestamp returns 400 invalid_timestamp.
curl -s "https://tahoe.workonward.com/api/partner/v1/applications?updated_after=2026-09-01T00:00:00.000Z" \
-H "Authorization: Bearer $TAHOE_API_KEY"Withheld fields
This rule is worth reading twice, because getting it wrong produces a bug you hear about from a customer months later. A field can be missing from a response for three different reasons, and the response always tells you which:
| 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 list | Tahoe holds it, and it is empty. |
The key is absent and named in restricted | Tahoe holds it and is not giving it to you. restricted_reason says why. |
A withheld field is never sent as a silent null. It is removed and named:
{
"object": "application_score",
"application_id": "app_6Qm2xKd4Rp8v",
"match_pct": 82,
"gaps": ["No forklift certification stated"],
"restricted": ["rationale", "model_version"],
"restricted_reason": {
"rationale": "scope_required:applications:internal:read",
"model_version": "scope_required:applications:internal:read"
}
}The reasons you can see:
| Reason | Meaning | What to do |
|---|---|---|
scope_required:<scope> | Your key does not have that scope. | Ask Tahoe for a key with the scope, if the workspace agrees to grant it. |
filtered:not_in_current_form | Some form answers were left out: answers to retired fields, consent fields and equal-opportunity questions are never returned. The reason text starts with this code and adds a short note. | Nothing. The answers you did get are complete for the fields that remain. |
never_exposed:consent_scope | The data exists but falls outside what the person agreed to share, such as the content of a phone screen. No scope unlocks it. | Nothing. Do not build a field for it. |
A resume behind Tahoe’s resume window works differently: the resume endpoints return 403 resume_locked with the current state and the unlock cost. See What is never exposed.
Exact and probable matches
Tahoe links profiles that belong to the same person. A link made on a LinkedIn URL is exact: it points to one profile. A link made on an email address is probable, because shared, recycled and role addresses exist.
The difference is enforced. A probable match can appear in the list of profiles behind a person, but it never adds a value to an endpoint that combines data across profiles. So one person’s phone number can never end up on another person’s record. When you look up a person, read match.confidence and treat probable as a suggestion for a human to confirm. See People.
Links in responses
Objects carry a links block. Links are paths, never full URLs. To follow one, put https://tahoe.workonward.com in front of it.
{
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"links": {
"self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v",
"applications": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications"
}
}curl -s "https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications" \
-H "Authorization: Bearer $TAHOE_API_KEY"Idempotency
A client retries after a timeout, a dropped connection or a restarted worker. A retry of a write must not move a candidate twice, send an email twice or post a note twice. Send an Idempotency-Key header with a write, and the first request with that key does the work. Every later request with the same key and the same body gets the first answer back, without the work running again.
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/move \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: move-6Qm2-to-screen-0001" \
-H "Content-Type: application/json" \
-d '{ "stage_id": "stg_3Rp8vKd2mXq4" }'HTTP/2 200
Idempotent-Replayed: true
Tahoe-Request-Id: req_3f9a1c7e5b2d4a60| Rule | Detail |
|---|---|
| Format | 8 to 255 characters from A-Z a-z 0-9 . _ : ~ -. Anything else is 400 invalid_idempotency_key. A random UUID works, and so does an ID from your own record of the action, such as move-6Qm2-to-screen-0001. |
| Scope | A record belongs to the key that made the request, the workspace, the method and the path, as well as your Idempotency-Key value. Another API key can never replay it, and the same value sent to another path is a different write. |
| Same key, same body | You get the first response back, with Idempotent-Replayed: true. The write does not run again and no second event is emitted. The order of JSON keys and the spacing of the body do not matter. |
| Same key, different request | 409 idempotency_key_reused. Returning the first answer for a request that asks for something else would silently drop the second request, so Tahoe refuses it. Use a new key for a new request. |
| Same key, first request still running | 409 idempotency_in_flight. Retry in a moment. If the first request died, its claim is released after about two minutes. |
| How long | 24 hours from the first request. |
| What is remembered | Only 2xx responses. A request that failed releases its key, so a retry after you fix the body, or after an outage, runs again. |
| Optional or required | Optional on most writes. Required where a duplicate does harm that cannot be undone: POST /messages returns 400 idempotency_key_required without it. |
{
"detail": {
"code": "idempotency_key_reused",
"type": "conflict",
"message": "This Idempotency-Key was already used with a different request. Use a new key for a new request.",
"param": null
}
}A key makes one HTTP request safe to repeat. Some calls are also safe to repeat on their own: POST /applications finds an existing application by the candidate’s email and by your source, and POST /jobs finds its posting by source. Use both protections where you have them.
The origin of an event
When a write through the API changes something, the event it causes carries origin inside data.object: the string api: followed by the id that GET /me returns for the key that made the write. For a token from Sign in with Tahoe it is app: followed by the app’s client ID, never the person it acted for. A change made in the Tahoe product has no origin. With it, an integration can drop the echo of its own change instead of applying it a second time.
{
"object": "event",
"id": "evt_2Wp5nRc8Kd3x",
"type": "application.stage_changed",
"sequence": 48219,
"data": {
"object": {
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"stage_id": "stg_3Rp8vKd2mXq4",
"origin": "api:pk_4f1a9c07d2e85b36"
},
"previous_attributes": { "stage_id": "stg_9Kd2mXq4Rp8v" }
}
}def handle(event, own_origin):
# An event caused by a write from this key carries its origin.
# A change made in Tahoe has no origin at all.
if event["data"]["object"].get("origin") == own_origin:
return # the echo of our own write
apply(event)- Every event your key causes has the same
origin. The simplest way to learn the exact value for your key is to read it from the first event one of your own writes causes, and keep it. - A key ID is an identifier, not a secret. It tells a reader which key wrote, never who or why.
- Another integration that shares the workspace sees your
originon your events and should still apply them: they are changes it did not make. - The event is still delivered to you.
originonly lets you recognize it. If you keep a copy of the data, the echo also carries the newupdated_at, which you may want.
Response headers
| Header | Sent on | Meaning |
|---|---|---|
Tahoe-Api-Version | Every response | The API version that answered, such as 2026-09-09. |
Tahoe-Request-Id | Every response | A unique ID for this request. Log it, and quote it when you contact us. |
RateLimit-Limit | Every response | The per-minute limit for the endpoint you called. |
RateLimit-Policy | Every response | All three request limits in one string. |
X-RateLimit-Limit | Every response | A copy of RateLimit-Limit for clients that read only X- headers. |
Retry-After | 429 responses | Seconds to wait before retrying. Use it rather than guessing. |
Idempotent-Replayed | A write answered from a stored response | true when the response is the stored first answer to a repeated Idempotency-Key, not the result of running the write again. |
Unknown parameters
A query parameter the API does not recognise is ignored, not refused. That keeps your client working if you send a parameter a later version adds, but it also means a misspelled filter quietly returns unfiltered data. Check a new filter against a small, known result the first time you use it.
Two places are strict instead: an unknown event type in the change feed’s ?type= returns 400 unknown_event_type, and an unknown field in the body of any write returns 400 invalid_request.