skip to content
san.is
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/second

Then 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:

TimeRequests This SecondRolling 10s Total
00:01 1919
00:02 1938
00:03 1957
00:04 1976
00:05 1995
00:06 19114
00:07 19133
00:08 19152
00:09 19171
00:10 19190 ✓
00:11 19190 ✓ (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 ERROR

A 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

SubscriptionBurst LimitDaily Limit
Starter/Free190/10s250,000
Professional190/10s500,000
Enterprise190/10s1,000,000

With API Limit Increase Add-on

PackBurst LimitDaily Limit
1x Capacity Pack250/10s+1,000,000
2x Capacity Pack250/10s+2,000,000

Note: Burst limit increase doesn’t stack — two packs still give you 250/10s, not 500/10s.

Public Apps (OAuth)

TypeBurst Limit
OAuth Apps110/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:

  1. You have a bucket that holds up to 190 tokens
  2. Tokens replenish at a rate of 19 per second
  3. Each API call consumes one token
  4. If no tokens are available, wait until one replenishes

This naturally handles bursts while respecting the rolling window.

Python Implementation

import time
import threading
from 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)
# Usage
rate_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 asyncio
import time
from 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 httpx
limiter = 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));
}
}
}
// Usage
const 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.

HeaderDescription
X-HubSpot-RateLimit-DailyYour daily limit
X-HubSpot-RateLimit-Daily-RemainingRequests left today
X-HubSpot-RateLimit-Interval-RemainingRequests 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 calls
for contact_id in contact_ids[:100]:
contact = client.crm.contacts.basic_api.get_by_id(contact_id)

Do this:

# GOOD: 1 API call
from 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=[
{"properties": {"email": "[email protected]", "firstname": "Alice"}},
{"properties": {"email": "[email protected]", "firstname": "Bob"}},
# ... 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 hours
from 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 time
from 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 False

Practical Limits for Common Operations

Based on real-world implementations, here’s what you can realistically achieve:

OperationTheoretical MaxPractical Safe Rate
Single record reads190/10s150/10s
Batch reads (100 records)19,000 records/10s15,000 records/10s
Search queries5/s4/s
Single record creates190/10s150/10s
Batch creates (100 records)19,000 records/10s15,000 records/10s

The “practical safe rate” accounts for:

  • Network latency variations
  • Shared app credentials
  • Retry overhead
  • Other services using the same account

Remember that 400k contact audit I mentioned? Here’s what the fixed version looked like:

import asyncio
import httpx
from collections import deque
import 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 audit
if __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

  1. 190/10s ≠ 19/s — HubSpot uses a rolling window, not fixed buckets
  2. Implement a proper sliding window — Token bucket algorithm handles bursts correctly
  3. Read the headersX-HubSpot-RateLimit-Interval-Remaining tells you exactly where you stand
  4. Use batch APIs — 100 records per call dramatically reduces your request count
  5. Search API has separate limits — 5/second, not 190/10s
  6. Build in safety margin — Target 170/10s instead of 190/10s for production reliability
  7. 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.