KSeF API rate limits run simultaneously per second, minute, and hour in rolling windows, usually for each `(context, IP)` pair. When KSeF returns HTTP `429`, wait for the server's `Retry-After` value, pause every worker sharing that quota, and reconcile stored session or invoice references before replaying a submission whose outcome is uncertain.

That last distinction matters. A received `429` tells you when to try again. A connection loss after sending an invoice leaves you with an unknown outcome. Treating both cases as a generic retry can create duplicate submissions, longer blocks, and a queue that gets less stable under load.

This is a technical operations guide, not tax or legal advice. The values below reflect the live KSeF contracts checked on August 28, 2026.

## What KSeF API rate limits apply in production?

**KSeF assigns separate second, minute, and hour thresholds to groups of API operations. All three thresholds apply at once, so the hourly cap can stop a client that never exceeds its per-second rate.**

Production and DEMO were running API 2.6.1 at the check date. TEST was on 2.7.1, but shared endpoint groups used the same defaults. These are the limits published in the live [production OpenAPI contract](https://api.ksef.mf.gov.pl/docs/v2/openapi.json):

| Limit group | Representative operation | req/s | req/min | req/h |
|---|---|---:|---:|---:|
| `onlineSession` | Open or close an online session | 10 | 30 | 120 |
| `batchSession` | Open or close a batch session | 10 | 20 | 60 |
| `invoiceSend` | Send an invoice in an online session | 10 | 30 | 180 |
| `invoiceStatus` | Get one invoice's status | 30 | 120 | 1,200 |
| `sessionList` | List sessions | 5 | 10 | 60 |
| `sessionInvoiceList` | List session invoices or failed invoices | 10 | 20 | 200 |
| `sessionMisc` | Other session, invoice, and UPO operations | 10 | 120 | 1,200 |
| `invoiceMetadata` | Query invoice metadata | 8 | 16 | 20 |
| `invoiceExport` | Start an invoice export | 8 | 16 | 20 |
| `invoiceExportStatus` | Check an export's status | 10 | 60 | 600 |
| `invoiceDownload` | Download an invoice by KSeF number | 8 | 16 | 64 |
| `other` | Each remaining protected resource | 10 | 30 | 120 |

These are defaults, not a configuration constant you should copy into your application forever. The authenticated `GET /rate-limits` endpoint returns the effective values for the current context. KSeF can adjust limits, grant individual increases, or grant temporary increases that later expire. The Ministry's [April 2026 integrator notice](https://ksef.podatki.gov.pl/komunikaty-techniczne/czasowe-zwiekszenie-limitow-api-ksef-20-komunikat-dla-integratorow/) says individual changes are applied to DEMO and production together.

Use the static table for capacity planning. Use `GET /rate-limits` for runtime policy, cache the result, and treat a real `429` as the final authority.

### Two stale figures to remove from old runbooks

First, TEST no longer has default limits ten times higher than production. API 2.5.0 made TEST match production for shared groups, while retaining TEST-only endpoints that let integrators simulate custom profiles. The old 10× statement still appears in the prose limit guide, but the [API changelog](https://github.com/CIRFMF/ksef-api/blob/main/api-changelog.md) and live contracts show the later change.

Second, an official PDF still lists invoice export at 4 requests per second and 8 per minute. API 2.4.0 raised those thresholds to 8 and 16 in production on April 16, 2026. The hourly limit stayed at 20.

Version awareness matters here. Repository `main` already contains API 2.7.1 changes that were deployed to TEST on August 26 but were scheduled for production on September 23. For today's production behavior, the production OpenAPI wins over a future entry on `main`.

## How does KSeF count requests?

**Protected requests are normally counted for each combination of KSeF context and source IP. The counters use rolling windows, not fixed clock minutes or hours.**

The official [request-limit guide](https://github.com/CIRFMF/ksef-api/blob/main/limity/limity-api.md) defines the quota key as the pair of:

- the `ContextIdentifier` used during authentication, such as `Nip`, `InternalId`, or `NipVatUe`;
- the public IP address from which the client connects.

The same NIP used through one egress IP shares a budget across every process and worker behind that address. Another office or integrator using the same context from a different IP receives a separate counter. Public endpoints are protected by IP.

Each request is counted in the previous one second, 60 seconds, and 60 minutes. A minute window does not reset at `12:01:00`, and an hour window does not reset at the top of the hour. If you spend the 20-per-hour invoice-export budget in the first ten minutes, waiting for the next clock hour is not enough. Capacity returns as those calls leave the rolling 60-minute window.

This also explains why a simple per-process sleep is insufficient. Ten application workers can each believe they are below the limit while their combined traffic exceeds the shared `(context, IP)` budget. The limiter has to coordinate across every worker that uses the same quota key and limit group.

Do not use IP rotation as a workaround. The Ministry explicitly says it records violations and watches for systematic use of multiple addresses to evade limits. Repeated or extreme patterns can trigger wider protection for a subject or an IP range.

## What should your integration do after HTTP 429?

**On `429 Too Many Requests`, read `Retry-After`, stop sending requests on the affected quota lane, and wait at least that many seconds. A small positive jitter may be added after the server's delay, never instead of it.**

KSeF returns `Retry-After` as an integer number of seconds. The block is dynamic, and repeated violations can make it substantially longer. There is no correct hardcoded fallback such as “always retry after 30 seconds.”

A safe scheduler follows this sequence:

```text
quota_key = [context_identifier, egress_ip, limit_group]

on HTTP 429:
  retry_after = parse Retry-After as seconds
  pause quota_key until monotonic_now + retry_after
  requeue the operation after pause_until + small_positive_jitter
  record the attempt and stop after a bounded retry/time budget
```

The shared pause is important. Requeueing only the worker that received the response leaves its peers hammering the same quota. Adding jitter is also a client-side engineering choice, not a Ministry requirement. It spreads a group of waiting workers after the mandatory delay so they do not all wake on the same millisecond.

KSeF supports two error-body formats. The legacy JSON response remains available. Clients can request Problem Details with `X-Error-Format: problem-details`. In both cases, the retry schedule comes from the response header, so your HTTP layer should preserve headers even when it turns the body into a typed exception.

The official C# client parses `Retry-After` and exposes a recommended delay. Its repository also contains a rate-limit wrapper that fetches effective limits, pre-throttles against all three windows, and retries a `429` up to five times. That wrapper lives in test utilities, not in the production SDK pipeline. The Java SDK likewise exposes the error and headers but does not install a generic automatic retry loop.

Both clients ship a circuit breaker that opens after five consecutive transient failures and permits a half-open probe after 30 seconds. **A circuit breaker is not a retry policy.** It fails fast to protect the application and KSeF; it does not replay the failed request for you.

## Which errors should be retried, reconciled, or stopped?

**Classify the outcome before you retry. Rate limiting, pending asynchronous work, invalid input, and an unknown network outcome need different responses.**

| Outcome | What it means | Safe next action |
|---|---|---|
| HTTP `429` with `Retry-After` | KSeF throttled the operation | Pause the shared quota lane, wait at least the supplied delay, then retry within a bounded budget |
| Status `100` or `150` | The async operation was accepted and is still processing | Poll with a paced, jittered schedule; do not resubmit |
| HTTP `400` or invoice validation failure | The request or document is invalid | Fix the input; do not retry the same payload unchanged |
| HTTP `401` or `403` | Authentication or authorization failed | Repair credentials or permissions before retrying |
| HTTP `408`, `5xx`, timeout, or connection loss | The failure is transient, but a write's outcome may be unknown | Retry safe reads; reconcile writes before replaying them |
| Terminal status `550` | KSeF cancelled processing and says to try again | Keep the old correlation record, then create a controlled resubmission |
| Status `440` | KSeF detected a duplicate invoice | Use the original session and KSeF references to reconcile; do not keep retrying |

This table is deliberately stricter than “retry every transient error.” A GET that times out can usually be repeated. A POST that sent bytes before the connection vanished may already have started an asynchronous operation.

Apply a retry ceiling as well as a delay. A queue that retries forever hides an incident and consumes the capacity needed for healthy work. After the attempt or elapsed-time budget is exhausted, move the operation to a visible blocked state and alert an operator with the correlation data needed to continue safely.

## How do you prevent duplicate invoice submissions?

**KSeF invoice submission is not documented as idempotent. Persist a local attempt, content hash, session reference, and invoice reference, then reconcile an uncertain outcome before creating another submission.**

The KSeF contract exposes no `Idempotency-Key` header or client request token for invoice submission. The SHA-256 invoice hash is used for integrity and correlation; it is not documented as an idempotency key.

Use a durable local state machine:

1. **Create the attempt before the request.** Store the source invoice ID, exact payload hash, context, environment, operation type, and attempt number.
2. **Persist references immediately.** Opening an online session returns a session `referenceNumber`. Sending an invoice returns HTTP 202 with a separate invoice `referenceNumber`. Save each one before scheduling the next step.
3. **Separate submitted from accepted.** A successful HTTP response means KSeF accepted work for processing. It does not yet mean the invoice received a KSeF number.
4. **Reconcile uncertain writes.** If the response disappears, inspect the known session, its invoices, and stored hashes. If an invoice reference exists, poll it.
5. **Create a new attempt only after reconciliation.** Keep the earlier attempt and explain why a replay was necessary.

The official batch guide specifically recommends a local mapping from each original XML file's SHA-256 hash to its source document. Returned session-invoice records contain the hash, reference, invoice number, status, and optional KSeF number, which gives you the material to match results without guessing.

KSeF also detects duplicates globally using seller NIP, invoice type, and invoice number. A duplicate becomes asynchronous status `440`, not another successful submission. The current response can include `originalSessionReferenceNumber` and `originalKsefNumber`. Those fields help repair state after a duplicate appears, but they do not turn replay into an idempotent operation.

## How should you pace polling and batch work?

**Give each KSeF limit group its own coordinated lane, reserve headroom below every rolling threshold, and prefer batch operations when more than one invoice is ready in the same operational window.**

Start with a limiter keyed by `(context, egress IP, limit group)`. Load effective values from `GET /rate-limits`, cache them, and refresh them periodically. Do not call the limits endpoint before every request because it is also an API operation.

Then separate these workloads:

- online session control;
- interactive invoice sends;
- invoice-status polling;
- export creation and export-status polling;
- invoice downloads;
- miscellaneous protected operations.

Keep deliberate headroom. A scheduler aiming at exactly 30 sends per minute has no room for clock skew, delayed job wakeups, another application instance, or manual traffic using the same NIP and IP.

Polling deserves its own budget. One invoice-status endpoint allows 120 calls per minute and 1,200 per hour, while listing all sessions allows only 10 per minute and 60 per hour. Poll the specific reference you already know instead of repeatedly listing the world. Back off while the status is `100` or `150`, add jitter, cap the delay, and stop on a terminal result or an operational deadline.

For multiple invoices, the Ministry recommends batch mode. One package containing 100 invoices normally uses request capacity more efficiently than 100 interactive sends. Package-part uploads inside an open batch session are excluded from the API request limits and may be uploaded in parallel, although opening and closing the batch session remain limited operations.

The same principle applies to retrieval. KSeF says high-volume systems should use asynchronous invoice exports and synchronize into a local database. Calling KSeF every time a user opens an invoice turns a central repository into an application database and wastes the tight download budget.

## What should you monitor in production?

**Monitor quota use, retry decisions, asynchronous outcomes, and recovery state without logging invoice contents or credentials.** A `429` count alone tells you that the system is late, not why.

At minimum, record:

- effective limits and the time they were last refreshed;
- request count by environment, context, egress IP, and limit group;
- `429` count, `Retry-After` value, attempt number, and eventual outcome;
- queue depth and age for sends, status checks, exports, and downloads;
- time from submission to terminal invoice status;
- counts of pending `100`/`150`, duplicate `440`, and cancelled `550` outcomes;
- unknown-outcome writes awaiting reconciliation;
- circuit-breaker state and rejected calls;
- the session, invoice, export, and local attempt references needed for support.

Keep sensitive fields out of logs and error trackers. Invoice XML, buyer data, authentication tokens, UPO documents, cookies, and raw request parameters do not belong in an exception event. Identifiers, state transitions, response class, trace ID, and timings are usually enough to diagnose a retry failure.

Alert on trends, not just individual responses. A rising hourly-budget estimate, growing status queue, or repeated long `Retry-After` values gives you time to slow producers before the integration becomes a retry storm.

## How does KSeF Kit handle retries today?

**KSeF Kit uses durable submission attempts and stored KSeF references so polling can resume without blindly submitting the same invoice again. Its public documentation describes five retries with increasing delays for transient `429`, `500`, and `550` failures.**

The [filing lifecycle](https://ksef.startupkit.app/docs/how-filing-works?locale=en) starts with a finalized Stripe invoice as the immutable source, maps it to FA(3), opens an online session, submits it, and polls for the KSeF number and UPO. Each submission attempt is a separate record. If polling is interrupted, the stored references let a later job continue from the accepted operation.

The [KSeF API guide](https://ksef.startupkit.app/guides/ksef-api?locale=en) and [outage runbook](https://ksef.startupkit.app/guides/ksef-awaria-co-zrobic?locale=en) distinguish transient retries from reconciliation. User-visible states separate queued, submitting, accepted, rejected, and blocked work. The [security documentation](https://ksef.startupkit.app/docs/security-data?locale=en) says hosted errors include identifiers and state in Sentry while excluding invoice contents, buyer PII, tokens, UPOs, request parameters, and cookies.

Those are the current product boundaries. KSeF Kit does not publicly claim per-context request budgets, a documented jitter policy, rate-limit dashboards, or offline24 issuance. The broader architecture in this guide is the standard a production integration should work toward, not a list of hidden product features.

The operational rule is simple: **pace before KSeF has to stop you, obey the delay when it does, and never confuse retrying transport with deciding whether an invoice exists.** KSeF Kit manages that lifecycle from Stripe finalization through KSeF status and UPO.

> [!CTA]
> **Submitting Stripe invoices to KSeF?** KSeF Kit turns finalized invoices into FA(3), tracks each attempt, and keeps the KSeF reference and UPO with the source record.
>
> [Start with KSeF Kit](https://ksef.startupkit.app/signup)