HubSpot API rate limits.
HubSpot rate-limits every API token along two axes — a per-second burst and a per-day pool — and a 429 from either one halts your sync. HS-X's runtime spends that budget through a per-portal token bucket: every ctx.hubspot call takes a lease, live headers clamp the bucket, and a 429 backs it off — so you almost never see a 429 in your own code. This page is what the runtime is doing and how to read the headers when something does slip through.
The 30-second answer
HubSpot enforces two limits on every API call: a burst limit (per 10 seconds) and a daily pool. The burst limit is scoped per app (per installing account for marketplace OAuth apps). The daily pool is scoped to the HubSpot account: every privately distributed app in that account draws from the same daily pool.
The ceilings depend on how the app is distributed. A privately distributed app (a 2025.2 or 2026.03 app installed outside the marketplace, or a legacy private app) inherits the installing account's tier: 100 requests per 10 seconds per app and 250,000 per day per account on Free and Starter, rising to 190 per 10 seconds and 1,000,000 per day on Enterprise. A marketplace-distributed OAuth app gets a flat 110 requests per 10 seconds in every account that installs it, CRM Search excluded. The full matrix is below. HS-X provisions its own client-side budget at 100 burst tokens refilling 10 per second per portal, at or under every published burst figure, so the engine's assumptions and HubSpot's limits line up.
HS-X's runtime paces every ctx.hubspot call — typed or raw — through that per-portal budget, clamps the budget against the live rate-limit headers, retries short 429/5xx responses, and turns long Retry-After windows into platform backpressure. Reads can opt into /batch/read coalescing (up to 100 records per call) via ctx.hubspot.batch. You'd have to do something unusual — a plain fetch to api.hubapi.com with your own token, bypassing ctx.hubspot entirely — to hit a 429 from inside an HS-X project.
Use batch endpoints; trust Retry-After; watch the daily pool, not the burst. The burst recovers in 10 seconds. Burning the daily pool means your sync stops shipping data until midnight in the account's configured time zone.
The limit matrix by tier
Every HubSpot account has a tier; every tier has a different ceiling. The numbers below are the public figures HubSpot publishes. Privately distributed apps inherit the installing account's tier (the daily pool is shared across every app in that account); marketplace-distributed OAuth apps get one flat per-account burst instead.
| Distribution and tier | Burst (per 10 s) | Daily pool |
|---|---|---|
| Private · Free / Starter | 100 per app | 250,000 per account |
| Private · Professional | 190 per app | 625,000 per account |
| Private · Enterprise | 190 per app | 1,000,000 per account |
| Private · with API Limit Increase (any tier) | 250 per app | 1,000,000 per account, per increase (maximum two) |
| Marketplace OAuth app | 110 per installing account | Not published separately; the add-on does not apply |
A few notes that matter more than the numbers themselves:
- Search has its own bucket — and no headers.
POST /crm/v3/objects/*/searchcounts against a separate, much tighter per-second limit: HubSpot allows 5 search requests per second per account, not a per-tier per-10s figure (HS-X's default Search bucket is 4 per second, deliberately under it). Search responses carry none of the rate-limit headers, so you can't even watch it drain. A search-heavy workload runs out of search quota long before it runs out of general quota. Batch endpoints are different: HubSpot counts a/batch/*call as one request against the general burst — there is no separate batch bucket, in HubSpot's accounting or in HS-X's. - The daily pool resets at midnight in the account's time-zone setting, not on a rolling 24-hour window and not necessarily at UTC midnight. A burst at 23:55 local followed by another at 00:05 local counts against two different days.
- Burst is per app; daily is per account. Two private apps in the same account each get their own 10-second window but drain one shared daily pool. A marketplace OAuth app has its own 110-per-10-seconds window per installing account and no separately published daily figure.
- The API Limit Increase is a paid add-on for privately distributed apps only. Each increase adds 250 requests per 10 seconds per app and 1,000,000 requests per day per account on top of the base tier, and an account can buy at most two. It does not raise the limits of marketplace-distributed OAuth apps (or legacy public apps); their 110 per 10 seconds per installing account is fixed.
What the response headers tell you
Non-Search 2xx and 429 responses from a HubSpot API call carry the headers below, with two carve-outs that matter. Search responses carry none of them (which is why HS-X paces Search blind, from a conservative bucket — more in §03), and both Daily headers only appear on private-app / static-token auth, never OAuth. Read the table once and you can debug any quota problem without leaving your terminal.
| Header | Meaning |
|---|---|
X-HubSpot-RateLimit-Daily | Your account's daily ceiling. Private-app / static-token responses only; absent on OAuth. |
X-HubSpot-RateLimit-Daily-Remaining | Calls left until midnight in the account's time zone. The one to alert on — when your token gets it. Absent on OAuth-authed responses, so this is only actionable for private-app tokens. |
X-HubSpot-RateLimit-Interval-Milliseconds | Length of the burst window (normally 10000; HS-X throttles on whatever value actually arrives). |
X-HubSpot-RateLimit-Max | Your tier's burst ceiling for this window. |
X-HubSpot-RateLimit-Remaining | Calls left in this 10 second window. |
X-HubSpot-RateLimit-Secondly-Remaining | Calls left in this one-second sub-window. Deprecated: still sent and accurate, but the per-second limit it describes is no longer enforced. HS-X clamps to the lower of this and -Remaining, so it can only make the bucket more conservative. |
When you get a 429 Too Many Requests, HubSpot may also return:
| Header | Meaning |
|---|---|
Retry-After | When present, how long to wait (delta-seconds or an HTTP date; HS-X parses both). HubSpot's usage guidelines do not document its value; the 429 body's policyName (TEN_SECONDLY_ROLLING vs DAILY) is the documented way to tell burst from daily exhaustion. |
$ curl -sI -H "Authorization: Bearer $TOKEN" \
https://api.hubapi.com/crm/v3/objects/contacts?limit=1
HTTP/2 200
x-hubspot-ratelimit-daily: 625000
x-hubspot-ratelimit-daily-remaining: 612211
x-hubspot-ratelimit-interval-milliseconds: 10000
x-hubspot-ratelimit-max: 190
x-hubspot-ratelimit-remaining: 188The two numbers that actually drive decisions are Daily-Remaining (capacity planning, on the tokens that get it) and Retry-After on a 429 (operational behavior). Everything else is context.
What the runtime does for you
You almost never need to think about any of the above when you're writing HS-X code. The runtime sits between your handler and HubSpot — every ctx.hubspot call, typed or raw, flows through it — and applies five behaviors automatically.
- A client-side budget that fires before HubSpot's does. Each portal gets a token bucket provisioned at 100 tokens, refilling at 10 per second, at or under every published limit, and every
ctx.hubspotcall takes a short-lived lease from it before the request leaves the Worker. A hot loop hits HS-X's own backpressure first, as a structured result with a retry-after hint, instead of burning the portal's real quota down to a 429. - Live header tracking. The bucket doesn't guess. Every response's
X-HubSpot-RateLimit-Remainingclamps the local count down to HubSpot's authoritative number, and when remaining hits zero the runtime throttles the portal for the interval the header reports. ADaily-Remainingof zero — visible on private-app tokens only — pauses the portal's bucket for 60 seconds and re-checks on the next response. - 429 backoff and
Retry-Afterhonoring. A 429 empties the bucket, halves its refill rate, and — if HubSpot sentRetry-After— holds all traffic to that portal for that window. Short waits are retried in-process; waits longer than the configured retry cap become structured backpressure so HubSpot or the caller retries later. There is no separate daily-exhaustion mode, so an hours-longRetry-Afterparks the portal for hours. The halved refill rate is not restored until the isolate is recycled, so a portal that has 429'd once stays paced at half speed (never below 1 per second) in that isolate. - A separate, conservative Search bucket. Any path with a
/searchsegment draws from its own per-portal bucket, 4 requests per second by default regardless of tier, one under HubSpot's 5-per-second Search limit. Search responses omit all the rate-limit headers, so this bucket can't track HubSpot's live counter — it paces by its defaults and backs off on 429s. The upshot: a bulk search-driven backfill slows itself down without draining the general budget. - Batched reads and writes.
ctx.hubspot.batch.objects.read(...)— and the matchingpropertiesandassociationssurfaces — coalesce lookups into/batch/readcalls of up to 100 records, sharing the same auth and rate-limit accounting as everything else.ctx.hubspot.batch.objects.create / update / upsert(...)chunk rows to HubSpot's 100-input cap, retry retryable chunks, and return a{ succeeded, failed }partition instead of throwing. Source-backed syncs deliver through that batch-upsert path automatically, 100 rows per call keyed on the sync'sidProperty. Only calls you make one at a time through the typed or raw client stay one request each.
When the budget runs dry, HS-X doesn't throw — your capability returns a structured backpressure result and the platform retries on its own schedule:
| Surface | What you get back |
|---|---|
| Workflow action (inline), agent tool | fail-continue with retryAfterSeconds in output |
| Card backend, sync, batched workflow action, streaming backend, webhook trigger | retry-later with a top-level retryAfterSeconds |
Both shapes carry the message HubSpot rate limit budget exhausted; retry after Ns. and both record in checkpoints as successful invocations — backpressure is the system working, not an error.
Tune the defaults from hsx.config.ts with rateLimits.retry (max, baseMs, capMs) and rateLimits.bucket (capacity, refillPerSecond, searchCapacity, searchRefillPerSecond, requestedTokensPerCall). Set rateLimits.metrics: { enabled: true } to have the generated Worker emit rate-limit metric events through the runtime logger (metrics: { enabled: false } currently still enables them; pass metrics: false to turn them off). Omit the block for the conservative defaults: 100 general tokens refilling 10 per second, 4 Search tokens refilling 4 per second, and retry { max: 5, baseMs: 200, capMs: 10000 }.
Bypass ctx.hubspot. A plain fetch('https://api.hubapi.com/...', ...) with your own token never touches the budget — no lease, no header tracking, no backoff. Everything routed through ctx.hubspot, including a raw per-row client.get(...) loop, hits HS-X's backpressure before it hits HubSpot's 429.
Capacity planning against the daily pool
The burst limit is self-healing: 10 seconds and it's back. The daily pool is the one that bites. A back-of-envelope sizing:
- A 5-minute sync that writes one record per call issues N calls per run. At N = 100 changed rows that's 100 calls × 288 runs/day = 28,800 calls/day — well inside any tier. At N = 10,000 it's 2.88M/day — far outside every tier, and the local budget (10 calls per second) would stretch each run past 16 minutes anyway. Volume like that wants HubSpot's
/batch/*write endpoints, which count 100 records as one request. - A read-heavy reconciliation through
ctx.hubspot.batchissuesceil(N/100)batch-read calls. At N = 1,000,000 that's 10,000 calls/day. Fine. - A search-driven reconciliation issues 1 search call per page (up to 200 records). At 1M records that's 5,000 search calls/day at the full page size — and the search bucket is much tighter, so it's the search bucket, not the main pool, that sets your floor: at HS-X's default 4 searches per second, those calls take at least 21 minutes of pure search time (42 at 100 per page).
There's no quota-projection command today — the math above is the tool. What the CLI gives you is the observed side: hs-x checkpoint prints the project's invocation totals and latency percentiles over the read window, and multiplying invocations by your handler's calls-per-invocation is a serviceable daily-burn estimate. Anything trending past about a quarter of your tier's daily pool should prompt a conversation about cutting frequency or upgrading tier — a week before it becomes a quota incident, not after.
The four levers when you're hitting a limit
When your capabilities start coming back with retry-later backpressure results, or Daily-Remaining is trending toward zero on a private-app token, there are four moves — in order of cost.
- Move from individual to batch. Reads coalesce with
ctx.hubspot.batch.objects.read(...)— one/batch/readcall covers 100 records. For writes, usectx.hubspot.batch.objects.upsert(...)(orcreate/update), or let a source-backed sync do it for you; HubSpot counts 100 records as one request. Either way the swap cuts request count by ~100×. Cheapest and almost always available. - Drop the frequency. A
5msync that mostly returns “no new rows” is mostly waste. If your source can push changes, declare it withdefineSource.pushandschedule: 'event'so a run happens only when a webhook arrives; otherwise back off to15m. A sync polling for a source that changes hourly does not need five-minute freshness. - Schedule heavy work just after the daily reset. HubSpot's daily reset is midnight in the account's configured time zone; Cloudflare cron triggers (which sync schedules compile to) run in UTC. Convert accordingly: for a US-Pacific account,
schedule: '0 13 * * *'runs at 06:00 PT, leaving the rest of the account's day on a fresh pool. - Buy the API Limit Increase, if the app is privately distributed. Each increase adds 250 requests per 10 seconds per app and 1,000,000 per day to the installing account, up to two increases. It is the customer's purchase, on their HubSpot subscription, so it is a conversation with the account owner rather than a flag you set. It does not apply to marketplace-distributed OAuth apps: their 110 per 10 seconds per account is fixed, so for a listed app the first three levers are the whole toolbox.
What to do when you see a 429 anyway
Enable rateLimits.metrics when you want a per-call event stream. The runtime emits hubspot_ratelimit_remaining, hubspot_ratelimit_headroom_pct, hubspot_ratelimit_429_total, and hubspot_ratelimit_backpressure_total with portal, surface, endpoint family, status, request path, and retry-after fields where available. Without metrics, you still get the backpressure result itself — the retry-later / fail-continue shapes from §03, complete with retryAfterSeconds, recorded as checkpoint output on the invocation that got throttled — plus the aggregate view from the CLI:
$ hs-x checkpoint --project prj_acme_crm
Project prj_acme_crm
Window: 2026-06-09T14:00:00.000Z -> 2026-06-10T14:00:00.000Z
Invocations: 4212 (4209 success, 3 error)
Latency p50/p95/p99: 184ms / 512ms / 1240ms
Recent failures: 0
Sampled successes: 5
Sources: metrics=analytics-engine, exemplars=d1A throttled run shows up in that panel as a success whose output reads HubSpot rate limit budget exhausted; retry after Ns. — scan the sampled outputs for that message before assuming a real failure. Three things to check, in order, before assuming the engine is misbehaving:
- Is the 429 from
Retry-After: <small>orRetry-After: <hours>? Small means burst; the engine waited it out and the capability resumed. Hours means daily exhaustion — the runtime honors the header literally, so the portal stays parked for the full window within that isolate. Work the §04 math against your sync schedule to see what burned the pool. - Was the 429 on
/searchor a general endpoint? A/search429 with the main pool still healthy means you have a search-heavy workload. HS-X already paces Search at 4 requests per second (HubSpot's limit is 5) and backs off on every 429 — that's the only signal available, since Search responses carry no rate-limit headers — so the durable fix is indexing data into HubSpot properties so you canGETinstead of/search. - Did a 429 reach your own code at all? Everything routed through
ctx.hubspotsurfaces budget exhaustion as a backpressure result, not an exception. If your handler caught a raw 429, that traffic bypassed the budget — usually a plainfetchtoapi.hubapi.comwith a hand-managed token.
If the engine is misbehaving — repeated 429s on traffic that all flows through ctx.hubspot, or a Retry-After that doesn't hold — file an issue with the output of hs-x checkpoint --project <id> --json. The checkpoint read is enough to see the failure pattern; no portal credentials are needed.