Skip to content

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.

PrefixObject
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:

List response
{
  "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..."
}
FieldMeaning
objectAlways "list". Each row has its own object.
dataThe rows on this page.
has_moreWhether there is anything after this page.
next_cursorThe 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.

Paging correctly
# 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.

A time filter
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 seeWhat it means
The key is absentTahoe does not hold this value.
The key is present and null, or an empty listTahoe holds it, and it is empty.
The key is absent and named in restrictedTahoe 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:

A response with withheld fields
{
  "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:

ReasonMeaningWhat 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_formSome 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_scopeThe 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.

Objects carry a links block. Links are paths, never full URLs. To follow one, put https://tahoe.workonward.com in front of it.

Links on a job
{
  "object": "job",
  "id": "job_7Kd2mXq4Rp8v",
  "links": {
    "self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v",
    "applications": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications"
  }
}
Following a link
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.

A write with a key
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" }'
A replayed response
HTTP/2 200
Idempotent-Replayed: true
Tahoe-Request-Id: req_3f9a1c7e5b2d4a60
RuleDetail
Format8 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.
ScopeA 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 bodyYou 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 request409 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 running409 idempotency_in_flight. Retry in a moment. If the first request died, its claim is released after about two minutes.
How long24 hours from the first request.
What is rememberedOnly 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 requiredOptional on most writes. Required where a duplicate does harm that cannot be undone: POST /messages returns 400 idempotency_key_required without it.
409 Conflict
{
  "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.

An event caused by a write through the API (shortened)
{
  "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" }
  }
}
Skipping your own echo
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 origin on your events and should still apply them: they are changes it did not make.
  • The event is still delivered to you. origin only lets you recognize it. If you keep a copy of the data, the echo also carries the new updated_at, which you may want.

Response headers

HeaderSent onMeaning
Tahoe-Api-VersionEvery responseThe API version that answered, such as 2026-09-09.
Tahoe-Request-IdEvery responseA unique ID for this request. Log it, and quote it when you contact us.
RateLimit-LimitEvery responseThe per-minute limit for the endpoint you called.
RateLimit-PolicyEvery responseAll three request limits in one string.
X-RateLimit-LimitEvery responseA copy of RateLimit-Limit for clients that read only X- headers.
Retry-After429 responsesSeconds to wait before retrying. Use it rather than guessing.
Idempotent-ReplayedA write answered from a stored responsetrue 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.