Rate limits
eFakturuj enforces rate limits with a Redis-backed token bucket
applied per identity — usually your API key, or your organisation
when the call is JWT-authenticated. The bucket fills at a constant
rate per minute (rpm) up to a burst ceiling, and each request
consumes one token. When the bucket is empty you get a 429 with a
Retry-After header.
Rate limits exist for two reasons: protect downstream Peppol / FS infrastructure from accidental floods, and preserve fair-use across all customers on shared workers.
Identity scope
The middleware (backend/app/core/middleware.py:RateLimitMiddleware)
picks identity in this order — the first one wins:
X-API-Keyheader → bucket keyapikey:<sha256[:16]>— per API keyAuthorization: Bearer efk_live_…/efk_test_…→ bucket keyapikey:<sha256[:16]>— per API keyAuthorization: Bearer <JWT>→ bucket keyorg:<org_id>— per organisation, plan tier read from the JWT'splanclaim- No auth → bucket key
ip:<X-Forwarded-For or peer IP>— fall back to per-IP
/api/v1/health, /docs, /redoc, /openapi.json, /metrics and
all OPTIONS preflights are exempt.
Plan-tier budgets
Buckets are sized from the plan_tiers table (cached in Redis).
Approximate per-minute budgets as of v1:
| Plan tier | rpm_limit | rpm_burst |
|---|---|---|
| Solo Free | 10 | 15 |
| Solo Lite | 30 | 50 |
| Solo Starter | 60 | 100 |
| Team Business | 120 | 200 |
| Team Professional | 200 | 400 |
| Team Enterprise | 500 | 1000 |
| Connect Sandbox | 10 | 15 |
| Connect Basic | 60 | 100 |
| Connect Pro | 200 | 400 |
| Connect Enterprise | unlimited | unlimited |
| Plug Single Store | 60 | 100 |
| Plug Multi-Store | 60 | 100 |
-1 (Team Custom, Connect Enterprise) means no rate limit. When the
plan can't be determined (cold cache, anonymous request) the
middleware falls back to 30 rpm / 50 burst.
The bucket refills at rpm / 60 tokens per second — i.e. Solo
Starter recovers one token roughly every second once empty.
Per-request response headers
eFakturuj does not emit X-RateLimit-Limit, X-RateLimit-Remaining
or X-RateLimit-Reset headers on every response today — only the
Retry-After header on 429 responses. Treat the absence of a 429
as confirmation that you're under budget.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
{"error":"rate_limit_exceeded","retry_after":12,"limit":60,"plan":"solo_starter","upgrade_url":"https://efakturuj.sk/pricing"}
(See Errors for the full body shape — and the note
that the body field is plan, not current_plan.)
Handling 429
The contract is: read Retry-After (seconds), wait, retry.
# curl — let curl back off and retry
curl --retry 3 --retry-delay 0 --retry-all-errors \
-H 'Authorization: Bearer EFK_TOKEN' \
https://api.efakturuj.sk/api/v1/invoices
# Python — Retry-After first, exponential backoff with jitter as fallback
import random, time, httpx
def request_with_backoff(client, method, url, **kwargs):
for attempt in range(5):
r = client.request(method, url, **kwargs)
if r.status_code != 429:
return r
wait = int(r.headers.get("Retry-After", 0))
if wait <= 0:
wait = (2 ** attempt) * 0.5 # 0.5, 1, 2, 4, 8s
time.sleep(wait * (0.75 + random.random() * 0.5)) # ±25% jitter
r.raise_for_status()
// Dart — same pattern with Dio
import 'dart:math' as math;
Future<Response> requestWithBackoff(
Dio dio,
String method,
String url, {
dynamic data,
}) async {
final rng = math.Random();
for (var attempt = 0; attempt < 5; attempt++) {
try {
return await dio.request(
url,
data: data,
options: Options(method: method),
);
} on DioException catch (e) {
if (e.response?.statusCode != 429) rethrow;
final retry = int.tryParse(
e.response?.headers.value('retry-after') ?? '',
) ??
(math.pow(2, attempt) * 0.5).toInt();
final jitter = 0.75 + rng.nextDouble() * 0.5;
await Future.delayed(
Duration(milliseconds: (retry * 1000 * jitter).toInt()),
);
}
}
throw Exception('rate-limited after 5 retries');
}
Avoiding rate limits
- Batch where you can. Bulk-import a CSV via
POST /invoices/bulk-importinstead of onePOST /invoicesper row. Bulk import is queued — one HTTP request, hundreds of invoices. - Use webhooks, not polling. Subscribe to invoice status changes
(Webhooks) instead of looping over
GET /invoices/{id}waiting for a status to flip. - Cache GET responses on your side. Customer directory and plan metadata are stable across most calls — a small TTL cache (60s – 5min) on your client kills most paging chatter.
- Use the largest
page_sizeyou can stomach when draining lists — see Pagination. - Failure-mode note. If the rate-limit Redis is unreachable the
middleware fails open — your request is allowed through and a
rate_limit_redis_erroris logged server-side. You will not see spurious 429s during a Redis outage; you may briefly see no enforcement.