Fakturnia Developers Quickstart API reference Polski

Sending invoices to KSeF

Issuing an invoice and sending it to KSeF (Poland's National e‑Invoicing System) are two separate steps. You first create the document, then send it. There is no single call that creates and sends at once, and that is deliberate: it lets you create, review or validate a document before it becomes a legal e‑invoice.

The send is "instant" but it can finish asynchronously. The endpoint waits a short while for KSeF to accept the invoice; if KSeF needs longer, the call still returns success and the final result arrives a little later. So the important part is waiting for the outcome correctly, by polling the status endpoint or by consuming a webhook.

The flow at a glance

Issue an invoice and send it to KSeF: create, send, then wait for the result by polling or webhook

Before you start

  • An authorized KSeF connection must be configured on the account (done in the app). Without it the send is rejected.
  • Your token needs the scopes documents:create (to create) and documents:send-to-ksef (to send). Polling needs documents:read.
  • The document must be a KSeF‑eligible type, for example FakturaVAT. See Document types & amounts.

Test in the sandbox first — no legal effect. Before you send real invoices, rehearse the whole flow in the test environment (https://sandbox.api.fakturnia.pl). It requires a test KSeF certificate: log in to a test entity in the Ministry of Finance test portal https://ap-test.ksef.mf.gov.pl/web/, generate a certificate, and connect it to your sandbox account. A production certificate will not work against the sandbox.

Optional: validate first. To catch problems before anything is saved, send the invoice body to POST /documents/validate. It runs the full pipeline (numbering, totals, party resolution) and returns a preview without persisting, so you can show the user any validation errors up front. See Document types & amounts.

1. Create the invoice

A normal create. Nothing KSeF‑specific here.

curl -s -X POST https://prod.api.fakturnia.pl/external/v1/documents \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "documentType": "FakturaVAT", "issueDate": "2026-06-05", "buyerId": "681a…", "products": [ … ] }'

You get 201 with the new document.id. Keep it. Everything below uses that id.

The document's dates and terms go to the FA(3) XML like this:

Document field FA(3) element
issuePlace P_1M (place of issue)
subDate, subDateTo P_6 or OkresFa (P_6_Od, P_6_Do)
paymentDeadlineDate, paymentDeadlineTerm TerminPlatnosci with Termin and TerminOpis
paymentInstallments one TerminPlatnosci per instalment, the amounts in DodatkowyOpis
advanceReceipts (advance invoice) ZaliczkaCzesciowa with P_6Z, P_15Z and KursWalutyZW for each receipt
exchangeRate the rate on the lines; when it is missing, Fakturnia takes the NBP rate of the business day before the tax point when sending (a correction: the corrected invoice's rate)

A date with a time becomes the day in Poland, and a date that cannot be read is a validation error instead of today's date.

Let the user review before sending. A document becomes final once it reaches KSeF, and fixing anything afterwards needs a formal correction (FakturaKorygujaca). So between create and send it is worth showing the invoice to the user, for example its PDF, to confirm the data is right. Catching a wrong buyer NIP or amount now saves a correction later.

2. Send it to KSeF

curl -s -X POST https://prod.api.fakturnia.pl/external/v1/documents/{id}/send-to-ksef \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"

Always pass an Idempotency-Key. If the connection drops while the send is in flight, you can safely retry the exact same request without risking a double submission. See Idempotency.

The endpoint returns 200 in two shapes. Branch on whether data.ksefNumber is present:

Accepted right away (KSeF answered within the wait window):

{
  "success": true,
  "data": {
    "ksefNumber": "1234567890-20260605-AABBCC-DD",
    "referenceNumber": "20260605…",
    "invoiceNumber": "1",
    "ordinalNumber": 1,
    "status": { "code": 200, "description": "…", "details": [] },
    "sessionReferenceNumber": "…",
    "hasUpo": true,
    "sendMode": "instant"
  },
  "executionTime": 8200
}

Still processing (KSeF is taking longer; the system finishes it in the background):

{
  "success": true,
  "message": "Sent to KSeF. Processing is taking longer than usual; the status will update automatically.",
  "data": {
    "status": "processing",
    "sendMode": "instant",
    "documentNumber": "FV/1/06/2026",
    "sessionReferenceNumber": "…",
    "invoiceReferenceNumber": "…"
  },
  "executionTime": 61000
}

When you get status: "processing", the invoice has been handed to KSeF. Do not resend it. Move to step 3 and wait for the final result.

When the send is refused

These come back before anything is sent, so they are safe to fix and retry:

Status Code Meaning
409 KSEF_CONNECTION_REQUIRED no KSeF connection configured on the account
409 KSEF_ALREADY_PROCESSING a send for this document is already in flight
409 KSEF_SEND_NOT_ALLOWED the document is not in a sendable state (already sent, wrong type, deleted)
422 KSEF_CONNECTION_NOT_AUTHORIZED the KSeF connection exists but its token is invalid or expired
400 VALIDATION_ERROR / BAD_REQUEST the document does not meet KSeF requirements

3. Wait for the final result

The send response is not the end of the story when it says processing. The source of truth is the status endpoint. There are two ways to learn the outcome.

Option A — poll the status endpoint

curl -s https://prod.api.fakturnia.pl/external/v1/documents/{id}/ksef-status \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "data": {
    "documentId": "681a…",
    "documentNumber": "FV/1/06/2026",
    "state": "sent",
    "ksefNumber": "1234567890-20260605-AABBCC-DD",
    "ksefReferenceNumber": "20260605…",
    "ksefStatus": { "code": 200, "description": "Accepted", "details": [] },
    "lastError": null,
    "sentAt": "2026-06-05T10:35:00.000Z",
    "processedAt": "2026-06-05T10:35:14.000Z",
    "updatedAt": "2026-06-05T10:35:14.000Z"
  }
}

state is the field to branch on:

state Meaning What to do
not_sent no send started yet (you should not see this after step 2)
processing handed to KSeF, awaiting acceptance keep polling
sent accepted; ksefNumber is set done, store ksefNumber
failed KSeF rejected it; lastError is set read lastError, fix, resend

Poll until state is sent or failed. Both are terminal. This endpoint is rate limited to 5 requests per minute, so poll roughly every 15 seconds, not faster, and give up after about 10 minutes. A processing send usually settles within seconds; if you need lower latency than a 15‑second poll, use the webhook below instead of polling harder. If you do exceed the limit you get 429 with a Retry-After header. Honor it.

async function waitForKsef(id: string, token: string, intervalMs = 15_000, timeoutMs = 600_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/documents/${id}/ksef-status`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (res.status === 429) {                                   // rate limited: wait it out
      const retry = Number(res.headers.get('Retry-After') ?? 15);
      await new Promise((r) => setTimeout(r, retry * 1000));
      continue;
    }
    const { data } = await res.json();
    if (data.state === 'sent')   return data.ksefNumber as string;
    if (data.state === 'failed') throw new Error(data.lastError?.message ?? 'KSeF rejected the invoice');
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error('Timed out waiting for KSeF');
}

Option B — subscribe to the webhook

Instead of polling, subscribe to document.sentToKSeF and document.ksefRejected. The first arrives when KSeF accepts the invoice (also when processing finished in the background), the second when it rejects it or the send fails (validation, timeout, lost session), so you never miss a rejection. It fires when the send completes, so you skip the polling overhead. See Webhooks for delivery format and signature verification.

When the event arrives, read the status endpoint to confirm the outcome rather than trusting the event alone: call GET /documents/{id}/ksef-status, check that state === 'sent', and take ksefNumber from there. If state is still processing, wait for the next event or fall back to polling. Webhooks can also be delayed or retried, so most integrations use the event as a trigger and keep polling as a backup.

You usually want the best of both worlds: a quick answer when KSeF is fast, and no busy‑polling when it is slow. Combine the burst and the webhook, and keep a safety net.

  1. Burst‑poll right after the send. Check the status endpoint up to 3 times, 3 seconds apart. Many sends finish in this window, and 3 checks stay well under the 5/min limit. If you get sent or failed, you are done.
  2. Still processing? Stop and hand off. Show the user that the invoice is being processed, stop polling, and wait for the document.sentToKSeF or document.ksefRejected webhook to deliver the outcome. No busy loop, no rate‑limit pressure.
  3. Keep a backstop. A webhook can be missed: your server was offline, mid‑deploy, or behind a network blip. So run a periodic job that re‑checks any document still in processing and reconciles it from the status endpoint, for example once a minute. An invoice then never hangs waiting for a webhook that never arrives.
// 1. burst-poll for a fast result (max 3 checks, 3 s apart)
for (let i = 0; i < 3; i++) {
  const { state, ksefNumber } = await getKsefStatus(id, token);
  if (state === 'sent')   return ksefNumber;
  if (state === 'failed') throw new Error('KSeF rejected the invoice');
  await sleep(3000);
}
// 2. still processing: tell the user, then let the webhook resolve it
markProcessing(id);   // UI: "the invoice is being processed"
// 3. document.sentToKSeF (or the once-a-minute backstop) finishes the job later

The limit is per application, not per document. The status endpoint allows 5 requests per minute per application (API account), shared by every document, however many servers the application calls from. If your backstop may face more than a few processing invoices at once, throttle it: reconcile a handful per minute, space the calls out, and honor 429 / Retry-After. The backstop is a safety net, not a fast path.

Handling a failure

When state is failed, lastError carries the KSeF code and message. A failed document is unlocked: because it carries a KSeF error, you can edit or delete it and send again. (A document that was accepted, or one still awaiting confirmation, is locked. See Deleting and editing documents.) Resend with a fresh Idempotency-Key, since it is a new attempt.

Putting it together

// 1. create the invoice
const created = await api.post('/documents', invoiceBody, { idempotencyKey: uuid() });
const id = created.data.document.id;

// 2. send to KSeF
const sent = await api.post(`/documents/${id}/send-to-ksef`, null, { idempotencyKey: uuid() });

// 3. resolve the outcome
let ksefNumber = sent.data.ksefNumber;        // present when accepted synchronously
if (!ksefNumber) {                            // otherwise data.status === 'processing'
  ksefNumber = await waitForKsef(id, token);  // poll until sent or failed
}
// ksefNumber is now the legal KSeF identifier of the invoice