Getting started
Rate limits and quotas
Three request tiers, a daily budget that meters personal data rather than requests, and a paging window that stops this being an export tool.
Request tiers
Every endpoint sits in one of three tiers, metered per credential per minute. The quotas below are the defaults; your own are reported by /me, which is the authoritative answer for your key.
| Tier | Default quota | Which endpoints |
|---|---|---|
sustained | 600 / minute | Everything not listed below — all the ordinary list and object reads. |
expensive | 60 / minute | POST /pool/search, POST /people/resolve:batch, GET /analytics/hiring-funnel |
download | 30 / minute | GET /applications/{handle}/resume/download, GET /sourced-profiles/{handle}/attachments/{kind}/content |
Exceeding a tier returns 429 rate_limit_exceeded with Retry-After. The limits are per credential, not per IP, so running your integration on more machines does not buy more throughput.
The personal-data budget
Separately from the per-minute tiers, each credential has a daily budget for reads that disclose a personal-data value. The default is 5,000 per day.
Four scopes consume it, one unit per record disclosed:
contact:read— an email addresscontact:phone:read— a phone numberresume:raw_text:read— the full text of a resumeresume:download— a resume or attachment file
Nothing else does. You can page jobs, applications and pipelines all day without touching this meter — it exists to bound harvesting, not traffic. Exceeding it is 429 personal_data_quota_exceeded, and its Retry-After can be hours, because the window is a day rather than a minute.
/me reports both personal_data_reads_per_day and personal_data_reads_used_today, so a backfill can watch its own budget and stop deliberately rather than crash into the wall.
The paging window
Paging depth is bounded at 10,000 rows per listing. Past that, 400 result_window_exceeded.
This is not a limit you should be hitting. A bounded window is what keeps a paginated read API from being used as a bulk export tool, and the alternatives are genuinely cheaper for both sides:
?updated_after=to resume from your last successful sync.- The change feed for anything ongoing. It is designed for exactly this and has no window.
What the headers tell you
| Header | Value |
|---|---|
RateLimit-Limit | The quota for the tier of the endpoint you just called. |
RateLimit-Policy | All three tiers as a policy string: "sustained";q=600;w=60, … |
X-RateLimit-Limit | A mirror of RateLimit-Limit, for clients that only parse the X- names. |
Retry-After | On a 429 only. Seconds to wait, and authoritative. |
Being a good client
- Ask for full pages.
limit=100is one request where the default 25 is four. - Filter server-side.
?status=published&updated_after=…beats fetching everything and discarding it. - Do not poll for change. Follow the change feed cursor. Polling
/jobsevery minute to spot an edit costs you your quota and tells you less than onejob.updatedevent. - Read contact details lazily. Fetch a phone number when someone is about to use it, not for every candidate at sync time. The daily budget is the constraint most integrations meet first, and it is almost always because they hydrated fields nobody looked at.
- One worker per credential. Parallel workers sharing a key divide the same quota and make 429s look random.
me = session.get(f"{BASE}/me", timeout=30).json()
limits = me["rate_limits"]
budget = limits["personal_data_reads_per_day"] - limits["personal_data_reads_used_today"]
# Leave headroom for whatever else uses this credential today. Stopping on
# purpose is recoverable; walking into personal_data_quota_exceeded halfway
# through a backfill leaves you guessing which records got written.
for applicant in applicants[: max(0, budget - 200)]:
hydrate_contact_info(applicant)