Errors
Every error code, what it means and whether to retry.
Every error from the Tahoe API comes back in the same shape, with a stable code you can branch on and a type that groups codes into families. This page lists every code, what causes it, and whether retrying will help.
The error body
An error is a JSON object with a single detail member:
{
"detail": {
"code": "insufficient_scope",
"type": "permission",
"message": "This credential does not carry the contact:read scope.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#insufficient_scope",
"required_scope": "contact:read",
"retry_after_seconds": null
}
}| Field | Meaning |
|---|---|
code | The stable identifier. Branch on this, never on the message. |
type | The family the code belongs to, for handling a whole class of failure at once. |
message | A sentence for people. Safe to log, not safe to parse. |
param | The query parameter or body field at fault, or null. |
doc_url | A link to this page, at the entry for the code. |
required_scope | On a 403 for a missing scope, the scope you need. Otherwise null. |
retry_after_seconds | On a 429, how long to wait. The same number as the Retry-After header. |
errors | Only on a validation failure: one entry per bad field, each with param and message. |
state, unlock_credits | Only on resume_locked. |
The request ID is not in the body. It is in the Tahoe-Request-Id header, which every response carries, successful or not. Log it with every failure and quote it when you contact us: with it, we can find the exact request.
Error types
| Type | Status | What to do |
|---|---|---|
invalid_request | 400, 413, 422 | Your request is wrong. Fix it; retrying will not help. |
authentication | 401 | The key cannot be used. Check it. |
permission | 403 | The key is valid but not allowed to do this. Check its scopes. |
payment_required | 403 | The workspace has not unlocked this in Tahoe. Not something your code can fix. |
not_found | 404 | No such object, or not one your key can reach. The two look the same on purpose. |
conflict | 409 | The resource changed, is in a state that does not allow this, or the person cannot be contacted. Read it again before you retry. |
rate_limit | 429 | Slow down. Wait for Retry-After. |
unavailable | 429 | Not your fault. Retry after Retry-After, usually a few seconds. |
server | 500 | A problem on our side. Retry once, then report it with the request ID. |
A 429 is not always a rate limit
When Tahoe cannot serve a request for a short time, it answers 429 with type: "unavailable", a specific code and a Retry-After header. It does not answer 503. So do not treat every 429 as “you are sending too fast”. Look at type: rate_limit means slow down, and unavailable means your request rate is fine and a retry in a few seconds should work.
HTTP/1.1 429 Too Many Requests
Retry-After: 5
Tahoe-Request-Id: req_3f9a1c7e5b2d4a60
{
"detail": {
"code": "datastore_unavailable",
"type": "unavailable",
"message": "This resource is temporarily unavailable. Retry shortly.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#datastore_unavailable",
"required_scope": null,
"retry_after_seconds": 5
}
}400 and 413: fix the request
invalid_request
400 · type invalid_request · Retry: No. Fix the request.
A parameter or body field is missing, malformed or contradictory. param names it. When several fields are wrong, errors lists each one. Validation failures always come back as 400 in this shape, never as 422.
{
"detail": {
"code": "invalid_request",
"type": "invalid_request",
"message": "One or more parameters are invalid.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#invalid_request",
"required_scope": null,
"retry_after_seconds": null,
"errors": [
{ "param": "title", "message": "Field required" },
{ "param": "source.external_id", "message": "String should have at least 1 character" }
]
}
}invalid_cursor
400 · type invalid_request · Retry: No. Start the listing again from the first page.
The cursor did not verify, was made for a different query, or has expired (cursors last one hour). The usual cause is changing a filter partway through a loop. Send the same filters on every page and add only cursor; changing limit is fine. Never build, decode or edit a cursor. See Cursors.
invalid_timestamp
400 · type invalid_request · Retry: No.
A time filter such as updated_after is not a valid RFC 3339 timestamp. Send 2026-09-09T10:14:22.510Z, not an epoch number and not a word like yesterday.
workspace_id_required
400 · type invalid_request · Retry: No. Add the parameter.
Your key can reach more than one workspace, so every request must say which one it means. Add ?workspace_id=wsp_.... There is no default and no “all workspaces”. GET /me tells you whether this applies to your key. See Which workspace a request reads.
unpublished_requires_opt_in
400 · type invalid_request · Retry: No.
You asked GET /jobs for job statuses other than published and closed, such as drafts, without opting in. Add include_unpublished=true. The opt-in exists so that a job board that copies /jobs can never show a recruiter’s unfinished draft by accident.
unknown_event_type
400 · type invalid_request · Retry: No.
A name in ?type= on the change feed is not an event type. Unknown names are refused rather than ignored, so a typo cannot quietly drop the events you rely on. The full list is on the Change feed page.
not_an_identity
400 · type invalid_request · Retry: No.
The value you sent to GET /people/resolve cannot identify one person. For example, a shared role address such as [email protected] is refused, because it would merge everyone who uses it into one person. Send a personal email address or a LinkedIn profile URL. A person who is simply not in Tahoe returns 404 not_found instead.
result_window_exceeded
400 · type invalid_request · Retry: No. Switch to an incremental sync.
You have paged deeper than 10,000 rows into one listing. A bigger page size will not help. Use ?updated_after= to start from where your last sync finished, or follow the change feed.
unsupported_source_system
400 · type invalid_request · Retry: No. Fix source.system and resend.
source.system is a name you choose: 2 to 40 lowercase letters, digits and underscores, such as acme_ats. This code means the value does not fit that shape, or is a name Tahoe reserves for its own use. Names that start with tahoe are always refused, and so are a few others. Pick another name.
invalid_idempotency_key
400 · type invalid_request · Retry: No. Fix the header.
The Idempotency-Key header is not 8 to 255 characters from A-Z a-z 0-9 . _ : ~ -. See Idempotency.
idempotency_key_required
400 · type invalid_request · Retry: No. Add the header.
The endpoint needs an Idempotency-Key header, so that a retry cannot do the work twice. POST /messages is the one that requires it.
invalid_stage
400 · type invalid_request · Retry: No. Use a valid stage.
The stage cannot be used. When moving an application, or creating one, the handle is malformed, or the stage does not exist, or it belongs to another job or workspace, or (for a new application) it is a final stage such as Hired or Rejected. All of these give the same answer. When setting a list member’s stage, the value is not one the list can hold, and the message lists the valid ones.
invalid_event_type
400 · type invalid_request · Retry: No. Fix the name.
A name in event_types of a webhook endpoint is not in the event catalogue. param is event_types. See the event types.
event_type_not_permitted
400 · type invalid_request · Retry: No. Use a key with the scope, or drop the type.
The key does not hold the read scope of an event type you asked a webhook endpoint to receive. The message names the type and the scope.
webhook_url_not_allowed
400 · type invalid_request · Retry: No. Use a public https URL.
The URL of a webhook endpoint breaks a rule, does not resolve, or resolves to a non-public address. param is url. See the rules for the URL.
invalid_upload
400 · type invalid_request · Retry: No. Fix the request.
POST /uploads cannot reserve an upload for that file type or size.
unknown_answer_field
400 · type invalid_request · Retry: No. Fix the answer.
An answer in POST /applications names a question the job’s application form does not have. param is answers[i].field_id.
duplicate_answer_field
400 · type invalid_request · Retry: No. Answer each question once.
The same question is answered twice in one POST /applications.
invalid_answer_value
400 · type invalid_request · Retry: No. Fix the value.
An answer has the wrong shape, or is not one of the question’s options.
answer_field_not_accepted
400 · type invalid_request · Retry: No. Leave the question out.
The question is an equal-opportunity question, a consent box, a section header or the resume field. Only the candidate can answer the first two, and the API never takes them.
answer_field_reserved
400 · type invalid_request · Retry: No. Send it in candidate.
The question is the name, email, phone or LinkedIn field. Send those in the candidate object, not as answers.
missing_required_answers
400 · type invalid_request · Retry: No. Add the answers.
The job’s form has required questions that have no answer. The message names the questions and never the candidate. A required phone number and the resume are not counted.
upload_not_found
400 · type invalid_request · Retry: No. Upload the file again.
resume_upload_id is unknown, expired, or belongs to another workspace. The three look the same on purpose.
resume_rejected
400 · type invalid_request · Retry: No. Upload a valid file.
The uploaded file is empty or larger than 5 MB. The file is deleted and the upload ID is spent.
payload_too_large
413 · type invalid_request · Retry: No. Send a smaller body.
A request body is larger than 256 KB. It is refused on its declared size, before it is read. The endpoints that take a body also limit how many items it holds: for example, POST /people/resolve:batch takes up to 100 lookups per call.
401 and 403: the key and its permissions
unauthenticated
401 · type authentication · Retry: No, unless you switch to a different key.
One code covers every key problem: no header, an unreadable header, an unknown key, a revoked or expired key, and a request from an address outside the key’s IP allowlist. They are deliberately alike, so that nobody holding a stolen key can test whether it still works. Do not retry a 401 in a loop. See One 401 for every key problem.
insufficient_scope
403 · type permission · Retry: No. Ask Tahoe for a key with the scope.
The key is valid but does not carry the scope this endpoint needs. required_scope names it. Scopes cannot be added to an existing key, so this means a new key.
A missing scope on a single field is not a 403: the request succeeds and the field is listed in restricted. See Withheld fields.
write_requires_api_key
403 · type permission · Retry: No.
Every write needs an API key that belongs to a Tahoe user, because a write is attributed to that person. A token from Sign in with Tahoe acts for a person and can read, and it writes only when writes for connected apps are switched on. A key that Tahoe cannot link to a Tahoe user gets the same code. Create a new key in Settings, under Developer.
webhooks_self_serve_disabled
403 · type permission · Retry: No. Email us, or wait until it is on.
Registering webhook endpoints through the API is not switched on yet. Email [email protected] and Tahoe registers the endpoint for you.
resume_locked
403 · type payment_required · Retry: No. The workspace must unlock the resume in Tahoe.
The resume exists, but the workspace cannot view or download it right now. The API follows the same resume window as the product: free to view and download for 90 days after the application, view-only (no file download) until day 120, then locked until the workspace unlocks it. Unlocking costs 50 credits, once, and is permanent. The API never gives you a cheaper way to a resume than the product does.
The body tells you the state and what unlocking would cost, so you can tell your user. The window moves with time, so a resume you could read last month can be locked today without anything changing on your side.
{
"detail": {
"code": "resume_locked",
"type": "payment_required",
"message": "This resume is not currently viewable by the workspace that owns it.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#resume_locked",
"required_scope": null,
"retry_after_seconds": null,
"state": "locked",
"unlock_credits": 50
}
}404: not found
not_found
404 · type not_found · Retry: No.
There is no such object, or it exists and your key cannot reach it. The two look the same on purpose: confirming that a record exists in a workspace you cannot read would leak it. A malformed or wrong-type ID is also a 404.
409: conflicts
job_modified_concurrently
409 · type conflict · Retry: Yes. Fetch the job again, then resend.
The posting changed while your POST /jobs was being saved, usually because a recruiter was editing it in Tahoe at the same moment. Nothing in your body is wrong. Fetch the job again and send your body as it was.
import_link_conflict
409 · type conflict · Retry: No. Ask Tahoe to review the link.
A company can be linked to a Tahoe workspace once, by one verified owner. The link you asked for does not agree with the one on record, so it was refused until a person reviews it. This applies only to the account linking call that Tahoe sets up with a partner.
invalid_job_transition
409 · type conflict · Retry: Not until the posting's status changes.
The posting’s current status does not allow what you asked. On POST /jobs, you sent publish: true or status: "closed" for a posting that cannot make that move, and the posting itself was still created or updated: only the publish or close was refused. On the lifecycle calls, the move is not allowed from the job’s status, such as publishing a job that is already published.
stale_resource
409 · type conflict · Retry: Yes, after reading the resource again.
You sent expected_updated_at and the application or job has changed since. Nothing was changed. Read the resource again and decide whether your change still applies.
application_modified_concurrently
409 · type conflict · Retry: Yes.
The application changed between the lock and the write. This cannot normally happen. Retry.
invalid_application_state
409 · type conflict · Retry: Not until the status changes.
Reject needs an application that is not already rejected, withdrawn or hired. Reopen needs a rejected application. Read the application and check its status first.
idempotency_key_reused
409 · type conflict · Retry: No. Use a new key.
The same Idempotency-Key was already used with a different request. Use a new key for a new request. See Idempotency.
idempotency_in_flight
409 · type conflict · Retry: Yes, after a short wait.
A request with the same Idempotency-Key is still running. Retry in a moment with the same key, and you get its answer.
recipient_unsubscribed
409 · type conflict · Retry: No.
POST /messages did not send, because the person unsubscribed, was suppressed or erased, or has an address that cannot receive mail. Do not retry.
sender_email_missing
409 · type conflict · Retry: Not until the user has an email address.
The Tahoe user the key acts for has no email address, so replies would have nowhere to go. Nothing was sent.
job_not_accepting_applications
409 · type conflict · Retry: Not until the job takes applications.
POST /applications needs a job that is published and accepts applications. Draft, scheduled, paused and closed jobs do not.
applicant_suppressed
409 · type conflict · Retry: No. Stop sending this person.
The person was erased, or asked Tahoe to stop processing their data. No application was created. See Erasure notices.
applicant_unsubscribed
409 · type conflict · Retry: No.
The person unsubscribed in this workspace, so no new application was created. A person who is already on the job is still returned with 200.
candidate_consent_required
409 · type conflict · Retry: No. The candidate has to apply themselves.
The job’s form has a required consent box that only the candidate can tick. The message names the box.
upload_already_used
409 · type conflict · Retry: No. Upload the file again.
The upload was already attached to an application. An upload can be used once.
upload_not_completed
409 · type conflict · Retry: Yes, after uploading the file.
No file has arrived at the upload URL yet. PUT the file there, then retry with the same upload ID.
application_conflict
409 · type conflict · Retry: Yes.
A concurrent request created the same application between the check and the insert, and it could not be read back. Retry, and you get it.
webhook_endpoint_exists
409 · type conflict · Retry: No. Change or turn on the existing one.
This key already has an endpoint with that URL in this workspace.
webhook_endpoint_limit_reached
409 · type conflict · Retry: Not until you disable an endpoint.
The key has 10 enabled endpoints, or the workspace has 20, or the key has 100 in total including disabled ones.
webhook_endpoint_disabled
409 · type conflict · Retry: Not until you turn the endpoint on.
You asked for a test event for an endpoint that is disabled.
422: the job is not ready
job_not_publishable
422 · type invalid_request · Retry: No. Complete the job, then publish.
POST /jobs/{job_handle}/publish was refused because the job lacks what a public page needs. reasons lists the missing parts: title when the title is blank, and description when neither the description nor the summary has any text.
429: wait, then retry
rate_limit_exceeded
429 · type rate_limit · Retry: Yes, after Retry-After.
You went over the per-minute limit for this endpoint. Wait the number of seconds in Retry-After. Do not retry at once or in a tight loop. See Rate limits and quotas.
message_quota_exceeded
429 · type rate_limit · Retry: Yes, after Retry-After.
The key has sent its daily maximum of messages through POST /messages, 200 per UTC day by default. Retry-After is the number of seconds to UTC midnight. The failed request does not use up allowance.
too_many_pending_uploads
429 · type rate_limit · Retry: Yes, after Retry-After.
The workspace holds 500 or more resume uploads that were never attached to an application and have not expired. Retry-After is 600 seconds.
webhook_test_throttled
429 · type rate_limit · Retry: Yes, after Retry-After.
A test event was sent to this endpoint a moment ago. One test per endpoint per 10 seconds.
personal_data_quota_exceeded
429 · type rate_limit · Retry: Not today. The budget is daily.
The key has used up its daily budget for reads that return personal data. This budget is separate from the request limit: paging through jobs does not use it, reading phone numbers does. It resets at midnight UTC, and Retry-After counts down to then, so it can be hours. If you hit it during a planned backfill, talk to us rather than retrying through it.
partner_api_disabled
429 · type unavailable · Retry: No. Retrying will not change the answer.
The Tahoe API is turned off. This is not a passing fault: every request gets the same answer until Tahoe turns it back on. Contact us.
limiter_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
Tahoe could not count this request against your rate limit for a moment, so it refused the request rather than serve it uncounted. Your request rate is not the problem.
datastore_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
The data behind this request was briefly out of reach. Nothing was returned, so retrying is safe.
quota_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
This read returns personal data, and Tahoe could not check the key’s daily budget for it. It refused the read rather than let it through unmetered.
audit_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
This read returns personal data, and Tahoe could not record the disclosure, so it refused the read. Nothing was disclosed. An unrecorded disclosure of someone’s contact details is worse than a failed request, and a retry costs you very little.
pool_search_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
Search over the shared pool (POST /pool/search) was briefly unavailable. Reading single pool profiles is not affected.
suppression_check_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
Tahoe could not read its suppression list, so POST /applications created nothing. Retry shortly.
webhook_secrets_unavailable
429 · type unavailable · Retry: Yes, after Retry-After.
Tahoe cannot create or read webhook signing secrets right now. Nothing was changed.
webhooks_delivery_disabled
429 · type unavailable · Retry: No, not until delivery is on.
Webhook delivery is switched off, so a test event would never be sent. Creating endpoints still works.
delivery_failed
429 · type unavailable · Retry: Yes, with a new Idempotency-Key.
The mail provider refused or failed to send a message from POST /messages. Nothing reached the person. The same key reports the same failure again, by design, so a failed send is never repeated by accident. Retry with a new key.
500: a problem on our side
internal_error
500 · type server · Retry: Once. Then report it.
Something failed on Tahoe’s side. The body says nothing more, by design: details of an internal failure stay in our logs. Do not depend on the body of a 500. Read the ID from the Tahoe-Request-Id header and send it to us.
When to retry
Retry a 429 or a 5xx, waiting for Retry-After when it is sent, with two exceptions: stop on partner_api_disabled (it will not clear by itself) and on personal_data_quota_exceeded (pick the job up again the next day). Retry 409 job_modified_concurrently, 409 stale_resource and 409 application_modified_concurrently after reading the resource again, and 409 idempotency_in_flight after a short wait with the same key. Never retry 400, 401, 403, 404, 413 or 422: the answer will not change, and a loop of 401 responses looks exactly like someone guessing keys. Retry a write only with the same Idempotency-Key, so that the retry cannot do the work twice. The one exception is delivery_failed, which needs a new key.
import time
def call(session, method, url, *, attempts=4, **kwargs):
"""Send a request, retrying only what is worth retrying."""
for attempt in range(attempts):
response = session.request(method, url, timeout=30, **kwargs)
code = response.json().get("detail", {}).get("code") if response.status_code == 429 else None
retryable = response.status_code >= 500 or (
response.status_code == 429 and code not in ("partner_api_disabled", "personal_data_quota_exceeded")
)
if not retryable or attempt == attempts - 1:
return response
# Use the server's own number when it sends one, and give up on a
# wait longer than five minutes rather than sleeping through it.
wait = response.headers.get("Retry-After")
delay = int(wait) if wait and wait.isdigit() else 2 ** attempt
if delay > 300:
return response
time.sleep(delay)
return response