view .md
Reference · Platform

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.

Time
≈ 9 min read
Outcome
A working mental model of HubSpot's two-axis rate-limit system, what each response header means, and the four levers you can pull when you're close to a limit.

The 30-second answer

HubSpot enforces two limits on every API call: a burst limit (per-second) and a daily pool. Both are scoped to the access token, not the portal — so a private app and an OAuth app on the same portal don't share quota.

For a public OAuth app — which is what an HS-X project is — the practical ceilings are 100 requests per 10 seconds per portal and a daily pool that starts at 250,000 requests. Marketplace and enterprise tiers raise both; the full matrix is below. HS-X provisions its own client-side budget at that same 100-burst figure (refilling 10 per second per portal), so the engine's assumptions and HubSpot's floor 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.

If you only read one thing

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 UTC.

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; private apps inherit the portal tier, public/marketplace apps get their own.

TierBurst (per 10s)Daily pool
Free / Starter100250,000
Pro150500,000
Enterprise1901,000,000
Marketplace app (per install)150500,000
Marketplace · API Limit Increase2001,000,000

A few notes that matter more than the numbers themselves:

  • Search has its own bucket — and no headers. POST /crm/v3/objects/*/search counts against a separate, much tighter per-second limit (roughly four requests per second per portal, not a per-tier per-10s figure), and 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 UTC, not a rolling 24-hour window. A burst at 23:55 UTC followed by another at 00:05 UTC counts against two different days.
  • OAuth apps and private apps don't share. Each token has its own counters. If your sync uses a private app token and a workflow action uses an OAuth token, they have independent quotas.
  • The free tier of the Marketplace API Limit Increase is one credit per developer account. Beyond that it's a paid add-on; HubSpot rates the increase on a per-app basis.

What the response headers tell you

Most 2xx and 429 responses from a HubSpot API call carry the same six headers — 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 Daily-Remaining only appears on private-app / static-token auth, never OAuth. Read the table once and you can debug any quota problem without leaving your terminal.

HeaderMeaning
X-HubSpot-RateLimit-DailyYour tier's daily ceiling. Constant per token.
X-HubSpot-RateLimit-Daily-RemainingCalls left until midnight UTC. 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-MillisecondsLength of the burst window (normally 10000; HS-X throttles on whatever value actually arrives).
X-HubSpot-RateLimit-MaxYour tier's burst ceiling for this window.
X-HubSpot-RateLimit-RemainingCalls left in this 10-second window.
X-HubSpot-RateLimit-Secondly-RemainingCalls left in this one-second sub-window. Deprecated but still sent and still accurate; HS-X reads it as a supplemental ceiling, not a primary signal.

When you get a 429 Too Many Requests, HubSpot also returns:

HeaderMeaning
Retry-AfterSeconds to wait before retrying. Integer, usually 1–10 for burst exhaustion, hours for 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: 500000
x-hubspot-ratelimit-daily-remaining: 487211
x-hubspot-ratelimit-interval-milliseconds: 10000
x-hubspot-ratelimit-max: 150
x-hubspot-ratelimit-remaining: 148

The 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 tier — and every ctx.hubspot call 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-Remaining clamps 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. A Daily-Remaining of 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-After honoring. A 429 empties the bucket, halves its refill rate, and — if HubSpot sent Retry-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-long Retry-After parks the portal for hours.
  • A separate, conservative Search bucket. Any path with a /search segment draws from its own per-portal bucket, fixed at roughly 4 requests per second regardless of tier. 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.
  • Opt-in batched reads. ctx.hubspot.batch.objects.read(...) — and the matching properties and associations surfaces — coalesce lookups into /batch/read calls of up to 100 records, sharing the same auth and rate-limit accounting as everything else. Writes are not batched for you: what your handler issues is what HubSpot sees, one request per call.

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:

SurfaceWhat you get back
Workflow action (inline), agent toolfail-continue with retryAfterSeconds in output
Card backend, sync, batched workflow action, streaming backend, webhook triggerretry-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. 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 }.

What you'd have to do to hit a 429

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.batch issues ceil(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 (100 records). At 1M records that's 10,000 search calls/day — and the search bucket is much tighter, so it's the search bucket, not the main pool, that sets your floor: at ~4 searches per second, those calls take at least 42 minutes of pure search time.

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.

  1. Move from individual to batch. Reads coalesce with ctx.hubspot.batch.objects.read(...) — one /batch/read call covers 100 records. For writes, drive HubSpot's /batch/* endpoints from your handler; HubSpot counts 100 records as one request. Either way the swap cuts request count by ~100×. Cheapest and almost always available.
  2. Drop the frequency. A 5m sync that mostly returns "no new rows" is mostly waste. Switch to a webhook-driven hook if your source supports it, or back off to 15m. Most syncs don't need the freshness their schedule implies.
  3. Stretch the daily window with regional pinning. HubSpot's daily reset is UTC; if your team works US-Pacific, a heavy sync scheduled for 06:00 PT (= 13:00 UTC) leaves the rest of your day on a fresh pool. Set schedule: '0 13 * * *' (a cron expression; sync schedules run in UTC) to pin.
  4. Request a Marketplace API Limit Increase. Available to listed marketplace apps and to enterprise portals on request. HubSpot reviews per-app and grants in 2× / 4× tiers. Submit via developers.hubspot.com/marketplace/listings/api-limits. Approval typically takes 5–10 business days; you'll need a description of the workload and a sample sync configuration.

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 logs --project prj_acme_crm
Project prj_acme_crm
Window: 2026-06-09T14:00:00Z -> 2026-06-10T14:00:00Z
Invocations: 4212 (4209 success, 3 error)
Latency p50/p95/p99: 184ms / 512ms / 1240ms
Recent failures: 0
Sampled successes: 5
Sources: metrics=analytics-engine, exemplars=d1

A 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> or Retry-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. Work the §04 math against your sync schedule to see what burned the pool.
  • Was the 429 on /search or a general endpoint? A /search 429 with the main pool still healthy means you have a search-heavy workload. HS-X already paces Search at ~4 requests per second 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 can GET instead of /search.
  • Did a 429 reach your own code at all? Everything routed through ctx.hubspot surfaces budget exhaustion as a backpressure result, not an exception. If your handler caught a raw 429, that traffic bypassed the budget — usually a plain fetch to api.hubapi.com with 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.