HubSpot API Rate Limiting: The 19/s Mistake
/ 12 min read
Table of Contents
I was reviewing a client’s consent audit script last month — a Python job that needed to check subscription preferences for 400,000+ contacts. The developer had implemented rate limiting at 19 requests per second.
“HubSpot allows 190 requests per 10 seconds,” they explained. “So 190 ÷ 10 = 19 per second. Simple maths.”
The script was hitting 429 errors constantly. The developer was genuinely baffled. They’d done the maths. The maths was correct. And yet, somehow, reality remained stubbornly unimpressed by their arithmetic.
Here’s what they got wrong — and what most HubSpot integrations get wrong about rate limiting.
The Misconception: Dividing by 10
HubSpot’s rate limit for private apps on Pro/Enterprise accounts is 190 requests per 10 seconds. The intuitive approach is to divide:
190 requests ÷ 10 seconds = 19 requests/secondThen implement a simple per-second throttle:
import time
def make_request(): # Naive implementation - DON'T DO THIS time.sleep(1/19) # ~52ms between requests return api_call()This seems mathematically sound. If you never exceed 19 requests in any given second, you should never exceed 190 in any 10-second window. Right?
Wrong.
I know. I had the same expression on my face when I first discovered this. It’s the sort of thing that makes you question everything you thought you knew about mathematics, only to realise the problem is rather more nuanced than simple division.
How HubSpot’s Sliding Window Actually Works
HubSpot uses a rolling 10-second window, not fixed 10-second buckets. This is a critical distinction — the kind of distinction that separates integration engineers who sleep soundly from those who get paged at 3 AM.
Fixed Window (Not How HubSpot Works)
With fixed windows, time divides into discrete buckets:
- 00:00 - 00:10 → up to 190 requests
- 00:10 - 00:20 → up to 190 requests
- And so on…
If this were the model, you could burst 190 requests at 00:09 , then another 190 at 00:11 , getting 380 requests in 2 seconds.
Rolling Window (How HubSpot Actually Works)
With a rolling window, HubSpot counts requests in the last 10 seconds from right now. At any moment, the system asks: “How many requests has this app made in the past 10 seconds?”
This means:
- At 00:15 , it counts requests from 00:05 to 00:15
- At 00:16 , it counts requests from 00:06 to 00:16
- The window slides continuously
Why 19/s Fails
Here’s the problem with the 19/s approach. Imagine this sequence:
| Time | Requests This Second | Rolling 10s Total |
|---|---|---|
| 00:01 | 19 | 19 |
| 00:02 | 19 | 38 |
| 00:03 | 19 | 57 |
| 00:04 | 19 | 76 |
| 00:05 | 19 | 95 |
| 00:06 | 19 | 114 |
| 00:07 | 19 | 133 |
| 00:08 | 19 | 152 |
| 00:09 | 19 | 171 |
| 00:10 | 19 | 190 ✓ |
| 00:11 | 19 | 190 ✓ (lost 00:01 ’s 19, gained 19) |
So far so good. But what happens if there’s any variation — network latency causes a retry, a batch completes faster than expected, or another process shares the same API credentials?
| 00:10 | 22 (spike) | 193 | ❌ 429 ERRORA single spike of 3 extra requests triggers rate limiting. In production, with async operations, retries, and multiple services hitting the same HubSpot account, this happens constantly.
Current HubSpot Rate Limits (December 2025)
Before diving into solutions, here are the current limits. HubSpot increased these in 2023, so if you’re working from documentation you found on Stack Overflow from 2019… my condolences.
Private Apps
| Subscription | Burst Limit | Daily Limit |
|---|---|---|
| Starter/Free | 190/10s | 250,000 |
| Professional | 190/10s | 500,000 |
| Enterprise | 190/10s | 1,000,000 |
With API Limit Increase Add-on
| Pack | Burst Limit | Daily Limit |
|---|---|---|
| 1x Capacity Pack | 250/10s | +1,000,000 |
| 2x Capacity Pack | 250/10s | +2,000,000 |
Note: Burst limit increase doesn’t stack — two packs still give you 250/10s, not 500/10s.
Public Apps (OAuth)
| Type | Burst Limit |
|---|---|
| OAuth Apps | 110/10s per account |
Public apps have lower limits, and the API add-on doesn’t apply. Each HubSpot account that installs your app gets its own 110/10s allocation.
Special Cases
Search API: Limited to 5 requests per second (not per 10 seconds). This is frequently overlooked and causes issues in data sync operations. The Search API also caps results at 10,000 records per query.
Associations API: Currently operating at legacy limits due to an ongoing issue. Check the developer changelog for updates.
Batch Endpoints: Count as a single API call regardless of records processed. A batch of 100 contacts = 1 request.
The Right Way: Token Bucket Implementation
The most reliable approach is a token bucket (also called “leaky bucket”) algorithm. It’s one of those computer science concepts that sounds more intimidating than it actually is — like “polymorphism” or “eventual consistency.” Here’s how it works:
- You have a bucket that holds up to 190 tokens
- Tokens replenish at a rate of 19 per second
- Each API call consumes one token
- If no tokens are available, wait until one replenishes
This naturally handles bursts while respecting the rolling window.
Python Implementation
import timeimport threadingfrom collections import deque
class HubSpotRateLimiter: """ Sliding window rate limiter for HubSpot API.
Uses a token bucket approach that respects HubSpot's rolling 10-second window. """
def __init__(self, max_requests: int = 190, window_seconds: int = 10): self.max_requests = max_requests self.window_seconds = window_seconds self.requests = deque() # Timestamps of recent requests self.lock = threading.Lock()
def acquire(self) -> None: """ Block until a request slot is available. Call this before every HubSpot API request. """ while True: with self.lock: now = time.time() window_start = now - self.window_seconds
# Remove timestamps outside the window while self.requests and self.requests[0] < window_start: self.requests.popleft()
# Check if we have capacity if len(self.requests) < self.max_requests: self.requests.append(now) return
# Calculate wait time until oldest request exits window wait_time = self.requests[0] - window_start
# Wait outside the lock time.sleep(wait_time + 0.01) # Small buffer
def get_remaining(self) -> int: """Return number of requests available in current window.""" with self.lock: now = time.time() window_start = now - self.window_seconds
while self.requests and self.requests[0] < window_start: self.requests.popleft()
return self.max_requests - len(self.requests)
# Usagerate_limiter = HubSpotRateLimiter(max_requests=190, window_seconds=10)
def get_contact(contact_id: str) -> dict: rate_limiter.acquire() # Blocks until slot available return hubspot_client.crm.contacts.basic_api.get_by_id(contact_id)Async Python Implementation
For high-throughput scripts using asyncio:
import asyncioimport timefrom collections import deque
class AsyncHubSpotRateLimiter: """Async-compatible sliding window rate limiter."""
def __init__(self, max_requests: int = 190, window_seconds: int = 10): self.max_requests = max_requests self.window_seconds = window_seconds self.requests = deque() self.lock = asyncio.Lock()
async def acquire(self) -> None: """Async version - awaits until slot available.""" while True: async with self.lock: now = time.time() window_start = now - self.window_seconds
# Clean old timestamps while self.requests and self.requests[0] < window_start: self.requests.popleft()
if len(self.requests) < self.max_requests: self.requests.append(now) return
wait_time = self.requests[0] - window_start
await asyncio.sleep(wait_time + 0.01)
# Usage with aiohttp or httpxlimiter = AsyncHubSpotRateLimiter()
async def fetch_contacts(contact_ids: list[str]) -> list[dict]: async def fetch_one(contact_id: str) -> dict: await limiter.acquire() async with httpx.AsyncClient() as client: response = await client.get( f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}", headers={"Authorization": f"Bearer {access_token}"} ) return response.json()
return await asyncio.gather(*[fetch_one(cid) for cid in contact_ids])JavaScript/Node.js Implementation
class HubSpotRateLimiter { constructor(maxRequests = 190, windowSeconds = 10) { this.maxRequests = maxRequests; this.windowMs = windowSeconds * 1000; this.requests = []; }
async acquire() { while (true) { const now = Date.now(); const windowStart = now - this.windowMs;
// Remove old timestamps this.requests = this.requests.filter(ts => ts > windowStart);
if (this.requests.length < this.maxRequests) { this.requests.push(now); return; }
// Wait for oldest request to exit window const waitTime = this.requests[0] - windowStart + 10; await new Promise(resolve => setTimeout(resolve, waitTime)); } }}
// Usageconst limiter = new HubSpotRateLimiter();
async function getContact(contactId) { await limiter.acquire(); const response = await hubspotClient.crm.contacts.basicApi.getById(contactId); return response;}Read the Response Headers
HubSpot tells you exactly where you stand with every response. Use this information.
| Header | Description |
|---|---|
X-HubSpot-RateLimit-Daily | Your daily limit |
X-HubSpot-RateLimit-Daily-Remaining | Requests left today |
X-HubSpot-RateLimit-Interval-Remaining | Requests left in current 10s window |
Note: The X-HubSpot-RateLimit-Secondly headers are deprecated. They still appear but the per-second limit is no longer enforced — only the 10-second rolling window matters.
Adaptive Rate Limiting
Use the headers to adjust your pace dynamically:
def make_request_adaptive(url: str, rate_limiter: HubSpotRateLimiter) -> dict: rate_limiter.acquire() response = requests.get(url, headers=auth_headers)
# Check remaining capacity remaining = int(response.headers.get('X-HubSpot-RateLimit-Interval-Remaining', 0))
if remaining < 20: # Getting close to limit - slow down time.sleep(0.5)
if response.status_code == 429: # Hit the limit - back off retry_after = int(response.headers.get('Retry-After', 10)) time.sleep(retry_after) return make_request_adaptive(url, rate_limiter) # Retry
return response.json()Reduce Call Volume with Batch APIs
The best rate limit strategy is making fewer calls. HubSpot’s batch endpoints are massively underutilised — which is a polite way of saying most developers I’ve worked with didn’t know they existed until I mentioned them.
Batch Read (up to 100 records)
Instead of:
# BAD: 100 API callsfor contact_id in contact_ids[:100]: contact = client.crm.contacts.basic_api.get_by_id(contact_id)Do this:
# GOOD: 1 API callfrom hubspot.crm.contacts import BatchReadInputSimplePublicObjectId
batch_input = BatchReadInputSimplePublicObjectId( inputs=[{"id": cid} for cid in contact_ids[:100]], properties=["email", "firstname", "lastname"])results = client.crm.contacts.batch_api.read(batch_input)Batch Create/Update (up to 100 records)
from hubspot.crm.contacts import BatchInputSimplePublicObjectInputForCreate
batch_input = BatchInputSimplePublicObjectInputForCreate( inputs=[ # ... up to 100 ])results = client.crm.contacts.batch_api.create(batch_input)Search API (up to 200 records per call)
The Search API returns up to 200 records per request and is perfect for filtered data retrieval:
# Get all contacts updated in last 24 hoursfrom datetime import datetime, timedelta
yesterday = int((datetime.now() - timedelta(days=1)).timestamp() * 1000)
search_request = { "filterGroups": [{ "filters": [{ "propertyName": "lastmodifieddate", "operator": "GTE", "value": str(yesterday) }] }], "limit": 200, "properties": ["email", "firstname", "lastname"]}
results = client.crm.contacts.search_api.do_search(search_request)Important: The Search API has its own limit of 5 requests per second. Factor this into your rate limiter or use a separate limiter for search operations.
Handling 429 Errors Gracefully
When you do hit a 429, handle it properly. Panicking is not a valid error handling strategy, though I’ve certainly seen it attempted:
import timefrom functools import wraps
def retry_on_rate_limit(max_retries: int = 3): """Decorator to handle HubSpot 429 errors with exponential backoff."""
def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except ApiException as e: if e.status == 429: # Get retry-after from headers, default to exponential backoff retry_after = int(e.headers.get('Retry-After', 2 ** attempt)) print(f"Rate limited. Waiting {retry_after}s (attempt {attempt + 1})") time.sleep(retry_after) else: raise raise Exception(f"Max retries ({max_retries}) exceeded") return wrapper return decorator
@retry_on_rate_limit(max_retries=5)def get_contact_safe(contact_id: str) -> dict: rate_limiter.acquire() return client.crm.contacts.basic_api.get_by_id(contact_id)Multiple Apps Sharing the Same Account
Here’s a gotcha that catches many teams: rate limits are per-app, per-account. I’ve seen this revelation cause actual, visible distress in project meetings.
If you have:
- A marketing automation tool (private app)
- A custom integration (private app)
- A data sync service (private app)
Each gets its own 190/10s limit. But if multiple services use the same private app credentials, they share the limit.
Solution: Centralised Rate Limiter
For microservices sharing credentials, use a distributed rate limiter:
import redis
class DistributedRateLimiter: """Redis-backed rate limiter for multi-service environments."""
def __init__(self, redis_client, key: str, max_requests: int = 190, window: int = 10): self.redis = redis_client self.key = f"hubspot_ratelimit:{key}" self.max_requests = max_requests self.window = window
def acquire(self) -> bool: """ Attempt to acquire a slot. Returns True if successful. Uses Redis sorted sets for distributed sliding window. """ now = time.time() window_start = now - self.window
pipe = self.redis.pipeline()
# Remove old entries pipe.zremrangebyscore(self.key, 0, window_start)
# Count current entries pipe.zcard(self.key)
# Add new entry (optimistically) pipe.zadd(self.key, {str(now): now})
# Set expiry on the key pipe.expire(self.key, self.window + 1)
results = pipe.execute() current_count = results[1]
if current_count >= self.max_requests: # Remove the entry we just added self.redis.zrem(self.key, str(now)) return False
return True
def acquire_blocking(self, timeout: float = 30) -> bool: """Block until slot available or timeout.""" start = time.time() while time.time() - start < timeout: if self.acquire(): return True time.sleep(0.1) return FalsePractical Limits for Common Operations
Based on real-world implementations, here’s what you can realistically achieve:
| Operation | Theoretical Max | Practical Safe Rate |
|---|---|---|
| Single record reads | 190/10s | 150/10s |
| Batch reads (100 records) | 19,000 records/10s | 15,000 records/10s |
| Search queries | 5/s | 4/s |
| Single record creates | 190/10s | 150/10s |
| Batch creates (100 records) | 19,000 records/10s | 15,000 records/10s |
The “practical safe rate” accounts for:
- Network latency variations
- Shared app credentials
- Retry overhead
- Other services using the same account
The Consent Audit Script — Fixed
Remember that 400k contact audit I mentioned? Here’s what the fixed version looked like:
import asyncioimport httpxfrom collections import dequeimport time
class AsyncHubSpotRateLimiter: def __init__(self, max_requests: int = 170, window_seconds: int = 10): # Using 170 instead of 190 for safety margin self.max_requests = max_requests self.window_seconds = window_seconds self.requests = deque() self.lock = asyncio.Lock()
async def acquire(self): while True: async with self.lock: now = time.time() window_start = now - self.window_seconds
while self.requests and self.requests[0] < window_start: self.requests.popleft()
if len(self.requests) < self.max_requests: self.requests.append(now) return
wait_time = self.requests[0] - window_start
await asyncio.sleep(wait_time + 0.01)
async def audit_consent_preferences(contact_ids: list[str]) -> dict: """ Audit subscription preferences for all contacts.
With 400,000 contacts at ~17 requests/second, this completes in approximately 6.5 hours. """ limiter = AsyncHubSpotRateLimiter(max_requests=170) results = {"subscribed": 0, "unsubscribed": 0, "errors": 0}
async with httpx.AsyncClient() as client:
async def check_one(contact_id: str) -> str: await limiter.acquire() try: response = await client.get( f"https://api.hubapi.com/communication-preferences/v3/status/email/{contact_id}", headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, timeout=30.0 ) if response.status_code == 200: data = response.json() # Check marketing subscription (ID varies by portal) for sub in data.get("subscriptionStatuses", []): if sub["id"] == MARKETING_SUBSCRIPTION_ID: return "subscribed" if sub["status"] == "SUBSCRIBED" else "unsubscribed" return "unknown" except Exception as e: print(f"Error for {contact_id}: {e}") return "error"
# Process in chunks to avoid memory issues chunk_size = 1000 for i in range(0, len(contact_ids), chunk_size): chunk = contact_ids[i:i + chunk_size] chunk_results = await asyncio.gather(*[check_one(cid) for cid in chunk])
for result in chunk_results: if result in results: results[result] += 1 else: results["errors"] += 1
# Progress update processed = min(i + chunk_size, len(contact_ids)) print(f"Processed {processed:,}/{len(contact_ids):,} contacts")
return results
# Run the auditif __name__ == "__main__": # Load contact IDs from your source contact_ids = load_contact_ids() # Your implementation
results = asyncio.run(audit_consent_preferences(contact_ids)) print(f"Audit complete: {results}")The script went from failing every few minutes to running for 6+ hours without a single 429 error. The developer was so pleased they almost smiled. (I’m exaggerating. They definitely smiled.)
Key Takeaways
- 190/10s ≠ 19/s — HubSpot uses a rolling window, not fixed buckets
- Implement a proper sliding window — Token bucket algorithm handles bursts correctly
- Read the headers —
X-HubSpot-RateLimit-Interval-Remainingtells you exactly where you stand - Use batch APIs — 100 records per call dramatically reduces your request count
- Search API has separate limits — 5/second, not 190/10s
- Build in safety margin — Target 170/10s instead of 190/10s for production reliability
- Centralise rate limiting — Multiple services sharing credentials need coordination
If you’re building HubSpot integrations and hitting mysterious 429 errors, the problem is almost certainly your rate limiter implementation, not HubSpot’s limits. Though I understand the temptation to blame the API — we’ve all been there.
Working on a high-volume HubSpot integration? I’d be interested to hear what patterns you’ve found effective — find me on LinkedIn.