Rate limits
Requests are counted twice: once per principal (the API key, or the dashboard user behind a session) and once per IP address. Both windows are one minute, and both are large enough that ordinary trading never touches them.
The limits
| Scope | Limit | Window |
|---|---|---|
| Any authenticated endpoint, per principal | 20,000 requests | 1 minute |
| Every request, per IP address, before authentication | 20,000 requests | 1 minute |
| Sign-in, per IP | 30 requests | 15 minutes |
| Certificate upload and bulk device import | 60 requests | 1 minute |
POST /v1/invoices has no separate, smaller budget. It shares the 20,000 per minute — a rate
limit that blocks a legitimate sale hands you a compliance problem in order to solve our capacity
problem, which is a bad trade.
The two small budgets are small on purpose and for different reasons:
- Sign-in is where someone guesses a password. It is not trading traffic, and it is not raised with the rest. It sits alongside per-account lockout, so neither a concentrated nor a sprayed attack goes unbraked.
- Upload endpoints parse untrusted input — a PKCS#12 container, a CSV of thousands of rows — and are driven by a human in a dashboard. Nobody uploads a certificate sixty times a minute.
IPv6 is counted per /56
An IPv4 address is used whole. An IPv6 address is truncated to its /56 prefix, because a single host is routinely delegated a /64 or larger — keyed on the full address, one machine could walk its own prefix and mint unlimited buckets, which is a rate limiter that does not limit.
Configuration
On a self-hosted deployment the limits are environment variables, and the whole mechanism can be switched off:
| Variable | Default | Effect |
|---|---|---|
RATE_LIMIT_ENABLED | true | false removes the limiters entirely. |
RATE_LIMIT_PER_MINUTE | 20000 | Per-principal budget. |
RATE_LIMIT_IP_PER_MINUTE | 20000 | Per-IP budget. |
RATE_LIMIT_AUTH_PER_15MIN | 30 | Sign-in, per IP. |
RATE_LIMIT_ENABLED=false does not disable the sign-in limiter. That one stays on in
every configuration; a convenience switch should not quietly become a credential-stuffing
invitation.
Counting is in-process. A deployment running more than one API replica gets the limit per replica.
Headers
Every response carries the current state of your budget:
RateLimit-Limit: 20000RateLimit-Remaining: 19994RateLimit-Reset: 27| Header | Meaning |
|---|---|
RateLimit-Limit | Requests permitted in the current window |
RateLimit-Remaining | Requests left |
RateLimit-Reset | Seconds until the window resets |
A 429 adds Retry-After, in seconds:
HTTP/1.1 429 Too Many RequestsRetry-After: 27RateLimit-Limit: 20000RateLimit-Remaining: 0RateLimit-Reset: 27{ "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Too many requests. Slow down and retry; see the Retry-After header.", "param": null, "upstream": null, "doc_url": "https://docs.fiskhub.com/errors/rate_limited" }}See rate_limited.
Backing off
A 429 is safe to retry. Wait for Retry-After, then send the request again with the same
Idempotency-Key — nothing was fiscalized, and the key is what makes retrying free of risk.
async function send(body, idempotencyKey, attempts = 4) { for (let attempt = 1; attempt <= attempts; attempt++) { const res = await fetch(`${API}/invoices`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Idempotency-Key": idempotencyKey, // unchanged across attempts "Content-Type": "application/json", }, body, });
if (res.status !== 429) return res;
// Honour the server's number. Guessing shorter just burns the budget. const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000; await sleep(wait + Math.random() * 250); // jitter: fleets sync up otherwise } throw new Error("rate limited after 4 attempts");}Two things worth doing:
- Honour
Retry-After. Retrying sooner cannot succeed and consumes the next window’s budget. - Add jitter. A fleet that all backs off for exactly 27 seconds returns as one thundering herd and trips the limit again.
Staying inside the limit
At 20,000 per minute you will not meet the limit by trading. You can still meet it by looping, and these are the loops that do it:
- Do not poll
GET /v1/invoices/{id}in a tight loop after aretryingresponse. The sweep retries every five minutes on its own; checking once a few minutes later, or reconciling once a day, is enough. - Reconcile with
GET /v1/invoices, not with N single reads. One filtered list call replaces hundreds of individual lookups. - Do not fetch
?include_messages=truein bulk. The raw XML is an audit and debugging view, not something to mirror. See the raw XML. - Cache
GET /v1/countries/{code}/schemas. It changes with a schema version, not with a request. - Import devices in bulk.
POST /v1/devices/importapplies thousands of rows in one call — and note it draws on the 60-per-minute upload budget, not the large one. - Do not use
GET /v1/health/fiscalas a pre-flight check on every sale. It is a probe for when things look wrong, and it is never a gate — fiscalize anyway and take the ZKI.