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:

Error response
{
  "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"
  }
}
FieldAlways presentMeaning
codeYesThe stable identifier. Branch on this, never on the message.
typeYesThe coarse category, for handling a class of failure at once.
messageYesHuman-readable. Safe to log, not safe to parse.
paramYes (may be null)The query parameter or body field at fault.
doc_urlYesA link to this page, anchored at the code.
required_scopeYes (may be null)On a 403, the scope you were missing.
retry_after_secondsYes (may be null)Set on every 429. Mirrors the Retry-After header.
request_idYesQuote it in a support request. Also in Tahoe-Request-Id.
errorsNoOn a multi-field validation failure, one entry per bad field.

Types

TypeStatusesHandling
invalid_request400, 413Your request is wrong. Fix it; retrying is pointless.
authentication401The credential is not usable. Check the key.
permission403The credential is valid but not allowed. Check scopes.
not_found404No such object, or not yours. Indistinguishable on purpose.
payment_required403The workspace has not purchased this. Not your call to fix.
rate_limit429Slow down. Honour Retry-After.
unavailable429Not your fault. Retry after a short pause. See the note below.
server500Our 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.

A retry policy that behaves
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")