Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Survive HubSpot API rate limits at production scale. Covers daily 500K portal quota, per-10s burst limits, batch API efficiency (100x), token bucket pattern, queue-based worker architecture, and Retry-After header parsing. Use when a sync job burns the daily quota before 8am, when a parallelized batch job retry-storms on 429s, when single-record reads waste 99% of available throughput, or when instrumenting a rate-limit dashboard for an Ops Hub Enterprise portal. Trigger with "hubspot rate limit
.claude/skills/jeremylongshore-hubspot-rate-limit-survival/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 72% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 28% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 129% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 111% | 0% |
Rate-limit your HubSpot integration so it survives production volume without burning the portal's daily quota before lunch. This skill covers the six failure modes that take down integrations at scale and gives you the code to prevent each one.
Key invariant: HubSpot rate limits are portal-scoped, not app-scoped. Every private app and OAuth app in the same portal shares the same daily and per-10s buckets. There is no per-app isolation.
The six production failures this skill prevents:
GET /contacts/{id} costs 1 quota unit and returns 1 record. POST /contacts/batch/read with 100 IDs costs 1 unit and returns 100 records. Single-record reads waste 99% of available throughput.Retry-After: N. Backing off by 1s when N=30 produces 29 consecutive failures. Backing off by 30s when N=1 adds unnecessary latency. Always parse and honor the header exactly.@hubspot/api-client (npm) or hubspot (pip) for SDK-based integrationsbullmq (npm) or celery+redis-py (Python)Auth: Every API call requires Authorization: Bearer {token}. For token acquisition and caching, see hubspot-auth skill. This skill assumes a valid token is already available.
Build in this order. Steps 1–3 are mandatory. Steps 4–6 apply when volume exceeds ~50,000 calls/day or when multiple apps share the portal.
Every HubSpot response carries both bucket states. Never fly blind.
typescriptinterface RateLimitState { dailyLimit: number; dailyRemaining: number; windowMs: number; windowMax: number; windowRemaining: number; } let rl: RateLimitState = { dailyLimit: 500_000, dailyRemaining: 500_000, windowMs: 10_000, windowMax: 100, windowRemaining: 100, }; function updateRateLimitState(headers: Headers): void { if (headers.get("X-HubSpot-RateLimit-Daily")) rl.dailyLimit = parseInt(headers.get("X-HubSpot-RateLimit-Daily")!, 10); if (headers.get("X-HubSpot-RateLimit-Daily-Remaining")) rl.dailyRemaining = parseInt(headers.get("X-HubSpot-RateLimit-Daily-Remaining")!, 10); if (headers.get("X-HubSpot-RateLimit-Max")) rl.windowMax = parseInt(headers.get("X-HubSpot-RateLimit-Max")!, 10); if (headers.get("X-HubSpot-RateLimit-Remaining")) rl.windowRemaining = parseInt(headers.get("X-HubSpot-RateLimit-Remaining")!, 10); const pctUsed = 1 - rl.dailyRemaining / rl.dailyLimit; console.log(JSON.stringify({ event: "hubspot_rate_limit_state", daily_remaining: rl.dailyRemaining, daily_pct_used: parseFloat(pctUsed.toFixed(4)), window_remaining: rl.windowRemaining, })); }
A token bucket is the correct primitive for the per-10s burst limit. Callers block until a slot is available instead of firing and failing.
typescriptclass TokenBucket { private tokens: number; private lastRefillAt: number; constructor( private readonly capacity: number, private readonly refillRatePerMs: number, ) { this.tokens = capacity; this.lastRefillAt = Date.now(); } consume(count = 1): number { // returns ms to wait; 0 = immediate const now = Date.now(); this.tokens = Math.min( this.capacity, this.tokens + (now - this.lastRefillAt) * this.refillRatePerMs, ); this.lastRefillAt = now; if (this.tokens >= count) { this.tokens -= count; return 0; } return Math.ceil((count - this.tokens) / this.refillRatePerMs); } } // Private app, standard plan: 100 req/10s = 0.01 req/ms // Ops Hub Enterprise: 100 req/s = 0.1 req/ms const BUCKETS = { starter: new TokenBucket(100, 10 / 10_000), professional: new TokenBucket(150, 15 / 10_000), ops_hub_enterprise: new TokenBucket(1000, 100 / 10_000), }; type PlanTier = keyof typeof BUCKETS; let activeBucket: TokenBucket = BUCKETS.starter; export function configurePlanTier(tier: PlanTier): void { activeBucket = BUCKETS[tier]; } export async function acquireToken(): Promise<void> { const waitMs = activeBucket.consume(); if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); }
Honor the exact backoff value HubSpot specifies. Full implementation with mock-server test harness in implementation-guide.md.
typescriptfunction parseRetryAfterMs(headers: Headers): number | null { const raw = headers.get("Retry-After"); if (!raw) return null; const s = parseInt(raw, 10); return isNaN(s) ? null : s * 1_000; } export async function hubspotFetch( path: string, init: RequestInit = {}, maxAttempts = 5, ): Promise<Response> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { await acquireToken(); const res = await fetch(`https://api.hubapi.com${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN!}`, "Content-Type": "application/json", ...init.headers, }, }); updateRateLimitState(res.headers); if (res.ok) return res; const retryable = res.status === 429 || (res.status >= 500 && res.status < 600); if (!retryable || attempt === maxAttempts) throw new Error(`HubSpot ${res.status}: ${path}`); const delayMs = parseRetryAfterMs(res.headers) ?? Math.random() * Math.min(60_000, 500 * 2 ** attempt); await new Promise((r) => setTimeout(r, delayMs)); } throw new Error("unreachable"); }
Auto-chunk any array of IDs into groups of 100. Each chunk costs 1 quota unit.
typescriptconst BATCH_SIZE = 100; // HubSpot hard limit function chunk<T>(arr: T[], size = BATCH_SIZE): T[][] { const chunks: T[][] = []; for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size)); return chunks; } export async function batchRead( objectType: "contacts" | "companies" | "deals" | "tickets", ids: string[], properties: string[], ): Promise<{ results: unknown[]; errors: unknown[] }> { const results: unknown[] = []; const errors: unknown[] = []; for (const batch of chunk(ids)) { const res = await hubspotFetch(`/crm/v3/objects/${objectType}/batch/read`, { method: "POST", body: JSON.stringify({ inputs: batch.map((id) => ({ id })), properties }), }); const body = await res.json() as { results: unknown[]; errors?: unknown[] }; results.push(...body.results); if (body.errors) errors.push(...body.errors); } return { results, errors }; }
Halt lower-priority work before the portal goes dark.
typescripttype Priority = "critical" | "high" | "normal" | "low"; const SHUTOFF: Record<Priority, number> = { critical: 0.99, high: 0.95, normal: 0.90, low: 0.80, }; export function assertDailyQuotaAvailable(priority: Priority): void { const pctConsumed = 1 - rl.dailyRemaining / rl.dailyLimit; if (pctConsumed >= SHUTOFF[priority]) { throw new Error( `Daily quota shutoff: ${(pctConsumed * 100).toFixed(1)}% consumed — ` + `${priority} priority requests halted`, ); } }
Read X-HubSpot-RateLimit-Max from the first response instead of hard-coding the tier.
typescriptexport async function detectAndConfigurePlanTier(): Promise<PlanTier> { const res = await fetch( "https://api.hubapi.com/crm/v3/objects/contacts?limit=1", { headers: { Authorization: `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN!}` } }, ); const max = parseInt(res.headers.get("X-HubSpot-RateLimit-Max") ?? "100", 10); const tier: PlanTier = max >= 1000 ? "ops_hub_enterprise" : max >= 150 ? "professional" : "starter"; configurePlanTier(tier); console.log(`HubSpot plan tier: ${tier} (window max: ${max})`); return tier; }
| HTTP Status | Error Code | Root Cause | Action | |---|---|---|---| | 429 | RATE_LIMIT / policyName: SECONDLY | Per-10s burst window exhausted | Read Retry-After; wait exactly that many seconds | | 429 | RATE_LIMIT / policyName: DAILY | 500K/day quota exhausted | Stop all non-critical calls; resume after midnight UTC | | 400 | BATCH_SIZE_EXCEEDED | More than 100 IDs in batch payload | Chunk inputs to max 100; batchRead wrapper handles this | | 400 | INVALID_BATCH_REQUEST | Malformed batch payload | Verify inputs is [{id: string}]; properties is an array | | 403 | MISSING_SCOPES | Token lacks scope for the object type | Add required scope in HubSpot Settings → Private Apps | | 403 | PORTAL_NOT_ALLOWED | Ops Hub Enterprise feature on lower-tier plan | Reduce throughput target; verify plan tier via X-HubSpot-RateLimit-Max | | 5xx | INTERNAL_ERROR | HubSpot transient error | Retry with exponential backoff; typically resolves within 60s |
Diagnose daily vs burst 429:
bashcurl -sv "https://api.hubapi.com/crm/v3/objects/contacts?limit=1" \ -H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" 2>&1 \ | grep -E "< (X-HubSpot|Retry-After|HTTP)" # Retry-After 1-30s + Daily-Remaining > 0 → burst window # Daily-Remaining = 0 → daily quota
bashcurl -sI "https://api.hubapi.com/crm/v3/objects/contacts?limit=1" \ -H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" \ | grep -i "X-HubSpot-RateLimit\|Retry-After"
bashcurl -s "https://api.hubapi.com/crm/v3/objects/contacts/batch/read" \ -H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"inputs":[{"id":"1"},{"id":"2"},{"id":"3"}],"properties":["email","firstname"]}' \ | jq '{count: (.results | length), errors: (.errors | length)}'
pythonimport os, json, requests def check_quota() -> dict: r = requests.get( "https://api.hubapi.com/crm/v3/objects/contacts", headers={"Authorization": f"Bearer {os.environ['HUBSPOT_ACCESS_TOKEN']}"}, params={"limit": 1}, ) daily = int(r.headers.get("X-HubSpot-RateLimit-Daily", 500_000)) rem = int(r.headers.get("X-HubSpot-RateLimit-Daily-Remaining", daily)) pct = 1 - rem / daily return {"daily_remaining": rem, "pct_consumed": round(pct, 4), "shutoff_active": pct >= 0.90, "window_remaining": int(r.headers.get("X-HubSpot-RateLimit-Remaining", 0))} print(json.dumps(check_quota(), indent=2))
hubspotFetch wrapper parsing Retry-After on every 429 and backing off by the server-specified durationAPI_REFERENCE.md — all rate-limit headers, limit tiers by plan, batch endpoint signatures, 429 response shapesimplementation-guide.md — Python token bucket, Bull/Redis queue worker, daily quota dashboard metrics, Retry-After parser, batch chunker, mock-server test harness| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | fail→fail | 38,399 | 33,802 | -12% | 1 | 1 | 0% | 6,911 | 9,997 | +45% | 0 | 0 | — |
case-01 | fail→pass | 31,217 | 37,268 | +19% | 1 | 1 | 0% | 6,725 | 11,590 | +72% | 0 | 0 | — |
case-03 | fail→pass | 36,905 | 31,924 | -13% | 1 | 1 | 0% | 8,279 | 10,559 | +28% | 0 | 0 | — |
case-04 | pass→pass | 9,153 | 14,143 | +55% | 1 | 1 | 0% | 1,978 | 5,816 | +194% | 0 | 0 | — |
case-05 | pass→pass | 17,152 | 19,716 | +15% | 1 | 1 | 0% | 3,411 | 6,905 | +102% | 0 | 0 | — |
case-06 | pass→pass | 20,320 | 19,934 | -2% | 1 | 1 | 0% | 2,457 | 7,327 | +198% | 0 | 0 | — |
case-07 | fail→fail | 15,528 | 19,595 | +26% | 1 | 1 | 0% | 3,018 | 6,312 | +109% | 0 | 0 | — |
case-08 | fail→pass | 16,620 | 11,499 | -31% | 1 | 1 | 0% | 2,892 | 6,226 | +115% | 0 | 0 | — |
case-09 | pass→pass | 20,538 | 32,337 | +57% | 1 | 1 | 0% | 2,872 | 8,264 | +188% | 0 | 0 | — |
case-10 | fail→pass | 18,923 | 14,991 | -21% | 1 | 1 | 0% | 2,598 | 5,954 | +129% | 0 | 0 | — |
case-11 | pass→pass | 37,737 | 44,752 | +19% | 1 | 1 | 0% | 2,747 | 6,508 | +137% | 0 | 0 | — |
case-12 | fail→fail | 25,879 | 21,856 | -16% | 1 | 1 | 0% | 3,662 | 8,086 | +121% | 0 | 0 | — |
case-13 | fail→pass | 17,053 | 12,864 | -25% | 1 | 1 | 0% | 3,272 | 6,920 | +111% | 0 | 0 | — |
case-14 | fail→pass | 16,424 | 10,405 | -37% | 1 | 1 | 0% | 2,116 | 5,824 | +175% | 0 | 0 | — |
case-15 | pass→pass | 23,163 | 16,753 | -28% | 1 | 1 | 0% | 4,892 | 7,342 | +50% | 0 | 0 | — |
case-16 | pass→pass | 24,838 | 14,406 | -42% | 1 | 1 | 0% | 3,805 | 6,936 | +82% | 0 | 0 | — |
case-17 | fail→fail | 24,424 | 15,106 | -38% | 1 | 1 | 0% | 4,096 | 6,270 | +53% | 0 | 0 | — |
case-18 | pass→pass | 18,047 | 11,756 | -35% | 1 | 1 | 0% | 2,470 | 6,245 | +153% | 0 | 0 | — |
case-19 | fail→pass | 22,591 | 18,045 | -20% | 1 | 1 | 0% | 2,934 | 6,521 | +122% | 0 | 0 | — |
case-20 | fail→pass | 9,239 | 11,871 | +28% | 1 | 1 | 0% | 1,225 | 4,726 | +286% | 0 | 0 | — |
case-21 | pass→pass | 10,756 | 15,428 | +43% | 1 | 1 | 0% | 1,803 | 5,824 | +223% | 0 | 0 | — |
case-22 | fail→fail | 42,866 | 26,155 | -39% | 1 | 1 | 0% | 7,225 | 8,620 | +19% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +36 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.