Skip to content

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

ScopeLimitWindow
Any authenticated endpoint, per principal20,000 requests1 minute
Every request, per IP address, before authentication20,000 requests1 minute
Sign-in, per IP30 requests15 minutes
Certificate upload and bulk device import60 requests1 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:

VariableDefaultEffect
RATE_LIMIT_ENABLEDtruefalse removes the limiters entirely.
RATE_LIMIT_PER_MINUTE20000Per-principal budget.
RATE_LIMIT_IP_PER_MINUTE20000Per-IP budget.
RATE_LIMIT_AUTH_PER_15MIN30Sign-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: 20000
RateLimit-Remaining: 19994
RateLimit-Reset: 27
HeaderMeaning
RateLimit-LimitRequests permitted in the current window
RateLimit-RemainingRequests left
RateLimit-ResetSeconds until the window resets

A 429 adds Retry-After, in seconds:

HTTP/1.1 429 Too Many Requests
Retry-After: 27
RateLimit-Limit: 20000
RateLimit-Remaining: 0
RateLimit-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 a retrying response. 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=true in 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/import applies 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/fiscal as 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.