Rate Limiting
The API enforces rate limits to ensure fair usage and system stability.
Limits
| Tier | Burst (per minute) | Sustained (per hour) |
|---|---|---|
| Free tier ($2.50 credits) | 60 requests | 600 requests |
| Standard | 100 requests | 1,000 requests |
| Enterprise | Custom | Custom |
Rate limits apply per API key. Different keys for the same organization have independent limits.
Response Headers
Every API response includes rate limit headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1711036800
Retry-After: 30| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait before retrying (only on 429) |
429 Response
When you exceed the rate limit:
{
"error": {
"type": "rate_limit_error",
"message": "Too many requests. Please retry after 30 seconds.",
"request_id": "req_01HV..."
}
}Exponential Backoff
When you receive a 429, use exponential backoff with jitter:
async function fetchWithBackoff(url: string, options: RequestInit, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') ?? '1', 10);
const delay = retryAfter * 1000 + Math.random() * 1000; // Add jitter
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}# cURL: check rate limit headers
curl -i https://api.firsthandapi.com/v1/jobs \
-H "Authorization: Bearer fh_live_..." \
2>&1 | grep -i "ratelimit\|retry"Best Practices
- Check
X-RateLimit-Remainingbefore making requests in tight loops - Always honor
Retry-After— don’t retry sooner than the server says - Add jitter to backoff delays to avoid thundering herd on recovery
- Cache responses when possible —
GET /v1/jobs/:idresults rarely change between polls - Use webhooks instead of polling —
job.completedfires when all files are collected, eliminating the need to poll - Use long-polling —
GET /v1/jobs/:idwithPrefer: wait=30blocks until state changes, reducing request count