Rate Limits & Caching¶
FirmaDB enforces rate limits per API key, sliding-window, measured per minute. Every response — successful or not — carries the headers you need to pace yourself. This page is the reference an agent should ground on before issuing concurrent requests.
Rate limits by tier¶
| Tier | Lookup/min | Search/min | Cross-country search/min | Batch rows/request | Concurrent batch |
|---|---|---|---|---|---|
| Free | 60 | 10 | 3 | 100 | 1 |
| Solo | 120 | 30 | 10 | 100 | 2 |
| Team | 300 | 60 | 20 | 100 | 3 |
| Scale | 600 | 120 | 40 | 100 | 5 |
Cross-country search costs 10× rate-limit units
A search call without a country parameter scans all 19 partitions and consumes 10 units against your search budget instead of 1. Always pass country when you know it. Cross-country is rate-limited separately and is much smaller — the table above lists both buckets.
Headers on every response¶
Rate-limit state is communicated via lowercase HTTP headers on every response, not only on 429s.
ratelimit-limit: 120
ratelimit-remaining: 117
ratelimit-reset: 42
ratelimit-policy: "lookup-minute";q=120;w=60
x-ratelimit-resource: companies-read
x-firmadb-cost-units: 1
x-firmadb-credits-remaining: 8766
x-request-id: req_01HZAB7MJX9V8GG6P3K5R2QC4P
| Header | Meaning |
|---|---|
ratelimit-limit |
Per-minute cap for the resource you just hit |
ratelimit-remaining |
Requests left in the current window |
ratelimit-reset |
Seconds until the window resets |
ratelimit-policy |
Window definition in IETF format |
x-ratelimit-resource |
Which bucket was consumed (see below) |
x-firmadb-cost-units |
Units charged by this request (1 normally, 10 for cross-country search) |
x-firmadb-credits-remaining |
Billable results left in the current month |
x-request-id |
ULID for support tickets and tracing |
Resource buckets¶
The bucket charged is reported in x-ratelimit-resource:
| Resource | Triggered by |
|---|---|
companies-read |
Single lookup |
companies-search |
Country-scoped search |
companies-search-global |
Cross-country search (10 units) |
companies-enrich |
Batch lookup |
coverage-read |
Countries endpoints |
account-read |
Usage endpoint |
Reading the headers¶
import httpx, os
resp = httpx.get(
"https://api.firmadb.com/v1/companies/FR/552120222",
headers={"Authorization": f"Bearer {os.environ['FIRMADB_API_KEY']}"},
)
print(f"{resp.headers['ratelimit-remaining']} of {resp.headers['ratelimit-limit']} left")
print(f"window resets in {resp.headers['ratelimit-reset']}s")
Handling 429¶
There are two distinct 429 conditions — you handle them differently.
429 rate_limit_exceeded¶
You burst too fast. The window will reset shortly.
{
"type": "https://errors.firmadb.com/rate-limit-exceeded",
"status": 429,
"code": "rate_limit_exceeded",
"retryable": true,
"retry_after_seconds": 28,
"limit": {
"resource": "companies-read",
"limit": 120,
"remaining": 0,
"reset_seconds": 28
}
}
Always honour Retry-After (seconds). Do not retry immediately — you'll just stack more 429s.
def call_with_backoff(client, request_kwargs):
while True:
r = client.request(**request_kwargs)
if r.status_code != 429:
return r
body = r.json()
if body["code"] != "rate_limit_exceeded":
return r # quota_exhausted needs different handling
time.sleep(int(r.headers.get("Retry-After", 30)))
429 quota_exhausted¶
You've used your monthly billable results. The window will not reset for hours/days. Sleeping won't help.
{
"type": "https://errors.firmadb.com/quota-exhausted",
"status": 429,
"code": "quota_exhausted",
"retryable": false,
"limit": {
"bucket": "monthly_results",
"scope": "account",
"limit": 1000,
"remaining": 0,
"reset_iso": "2026-06-01T00:00:00Z"
},
"price": {
"next_tier": "solo",
"monthly_fee_eur": 29,
"included_results": 10000,
"upgrade_url": "https://firmadb.com/upgrade"
}
}
Distinguish the two via code, not the HTTP status alone. rate_limit_exceeded is transient; quota_exhausted requires either waiting until next billing period or upgrading.
Caching rules¶
Once you've fetched a company record, you may cache it locally for the duration of your tier's cache window:
| Tier | Cache window |
|---|---|
| Free | 24 hours |
| Solo | 7 days |
| Team | 30 days |
| Scale | 90 days |
After the window expires, re-fetch. The data_freshness.last_confirmed_at field tells you when FirmaDB last reconciled the record with the source registry — independent of your local cache window.
ETag + If-None-Match (free conditional requests)¶
Every company response carries an ETag. Send it back with If-None-Match and you'll get a 304 Not Modified if the record hasn't changed — at zero billable cost.
Idempotency¶
The batch endpoint requires an idempotency_key (any UUID). Rules:
- Same key + same body within 24h → cached response, no re-billing.
- Same key + different body →
409 idempotency_conflictwithdiffering_fields[]. - Concurrent requests with the same key → serialized; one runs, the others wait for its result.
- After 24h → key expires and may be reused.
Generate a fresh UUID per logical batch operation. Reusing the same UUID across retries of the same logical batch is the entire point — that's what makes network errors safe.
Best-practice checklist¶
- Always pass
countryon search (10× cost without it). - Read
ratelimit-remainingand slow down preemptively, don't wait for 429. - On 429, branch on
code, then either sleepRetry-Afteror upgrade. - Use
ETag+If-None-Matchwhenever you re-fetch within your cache window. - For batches, generate the
idempotency_keyonce per logical job and reuse it on every retry attempt. - Keep concurrent batch requests under your tier's
Concurrent batchcap.
What's next¶
- Estimate spend before running a job → Cost Estimation
- Connect via MCP → MCP Setup
- Full error catalog → Errors