Getting started
Errors
One envelope for every failure, a stable code to branch on, and an explicit answer to whether retrying will help.
The envelope
Every error — including the ones FastAPI would otherwise render its own way — comes back in this shape:
{
"detail": {
"code": "insufficient_scope",
"type": "permission",
"message": "This endpoint requires the contact:read scope.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#insufficient_scope",
"required_scope": "contact:read",
"retry_after_seconds": null,
"request_id": "req_8Kf2mQx4Rp"
}
}| Field | Always present | Meaning |
|---|---|---|
code | Yes | The stable identifier. Branch on this, never on the message. |
type | Yes | The coarse category, for handling a class of failure at once. |
message | Yes | Human-readable. Safe to log, not safe to parse. |
param | Yes (may be null) | The query parameter or body field at fault. |
doc_url | Yes | A link to this page, anchored at the code. |
required_scope | Yes (may be null) | On a 403, the scope you were missing. |
retry_after_seconds | Yes (may be null) | Set on every 429. Mirrors the Retry-After header. |
request_id | Yes | Quote it in a support request. Also in Tahoe-Request-Id. |
errors | No | On a multi-field validation failure, one entry per bad field. |
Types
| Type | Statuses | Handling |
|---|---|---|
invalid_request | 400, 413 | Your request is wrong. Fix it; retrying is pointless. |
authentication | 401 | The credential is not usable. Check the key. |
permission | 403 | The credential is valid but not allowed. Check scopes. |
not_found | 404 | No such object, or not yours. Indistinguishable on purpose. |
payment_required | 403 | The workspace has not purchased this. Not your call to fix. |
rate_limit | 429 | Slow down. Honour Retry-After. |
unavailable | 429 | Not your fault. Retry after a short pause. See the note below. |
server | 500 | Our bug. Retry once, then report with the request_id. |
The catalogue
400invalid_requestinvalid_request
A parameter is missing, malformed, or contradictory. param names the offender. When more than one field is wrong, an errors array lists each with its own param and message.
FastAPI’s own validation failures are folded into this code as a 400, not a 422 — the 422 array is the one shape a detail.code check cannot read, and HRTech clients handle 400 consistently where they treat 422 in every possible way.
Retry? No. Fix the request.
400invalid_cursorinvalid_request
The cursor did not verify, was minted for a different query, or expired. Cursors are signed and bound to the endpoint, the normalised filter set and the credential that produced them.
The usual cause is changing a filter mid-loop: send the identical filters on every page and add only cursor. Do not construct, decode or mutate a cursor.
Retry? No. Restart the listing from the first page.
400invalid_timestampinvalid_request
A timestamp filter such as updated_after was not a valid RFC 3339 instant. Send 2026-09-09T10:14:22.510Z, not an epoch integer and not a local time.
Retry? No.
400workspace_id_requiredinvalid_request
Your credential can reach more than one workspace, so it must name which one every request means. Add ?workspace_id=wsp_….
There is no default and no implicit “all”: one accidentally unscoped query is how a multi-tenant sync reads a workspace it had no business touching. /me tells you whether this applies to your key via workspace_id_required.
Retry? No. Add the parameter.
400unpublished_requires_opt_ininvalid_request
You asked for draft, closed or archived jobs without opting in. Add include_unpublished=true.
The opt-in exists because a job board that mirrors /jobs naively should never publish a customer’s unfinished draft requisition. Asking for it has to be deliberate.
Retry? No.
400unknown_event_typeinvalid_request
A name in ?type= is not an event type. The full list is on the change feed page. Unknown names are rejected rather than ignored, so a typo in a filter cannot silently drop the events you were relying on.
Retry? No.
400not_an_identityinvalid_request
The value you passed to /people/resolve is not something Tahoe can identify a person by. Send an email address or a LinkedIn profile URL.
A person who simply is not in Tahoe returns 404 not_found instead — this code means the input was not an identifier at all.
Retry? No.
400result_window_exceededinvalid_request
You have paged deeper than the endpoint allows. The window is bounded so a paginated read API cannot be used as a bulk export tool.
The fix is not a bigger page size. Use ?updated_after= to resume from where your last sync finished, or follow the change feed — both are cheaper for you and for us than deep paging.
Retry? No. Switch to an incremental sync.
413payload_too_largeinvalid_request
A POST body exceeded the limit, rejected on the declared Content-Length before it was buffered. The two endpoints that take bodies both have their own item caps — /people/resolve:batch accepts up to 100 identities per call.
Retry? No. Send a smaller body.
401unauthenticatedauthentication
One body covers all of it: no header, an unparseable header, an unknown key, a revoked key, an expired key, and a request from an address the key’s IP allowlist does not permit.
They are deliberately indistinguishable — telling “revoked” apart from “never existed” would make this endpoint an oracle for testing stolen tokens. Check the key’s real status in Settings → Developer.
Retry? No, unless you have a different credential.
403insufficient_scopepermission
The credential is valid but was not granted what this endpoint needs. required_scope names it exactly.
Scopes cannot be widened on an existing key — mint a new one. And note that a missing scope on a field is not a 403: the request succeeds and the field is named in restricted. See the restricted contract.
Retry? No. Mint a key with the scope.
404not_foundnot_found
No such object — or it exists and your credential cannot reach it. The two are indistinguishable on purpose: confirming that a record exists in a workspace you cannot read is enumeration.
A malformed or wrong-type handle is also a 404 rather than a 400, for the same reason.
Retry? No.
403resume_lockedpayment_required
The resume exists but is not currently viewable by the workspace that owns it. The API mirrors the product’s own paywall exactly — it is never a cheaper path to a document than the product is.
The body carries state and unlock_credits, so you can tell a user what unlocking would cost. Note the paywall is time-based as well as purchase-based: a resume can move from viewable to locked without anything changing at your end.
Retry? No. The workspace must unlock it.
429rate_limit_exceededrate_limit
You exceeded the per-minute quota for this endpoint’s tier. Wait the number of seconds in Retry-After — do not retry immediately, and do not retry in a tight loop.
See rate limits for the three tiers and their quotas.
Retry? Yes, after Retry-After.
429personal_data_quota_exceededrate_limit
You have spent the credential’s daily budget for reads that disclose personal data. This is a separate meter from the request rate limit: paging jobs does not consume it, and reading phone numbers does.
Retry-After points at the window reset, which may be hours away. If you are hitting it during a legitimate backfill, that is a conversation to have with us rather than something to retry through.
Retry? Not today. The budget is daily.
429partner_api_disabledunavailable
The partner API is switched off on this deployment. This is a configuration state, not a transient one — retrying will produce the same answer until an operator changes it.
Retry? No. Nothing you do will change it.
429limiter_unavailableunavailable
Request metering could not be reached, so the request was refused rather than served unmetered.
Failing open here was considered and rejected: a credential that can read several workspaces must not become unlimited because a cache blinked.
Retry? Yes, after a short pause.
429datastore_unavailableunavailable
A backing store was briefly unreachable. Safe to retry — nothing was disclosed and nothing was consumed.
Retry? Yes, after a short pause.
429quota_unavailableunavailable
This read discloses personal data and its daily budget could not be checked. Refused rather than served, because an unmetered personal-data read is exactly what the budget exists to prevent.
Retry? Yes, after a short pause.
429audit_unavailableunavailable
This read discloses personal data and the per-record audit row could not be written, so the read was refused.
An unlogged disclosure of someone’s contact details is worse than a failed request, and a retry costs you very little. Nothing was disclosed.
Retry? Yes, after a short pause.
429pool_search_unavailableunavailable
The shared-pool search index was briefly unavailable. Reads of individual pool profiles are unaffected.
Retry? Yes, after a short pause.
500internal_errorserver
An unhandled failure. The body carries a request_id and nothing else — an internal error message can leak a query, a column name or a row, so the detail goes to our logs and the identifier goes to you.
Quote the request_id. With it, this is a lookup; without it, a guess.
Retry? Once. Then report it.
Retrying, in one rule
Retry 429 and 500. Never retry 400, 401, 403, 404 or 413 — the answer will not change, and a loop on a 401 looks exactly like credential stuffing from our side.
import time
import requests
RETRYABLE = {429, 500, 502, 504}
def call(session, method, url, *, attempts=4, **kwargs):
for attempt in range(attempts):
response = session.request(method, url, timeout=30, **kwargs)
if response.status_code not in RETRYABLE:
return response
# Honour the server's own number. Retry-After is authoritative on a 429,
# and on a personal-data quota it can be hours rather than seconds — so
# respect it instead of substituting a backoff of your own.
wait = response.headers.get("Retry-After")
delay = int(wait) if wait and wait.isdigit() else 2 ** attempt
if attempt == attempts - 1:
return response
time.sleep(delay)
raise AssertionError("unreachable")