eFakturuj / API Docs
eFakturuj Guides

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:

  1. X-API-Key header → bucket key apikey:<sha256[:16]> — per API key
  2. Authorization: Bearer efk_live_… / efk_test_… → bucket key apikey:<sha256[:16]> — per API key
  3. Authorization: Bearer <JWT> → bucket key org:<org_id> — per organisation, plan tier read from the JWT's plan claim
  4. 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 tierrpm_limitrpm_burst
Solo Free1015
Solo Lite3050
Solo Starter60100
Team Business120200
Team Professional200400
Team Enterprise5001000
Connect Sandbox1015
Connect Basic60100
Connect Pro200400
Connect Enterpriseunlimitedunlimited
Plug Single Store60100
Plug Multi-Store60100

-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-import instead of one POST /invoices per 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_size you 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_error is logged server-side. You will not see spurious 429s during a Redis outage; you may briefly see no enforcement.