Rate limits
Understand the rate limits applied to the BeeL. API and how to handle them.
The BeeL. API enforces rate limits to protect service stability. Limits are enforced at the network edge, on a fixed 60-second window. What the window is counted against depends on the tier: Standard counts per credential, while Strict and Global count per originating IP address.
Tiers
| Tier | Limit | Window | Applied to |
|---|---|---|---|
| Global | 1,000 requests | 60 seconds | Every request to the API |
| Standard | 300 requests | 60 seconds | /v1/accounts/imports, /v1/companies/{company_id}/customers, /v1/companies/{company_id}/invoices, /v1/customers, /v1/invoices, /v1/me, POST /v1/nif/validate — each including its sub-paths |
| Strict | 5 requests | 60 seconds | /v1/companies/{company_id}/logo, POST /v1/accounts/{account_id}/companies/{company_id}/representation/generate, POST /v1/accounts/{account_id}/companies/{company_id}/representation/submit, POST /v1/accounts/{account_id}/invitations, POST /v1/companies/{company_id}/invoices/deliveries, POST /v1/companies/{company_id}/invoices/{invoice_id}/send, POST /v1/companies/{company_id}/representation, POST /v1/invoices/bulk/send — each including its sub-paths |
A single request may count against more than one tier. A POST /api/v1/companies/{company_id}/invoices counts against both the Standard tier (300 requests a minute) and the Global tier (1,000 requests a minute).
These limits govern API requests. The number of emails you can actually send is a separate, lower quota with its own axes — see Sending email.
Client identification
The Standard tier is tracked per credential: per API key, or per client for OAuth2 tokens and dashboard sessions. Each key has its own independent Standard quota, so splitting an integration across several keys (for example one per worker) splits that quota accordingly.
The Strict and Global tiers are tracked per originating IP address, whatever credential you present. Several keys calling from the same host share those two budgets, and adding keys does not raise them.
Strict and Global are counted before your credential is checked, so they cannot be keyed by it: a caller could send a different invented Authorization: Bearer on each request and get a fresh budget every time. That is why the two tiers that exist to stop abuse are keyed by the one thing a caller cannot fake.
Excluded endpoints
| Endpoint | Reason |
|---|---|
/api/webhooks/stripe | Stripe signs webhook requests; rejecting them causes retries |
Response headers
When a rate limit is hit, the API returns 429 Too Many Requests with these headers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-Reset: 60{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Límite global de API excedido. Intenta de nuevo en un minuto."
}
}A request-rate 429 is 429 RATE_LIMIT_EXCEEDED, answered before the request reaches the API: it carries success and error.code, but not the rest of the error envelope (no RFC 9457 fields such as title, no meta), and its message is Spanish text that depends on the tier and route, whatever your Accept-Language. Monthly-quota 429s (QUOTA_EXCEEDED) come from the application with the full envelope. Branch on the status and error.code, and wait what Retry-After says.
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying. This is the value to program against. |
RateLimit-Limit | Maximum requests allowed in the current window (or quota) |
RateLimit-Remaining | Requests remaining in the current window |
RateLimit-Reset | When the window or quota resets — seconds for request-rate 429s, a date for monthly-quota 429s. Prefer Retry-After for timing retries. |
RateLimit-* headers are only sent on a 429. Successful responses carry none, so you cannot track your remaining budget ahead of time — don't build a client-side gauge. React to the 429 and honour Retry-After.
Handling rate limits
Retry with backoff
async function requestWithRetry(fn: () => Promise<any>, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.status === 429) {
const retryAfter = parseInt(error.headers?.['retry-after'] || '60');
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}Best practices
- Use bulk endpoints when operating on multiple resources — one bulk request instead of N individual requests
- Cache responses where possible to avoid unnecessary API calls
- Respect
Retry-Afterheaders instead of retrying immediately - Use idempotency keys so retried requests don't create duplicates
Pacing a polling or reconciliation job
A scheduled job that re-reads invoices — to follow VeriFactu states, or to catch up after a webhook outage — runs under the same Standard budget as the rest of your integration with that key. Keep it well below 300 requests a minute so it never starves your live traffic:
- List, don't fetch one by one. One
GET …/invoices?verifactu_status=…page returns many invoices; reading each invoice separately costs one request per invoice. - Look only where something can still change. An
ACCEPTEDinvoice does not change on its own — see IsACCEPTEDfinal? — so sweepPENDING,REJECTEDandNOT_SUBMITTED, as in Reconcile, do not only listen. - Prefer webhooks for the fast path.
verifactu.status.updatedtells you when a record changes; the job is the safety net, and daily is enough for most volumes. - Give the job its own key if you want its budget separated from your live traffic: the Standard tier counts per credential.
- On a
429, stop and waitRetry-Afterbefore the next request.
API status
Check status.beel.es for real-time API uptime, incident reports, and scheduled maintenance.
Need higher limits?
If the standard limits don't fit your use case, contact us and we'll work out a plan:
📧 Request a rate limit increase →