To retrieve a KSeF UPO safely, preserve the invoice reference returned by the send call, poll the invoice status until it reaches a terminal state, and accept only status `200` with a KSeF number. Then download the UPO XML, verify its response hash, schema, and XAdES signature, and archive the exact bytes. A successful send response or an expiring UPO URL is not proof that KSeF accepted the invoice.

## What proves that KSeF accepted an invoice?

**Acceptance requires the completed asynchronous result, not a successful HTTP submission.** The send call returns an invoice `referenceNumber` and starts verification. It does not assign the final KSeF number or prove that the document passed KSeF checks.

This distinction is fundamental to a reliable integration. Your application can receive a normal response from the send endpoint while the invoice still awaits validation. If you mark the invoice as accepted at that point, your local state will be ahead of the official system. You may show a customer a false success, start downstream accounting work too early, or lose the identifiers needed to trace a later rejection.

The [official interactive-session guide](https://github.com/CIRFMF/ksef-api/blob/main/sesja-interaktywna.md#2-wys%C5%82anie-faktury) describes post-submission verification as asynchronous. Immediately preserve the returned invoice reference alongside your local invoice and the session reference. These identifiers serve different purposes:

- Your local invoice ID ties the workflow to your own business record.
- The session reference identifies the KSeF session used for transport.
- The invoice reference identifies this submission while KSeF processes it.
- The KSeF number arrives only after acceptance and becomes part of the evidence.

For a positive result, wait for invoice status `200`, confirm that the response contains the KSeF number, and retrieve the UPO. The Ministry's [KSeF 2.0 handbook, part II](https://ksef.podatki.gov.pl/media/cark5buy/podrecznik-ksef-20-czesc-ii-wystawianie-i-otrzymywanie-faktur-w-ksef-19022026.pdf) explains that acceptance assigns a KSeF number and makes the UPO available as a separate XML document.

That gives you a clean state model: **submitted**, **processing**, **accepted**, or **failed**. Do not collapse submitted and accepted into one state. The HTTP response proves transport; status `200`, the KSeF number, and the verified UPO prove the completed outcome.

## Which KSeF status endpoint should you poll?

**Poll the status of the individual invoice when you need a result for that invoice.** Use `GET /sessions/{referenceNumber}/invoices/{invoiceReferenceNumber}`, with the session reference and invoice reference stored at submission time.

According to the [official OpenAPI specification](https://github.com/CIRFMF/ksef-api/blob/main/open-api.json), the response can include the KSeF invoice status, readable local invoice number, KSeF number, invoice hash, acquisition date, invoicing date, permanent-storage date, invoicing mode, and an expiring UPO download URL with its expiration time. Persist useful fields as they appear instead of waiting until the whole session finishes.

The session endpoint, `GET /sessions/{referenceNumber}`, solves a different problem. It reports session state and aggregate counts such as `invoiceCount`, `successfulInvoiceCount`, and `failedInvoiceCount`. For a closed session, it can also return references and download URLs for aggregate UPO pages.

Those totals are useful for reconciliation. They tell you whether the number of individual outcomes you recorded agrees with KSeF. They do not tell you which local invoice received which KSeF number. A session can contain both successful and failed invoices, so a session-level result cannot replace the per-invoice record.

Use both views deliberately:

1. Poll per-invoice status to drive the invoice state shown in your product or ERP.
2. Read session status to reconcile totals and retrieve aggregate UPO pages when appropriate.
3. Investigate any difference between the session counts and your stored invoice outcomes.

The [official session status and UPO guide](https://github.com/CIRFMF/ksef-api/blob/main/faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md) documents both levels. Keeping them separate prevents a common identity error: treating a session reference, invoice reference, and KSeF number as interchangeable values.

## Which invoice status codes are terminal?

**A poller must stop on every documented terminal state, not only on success.** Codes `100` and `150` remain in progress. Code `200` is success. The documented `4xx` and `5xx` outcomes below require failure handling or investigation.

| Code | Meaning | Production classification |
|---|---|---|
| `100` | Accepted for further processing | Non-terminal |
| `150` | Processing | Non-terminal |
| `200` | Success | Terminal success |
| `405` | Cancelled because of a session error | Terminal failure |
| `410` | Invalid permission scope | Terminal failure |
| `415` | Invoice with attachment cannot be sent | Terminal failure |
| `430` | Invoice-file verification error | Terminal failure |
| `435` | File decryption error | Terminal failure |
| `440` | Duplicate invoice | Terminal failure, inspect structured extensions |
| `450` | Semantic invoice validation error | Terminal failure |
| `500` | Unknown error | Terminal failure, manual investigation |
| `550` | Operation cancelled by the system | Investigate, then retry at operation level if appropriate |

These meanings come from `SessionInvoiceStatusResponse` in the [current OpenAPI contract](https://github.com/CIRFMF/ksef-api/blob/main/open-api.json). Store the numeric code, description, details, and structured extensions you receive. Human-readable text helps operators, while the code gives application logic a stable decision point.

Take special care with duplicate status `440`. It can include structured information about the original session and KSeF number. That is evidence for investigation, not permission to silently convert the new attempt into success. Link to the original only after your own invoice identity and hash rules confirm that both records represent the same document.

Your parser should also tolerate fields it does not recognize. The [API changelog](https://github.com/CIRFMF/ksef-api/blob/main/api-changelog.md) states that additional properties may appear without being treated as a breaking change. Unknown status codes should move the invoice to manual investigation. They must never default to acceptance.

## How should production polling work?

**Production polling should be resumable, bounded, and quota-aware.** Persist every attempt, use exponential backoff with jitter, honor `Retry-After`, and move unusually long-running items into reconciliation instead of polling forever.

The API publishes limits rather than a required polling interval. The [official rate-limit guide](https://github.com/CIRFMF/ksef-api/blob/main/limity/limity-api.md) currently documents 30 requests per second, 120 per minute, and 1,200 per hour for the single-invoice status endpoint. Other `/sessions/*` routes, including session status and UPO endpoints, allow 10 per second, 120 per minute, and 1,200 per hour. A `429` response includes `Retry-After`.

That mix of limits matters. A tight one-second loop may fit the per-second ceiling during a small test yet exhaust the hourly budget in production. A worker fleet can also create synchronized bursts after a deploy or outage. Jitter spreads those requests, while a persisted `next_attempt_at` lets polling resume on the same schedule after a restart.

```text
submit invoice
persist local_id, session_reference, invoice_reference, submitted_hash

repeat with bounded exponential backoff and jitter:
  response = get invoice status
  persist code, details, extensions, checked_at

  if response is 429:
    schedule next attempt from Retry-After
  else if code is 100 or 150:
    schedule next attempt
  else if code is 200 and ksef_number is present:
    persist ksef_number and status timestamps
    retrieve, verify, and archive UPO
    finish as accepted
  else if code is documented terminal failure:
    finish as failed and route to the matching repair path
  else:
    stop automatic acceptance and request manual investigation

if processing exceeds the operational deadline:
  move record to reconciliation queue
```

The operational deadline is your safeguard, not an invented KSeF status. It should stop an individual worker from retrying forever while preserving the record for later checks. Store at least the last attempt time, attempt count, last response details, and next scheduled attempt. That is enough to make polling durable across process crashes.

## How do you retrieve invoice and session UPO?

**Retrieve an invoice UPO after that invoice reaches status `200`; retrieve aggregate UPO pages only after the session conditions are met.** The invoice-level artifact can exist while an interactive session remains open.

KSeF exposes three authenticated routes in the current OpenAPI contract:

- `GET /sessions/{sessionReferenceNumber}/invoices/{invoiceReferenceNumber}/upo`
- `GET /sessions/{sessionReferenceNumber}/invoices/ksef/{ksefNumber}/upo`
- `GET /sessions/{sessionReferenceNumber}/upo/{upoReferenceNumber}` for an aggregate UPO page

The status response can also give you a signed `upoDownloadUrl` or `downloadUrl`. Fetch that storage URL with a plain HTTP GET and **do not attach the KSeF access token**. The [OpenAPI descriptions](https://github.com/CIRFMF/ksef-api/blob/main/open-api.json) say that these signed URL downloads do not count against API limits and expire at the timestamp in the response.

Keep this boundary clear. The API routes require one of the permission scopes listed by the current contract, including `InvoiceWrite`, `Introspection`, `PefInvoiceWrite`, or `EnforcementOperations`. Do not implement against older guidance that describes API retrieval as unauthenticated. Authentication is required for the KSeF API route; the signed storage URL is fetched without your access token.

An invoice UPO is available after that invoice is accepted and receives a KSeF number. Aggregate UPO becomes available after the session is closed, all documents are processed, and at least one has both a KSeF number and permanent storage. Therefore, closing an interactive session should not be a prerequisite for collecting an already available invoice UPO.

## How do you verify UPO before archiving it?

**Archive the UPO only after verifying the exact response bytes against the transport hash, XML schema, signature, and your submission record.** Parsing the XML successfully is useful, but it is not a complete integrity or identity check.

Every successful invoice or UPO download response exposes `x-ms-meta-hash`, a Base64-encoded SHA-256 hash of the returned document. Read the body as bytes, compute SHA-256 over those unchanged bytes, Base64-encode the result, and compare it to the header before transforming or normalizing the XML.

Next, validate the XML against the official [UPO v4-3 XSD](https://github.com/CIRFMF/ksef-api/blob/main/faktury/upo/schemy/upo-v4-3.xsd). UPO v4-3 has been the default since 2025-12-22 and uses one schema for invoice and session UPO. It includes `TrybWysylki`, which distinguishes `Online` and `Offline` sending modes. The [API changelog](https://github.com/CIRFMF/ksef-api/blob/main/api-changelog.md) records the version change and hash behavior.

Then validate the XAdES signature and its trust chain with the appropriate Ministry trust material. Finally, compare the signed document's business fields with your submission record: session reference, invoice hash, seller NIP, local invoice number, KSeF number, issue date, submission and acquisition timestamps, and sending mode.

Run these as separate checks and retain each result. A correct response hash proves that you stored the bytes delivered in that response. Schema validation proves structural conformance. Signature validation covers authenticity and integrity under the signing trust model. Business-field matching proves that the artifact belongs to the invoice you intended to process.

## What should your KSeF evidence record contain?

**A durable KSeF record must preserve identifiers, state history, exact UPO bytes, and verification results.** Storing only the KSeF number leaves you unable to reproduce how your system reached its conclusion.

| Evidence field | Why retain it |
|---|---|
| Local invoice primary key and business number | Connects KSeF evidence to your accounting record |
| Exact submitted FA(3) XML hash | Identifies the document sent and supports duplicate checks |
| Session and invoice reference numbers | Supports status polling, UPO retrieval, and support investigation |
| KSeF number | Records the identifier assigned after acceptance |
| Last status code, description, details, and extensions | Preserves the official processing result and structured error context |
| Invoicing, acquisition, and permanent-storage dates | Preserves each timestamp without inferring state from their order |
| Exact UPO XML bytes | Retains the evidence independently of delivery infrastructure |
| Computed SHA-256 and `x-ms-meta-hash` | Records the response-integrity comparison |
| UPO schema version and validation result | Shows which structural contract was checked |
| XAdES signature and trust-chain result | Records authenticity verification |
| Retrieval and verification timestamps | Shows when evidence was collected and checked |
| Retry, repair, correlation, and trace history | Makes failures reproducible and operationally diagnosable |

Keep the three KSeF timestamps as separate fields. `invoicingDate`, `acquisitionDate`, and `permanentStorageDate` describe different events. Do not turn their apparent order into a substitute state machine. The status code and persisted identifiers remain the authoritative basis for workflow decisions.

Also keep the raw bytes even if you extract convenient searchable fields. An object-store key plus a content hash can work well, provided retention and access controls match your evidence requirements. The expiring URL does not belong in place of the object. It is only one delivery route to the object.

## How do you recover after failures and expired links?

**Recovery starts from persistent identifiers and status, not from a cached signed URL.** If a URL expires, query KSeF again through the authenticated flow and obtain a current retrieval path.

After transport problems or process restarts, resume from the saved invoice reference and last poll state. For `429`, honor `Retry-After` exactly. For statuses `100` and `150`, continue the bounded schedule. For a documented terminal failure, stop polling and create a repair path that matches the error instead of resubmitting blindly.

Status `440` deserves a deliberate reconciliation path. Inspect its structured extensions, find the original submission, and compare the local identity with the immutable invoice hash. Only then decide whether the original accepted document is the valid outcome for your local invoice. A duplicate response itself remains a failure for the attempted operation.

Use the session aggregate as a second line of defense. Compare `invoiceCount`, `successfulInvoiceCount`, and `failedInvoiceCount` with your per-invoice records. A mismatch can reveal a lost job, an unpersisted callback result, or an invoice that entered manual investigation. The [session/UPO guide](https://github.com/CIRFMF/ksef-api/blob/main/faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md) provides the official session-level fields and availability rules.

As of 2026-08-28, **KSeF API 2.6.1 is the newest version shown as deployed to PRD**. Version 2.7.1 reached TEST on 2026-08-26, with DEMO scheduled for 2026-09-15 and PRD for 2026-09-23. Its documented changes do not alter this status and UPO flow, but production documentation should not call 2.7.1 the production version before that deployment. Recheck the [official changelog](https://github.com/CIRFMF/ksef-api/blob/main/api-changelog.md) when implementing or revisiting the integration.

## How KSeF Kit closes the evidence loop

**KSeF Kit applies the same evidence-first workflow to Stripe invoices.** It converts finalized Stripe invoices to FA(3), submits them, waits for acceptance, stores the UPO, and writes the KSeF number back to Stripe metadata.

That implementation is useful because it keeps the asynchronous boundary visible. Stripe finalization does not become a claim that KSeF accepted the invoice. KSeF Kit waits for the official result and retains the artifact that proves it. You can read the [filing workflow documentation](https://ksef.startupkit.app/docs/how-filing-works) for the product-level sequence or visit the [KSeF Kit product page](https://ksef.startupkit.app/) for the supported Stripe integration.

The general rule is the same whether you build the integration or use a product: preserve every reference, stop on every terminal status, verify the downloaded artifact, and archive evidence that outlives its URL. That turns a successful API call into a result you can reconcile and defend later.

> [!CTA]
> If you issue invoices through Stripe and would rather not build this polling and evidence pipeline yourself, explore [how KSeF Kit files and records them](https://ksef.startupkit.app/docs/how-filing-works).