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.

TierDefault quotaWhich endpoints
sustained600 / minuteEverything not listed below — all the ordinary list and object reads.
expensive60 / minutePOST /pool/search, POST /people/resolve:batch, GET /analytics/hiring-funnel
download30 / minuteGET /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 address
  • contact:phone:read — a phone number
  • resume:raw_text:read — the full text of a resume
  • resume: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

HeaderValue
RateLimit-LimitThe quota for the tier of the endpoint you just called.
RateLimit-PolicyAll three tiers as a policy string: "sustained";q=600;w=60, …
X-RateLimit-LimitA mirror of RateLimit-Limit, for clients that only parse the X- names.
Retry-AfterOn a 429 only. Seconds to wait, and authoritative.

Being a good client

  • Ask for full pages. limit=100 is 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 /jobs every minute to spot an edit costs you your quota and tells you less than one job.updated event.
  • 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.
Watching your own personal-data budget
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)