Rate limits and quotas
How many requests a key can make, and the daily limit on personal data.
The Tahoe API has three kinds of limit. Request limits cap how many calls a key makes per minute. A daily budget caps how much personal data a key reads, however slowly it reads it. And a paging limit stops any one listing being read deeper than 10,000 rows. All of them apply per key, and GET /me reports the values for yours.
Request limits
Every endpoint belongs to one of three tiers. Each key has its own allowance per tier per minute. The numbers below are the defaults.
| Tier | Default limit | Endpoints |
|---|---|---|
| sustained | 600 per minute | Everything not listed below: all the ordinary list and object reads. |
| expensive | 60 per minute | POST /pool/search, POST /people/resolve:batch, GET /analytics/hiring-funnel and POST /jobs |
| download | 30 per minute | GET /applications/{id}/resume/download and GET /sourced-profiles/{id}/attachments/{kind}/content |
Going over a limit returns 429 rate_limit_exceeded with a Retry-After header. Wait that many seconds before you try again. The limits count per key, not per IP address, so running your integration on more machines does not raise them.
HTTP/1.1 429 Too Many Requests
Retry-After: 17
{
"detail": {
"code": "rate_limit_exceeded",
"type": "rate_limit",
"message": "Rate limit exceeded. Slow down and retry.",
"param": null,
"doc_url": "https://tahoe.workonward.com/developers/errors#rate_limit_exceeded",
"required_scope": null,
"retry_after_seconds": 17
}
}Limits by plan
A key you create in Settings takes its limits from the plan of the workspace it belongs to. A plan with more headroom raises the per-minute allowances and the daily personal-data budget, and every plan adds a monthly request quota that all the keys of the workspace share. The numbers above are the defaults for keys that Tahoe sets up for you.
GET /me returns the limits that apply to the key you call it with, under rate_limits: the plan, the per-minute allowance, the monthly quota and how much of it the workspace has used. A key on a workspace with no plan gets the lowest limits, and the monthly quota returns 429 rate_limit_exceeded once it is spent, with Retry-After set to the time left.
The daily personal-data budget
On top of the per-minute limits, each key has a daily budget for reads that return personal data. The default is 5,000 values per day. It counts values, not requests:
| What you read | Scope | Uses |
|---|---|---|
| An email address | contact:read | 1 per address returned |
| A phone number | contact:phone:read | 1 per number returned |
| The full text of a resume | resume:raw_text:read | 1 per resume |
| A resume or attachment file | resume:download or attachments:read | 1 per file |
Nothing else uses it. You can page through jobs, applications and pipelines all day without touching this budget: it exists to stop bulk copying of personal data, not to slow down ordinary traffic.
Going over it returns 429 personal_data_quota_exceeded. The budget resets at midnight UTC, and Retry-After counts down to then, so it can be hours rather than seconds. GET /me reports both the budget and what you have used today, so a long job can watch it and stop on purpose:
{
"rate_limits": {
"general_per_minute": 600,
"expensive_per_minute": 60,
"download_per_minute": 30,
"personal_data_reads_per_day": 5000,
"personal_data_reads_used_today": 128,
"max_page_size": 100,
"default_page_size": 25,
"max_result_window": 10000
}
}How deep you can page
One listing can be paged up to 10,000 rows deep. Past that you get 400 result_window_exceeded. You should not need to go that far. To keep a copy current:
- filter with
?updated_after=from the time of your last successful sync; - or follow the change feed, which is built for this and has no depth limit.
Rate limit headers
Every response tells you the limit for the endpoint you called:
HTTP/1.1 200 OK
Content-Type: application/json
Tahoe-Api-Version: 2026-09-09
Tahoe-Request-Id: req_3f9a1c7e5b2d4a60
RateLimit-Limit: 600
RateLimit-Policy: "sustained";q=600;w=60, "expensive";q=60;w=60, "download";q=30;w=60
X-RateLimit-Limit: 600| Header | Meaning |
|---|---|
RateLimit-Limit | The per-minute limit for the tier of the endpoint you just called. |
RateLimit-Policy | All three tiers, each with its limit (q) and window in seconds (w). |
X-RateLimit-Limit | A copy of RateLimit-Limit, for clients that read only X- headers. |
Retry-After | Only on a 429. Seconds to wait. |
Being a good client
- Ask for full pages.
limit=100is one request where the default of 25 takes four. - Filter on the server.
?status=published&updated_after=...beats fetching everything and throwing most of it away. - Do not poll for changes. Follow the change feed. Reading
/jobsevery minute to spot an edit spends your limit and tells you less than onejob.updatedevent. - Read contact details when you need them. Fetch a phone number when someone is about to call, not for every candidate during a sync. The daily budget is the limit most integrations reach first, almost always because they fetched values nobody looked at.
- One worker per key. Several workers sharing a key share one allowance, which makes 429 responses look random.
import os
import requests
BASE = "https://tahoe.workonward.com/api/partner/v1"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['TAHOE_API_KEY']}"
limits = SESSION.get(f"{BASE}/me", timeout=30).json()["rate_limits"]
left_today = limits["personal_data_reads_per_day"] - limits["personal_data_reads_used_today"]
# Leave headroom for anything else that uses this key today. Stopping on
# purpose is easy to resume; running into personal_data_quota_exceeded halfway
# through a backfill leaves you working out which records were written.
left = left_today - 200
for applicant_id in applicant_ids:
if left <= 0:
break # pick up the rest tomorrow, after midnight UTC
contact = SESSION.get(f"{BASE}/applicants/{applicant_id}/contact-info", timeout=30).json()
save_contact(applicant_id, contact)
# Every email and phone number returned uses one unit of the budget.
left -= len(contact.get("emails", [])) + len(contact.get("phones", []))