Skip to content

Invoice XML Serialization ​

Build KSeF-compliant invoice XML (FA2, FA3, PEF, PEF_KOR) from typed TypeScript objects. The serializer produces output that matches the official XSDs — correct element order, correct namespaces, and the per-VAT-rate P_13_N / P_14_N / P_14_NW interleave required by FA3 — so invoices are not rejected for structural reasons at submission time.

The entry points live in src/xml/ and are re-exported from the package root:

typescript
import {
  serializeInvoiceXml,
  buildFakturaXml,
  buildPefXml,
} from 'ksef-client-ts';

Overview ​

The module does not invent its own XML engine and it does not reorder the invoice on behalf of the user — it produces XML. Plain typed objects go in, a UTF-8-encoded Buffer (or string from the typed helpers) comes out. Element order is driven by a hand-transcribed ORDER_MAP keyed on each parent element, combined with a natural sort over P_* ordinals so that P_13_10 follows P_13_9 rather than landing after P_13_1.

Entry pointAcceptsReturnsUse when
serializeInvoiceXml(input, options?)string / Buffer / XmlDocument / FakturaInput / PefUblDocumentInputBufferYou have a typed object, a pre-built string, or a pre-parsed tree and want a single dispatch point
buildFakturaXml(faktura, options?)FakturaInputstringYou know you are building an FA2/FA3 invoice
buildPefXml(input, options?)PefUblDocumentInputstringYou know you are building a PEF Invoice or PEF_KOR CreditNote

Supported document forms ​

SchemaTarget XSDRoot element
FA2docs/schemas/FA/schemat_FA(2)_v1-0E.xsdFaktura
FA3docs/schemas/FA/schemat_FA(3)_v1-0E.xsdFaktura
PEFdocs/schemas/PEF/Schemat_PEF(3)_v2-1.xsd (UBL Invoice-2)Invoice
PEF_KORdocs/schemas/PEF/Schemat_PEF_KOR(3)_v2-1.xsd (UBL CreditNote-2)CreditNote

FA3 is the default schema for FakturaInput (required on DEMO and PROD since February 2026). Pass { schema: 'FA2' } to target the legacy schema.


Quick Start — Build FA3 ​

typescript
import { buildFakturaXml, type FakturaInput } from 'ksef-client-ts';

const faktura: FakturaInput = {
  Naglowek: {
    KodFormularza: {
      systemCode: 'FA (3)',
      schemaVersion: '1-0E',
      value: 'FA',
    },
    WariantFormularza: 3,
    DataWytworzeniaFa: '2026-04-18T10:00:00Z',
  },
  Podmiot1: {
    DaneIdentyfikacyjne: {
      NIP: '5261040828',
      Nazwa: 'Sprzedawca sp. z o.o.',
    },
    Adres: {
      KodKraju: 'PL',
      AdresL1: 'ul. Marszałkowska 1',
      AdresL2: '00-001 Warszawa',
    },
  },
  Podmiot2: {
    DaneIdentyfikacyjne: {
      NIP: '1234567890',
      Nazwa: 'Nabywca S.A.',
    },
    Adres: {
      KodKraju: 'PL',
      AdresL1: 'ul. Krakowska 2',
      AdresL2: '30-001 Kraków',
    },
  },
  Fa: {
    KodWaluty: 'PLN',
    P_1: '2026-04-18',
    P_2: 'FV/2026/04/001',
    P_13_1: '100.00',
    P_14_1: '23.00',
    P_15: '123.00',
    FaWiersz: [
      {
        NrWierszaFa: 1,
        P_7: 'Usługa konsultingowa',
        P_8A: 'godz.',
        P_8B: '1.00',
        P_9A: '100.00',
        P_11: '100.00',
        P_12: '23',
      },
    ],
  },
};

const xml: string = buildFakturaXml(faktura);
// <?xml version="1.0" encoding="UTF-8"?>
// <Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/"
//          xmlns:etd="http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/">
//   <Naglowek>
//     <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
//     ...
//   </Naglowek>
//   ...
// </Faktura>

The polymorphic entry point behaves identically for typed input and returns a Buffer:

typescript
import { serializeInvoiceXml } from 'ksef-client-ts';

const buffer: Buffer = serializeInvoiceXml(faktura);
// Ready to pass to session.sendInvoice(buffer).

Pass { schema: 'FA2' } in options to build against the legacy FA2 namespace.


PEF Invoice ​

PEF invoices are UBL 2.1 documents wrapped with the KSeF UBL namespace set. The builder injects the default xmlns plus five prefixed UBL namespaces (xmlns:ext, xmlns:cbc, xmlns:cac, xmlns:cbc-pl, xmlns:cac-pl) on the root element — callers only supply the body.

typescript
import { buildPefXml, type PefUblInvoiceInput } from 'ksef-client-ts';

const pef: PefUblInvoiceInput = {
  Invoice: {
    'cbc:CustomizationID': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
    'cbc:ID': 'PEF/2026/04/001',
    'cbc:IssueDate': '2026-04-18',
    'cbc:InvoiceTypeCode': '380',
    'cbc:DocumentCurrencyCode': 'PLN',
    'cac:AccountingSupplierParty': {
      'cac:Party': {
        'cac:PartyTaxScheme': {
          'cbc:CompanyID': 'PL5261040828',
          'cac:TaxScheme': { 'cbc:ID': 'VAT' },
        },
      },
    },
    'cac:AccountingCustomerParty': {
      'cac:Party': {
        'cac:PartyTaxScheme': {
          'cbc:CompanyID': 'PL1234567890',
          'cac:TaxScheme': { 'cbc:ID': 'VAT' },
        },
      },
    },
  },
};

const xml: string = buildPefXml(pef);

The builder infers the schema from the root key (Invoice → PEF, CreditNote → PEF_KOR). Pass { schema: 'PEF' } explicitly if the call site needs to document intent.


PEF_KOR CreditNote ​

typescript
import { buildPefXml, type PefUblCreditNoteInput } from 'ksef-client-ts';

const creditNote: PefUblCreditNoteInput = {
  CreditNote: {
    'cbc:CustomizationID': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
    'cbc:ID': 'KOR/2026/04/001',
    'cbc:IssueDate': '2026-04-18',
    'cbc:CreditNoteTypeCode': '381',
    'cbc:DocumentCurrencyCode': 'PLN',
    'cac:BillingReference': {
      'cac:InvoiceDocumentReference': {
        'cbc:ID': 'PEF/2026/04/001',
      },
    },
    'cac:AccountingSupplierParty': {
      'cac:Party': {
        'cac:PartyTaxScheme': {
          'cbc:CompanyID': 'PL5261040828',
          'cac:TaxScheme': { 'cbc:ID': 'VAT' },
        },
      },
    },
    'cac:AccountingCustomerParty': {
      'cac:Party': {
        'cac:PartyTaxScheme': {
          'cbc:CompanyID': 'PL1234567890',
          'cac:TaxScheme': { 'cbc:ID': 'VAT' },
        },
      },
    },
  },
};

const xml: string = buildPefXml(creditNote);

Mixing Invoice and CreditNote in the same input, or passing a schema option that contradicts the detected root, throws KSeFValidationError.


Multi-Rate Interleave (FA3) ​

FA3 XSDs require that per-VAT-rate totals interleave — all values for rate 1 (P_13_1, P_14_1, optionally P_14_1W), then all values for rate 2, and so on — not all P_13_* first followed by all P_14_*. Getting this wrong is the single most common reason FA3 invoices fail XSD validation, and the bug is silent: fast-xml-parser will happily emit lexicographically ordered output that the KSeF server then rejects.

The serializer handles this for you. The input can list the fields in any order; the output is always:

text
P_13_1, P_14_1, P_14_1W, P_13_2, P_14_2, P_14_2W, ..., P_13_6_1, P_15

Example:

typescript
const fa: FakturaInput = {
  Naglowek: { /* ... */ },
  Podmiot1: { /* ... */ },
  Fa: {
    // Declared in whatever order is convenient
    P_15: '246.00',
    P_14_2: '23.00',
    P_13_1: '100.00',
    P_14_1: '23.00',
    P_13_2: '100.00',
    P_14_1W: '0.00',
  },
};

// Emitted order (inside <Fa>):
//   P_13_1, P_14_1, P_14_1W, P_13_2, P_14_2, P_15

This matters because the XSD defines <xsd:sequence> (not <xsd:all>) for these elements — the server rejects reordered input. The serializer has a dedicated regression test covering the exact interleaved sequence; the underlying logic lives in src/xml/order-map.ts alongside the ORDER_MAP constant and the comparePKey natural-sort helper.


Pass-Through Inputs ​

serializeInvoiceXml is deliberately polymorphic. If you already have XML — from a legacy system, from disk, from an earlier serialization step — it is passed through unchanged:

InputBehavior
BufferReturned byte-for-byte
stringUTF-8 BOM stripped, wrapped in a Buffer; no validation, no reordering
Pre-parsed XmlDocument (from parseXml)Rebuilt via the engine; existing order preserved
typescript
import { readFileSync } from 'node:fs';
import { serializeInvoiceXml } from 'ksef-client-ts';

// Pass-through: existing XML is not validated or reformatted.
const existing: Buffer = readFileSync('./invoice.xml');
const buffer: Buffer = serializeInvoiceXml(existing);

// String: BOM stripped if present; otherwise untouched.
const asString: string = readFileSync('./invoice.xml', 'utf8');
const buffer2: Buffer = serializeInvoiceXml(asString);

Pass-through does not validate. If the input is structurally invalid, the serializer will not catch it — the server will. Combine with the invoice XML validator if you want a client-side safety net before submission.


Building XML from the CLI ​

ksef invoice build wraps the serializer for shell-driven workflows. It reads JSON or YAML, infers or accepts an explicit schema, and writes XML to stdout or a file.

bash
# Basic: JSON input → XML on stdout
ksef invoice build invoice.json -o out.xml

# Pipe chain: transform then send
jq -f transform.jq erp.json | ksef invoice build - --schema FA3 | ksef invoice send -

# Both validations: Zod schema + XSD
ksef invoice build invoice.yaml --validate --validate-xsd -o out.xml

# Dry-run: summarise input without emitting XML
ksef invoice build invoice.json --dry-run --json

# Starter skeletons (fillable, pass both validations as-is)
ksef invoice build --template FA3 > skeleton.json
ksef invoice build --template PEF_KOR > credit-skeleton.json

Exit codes: 0 success, 2 malformed input (JSON/YAML parse), 3 structural validation error, 4 XSD validation error, 5 IO error. XSD validation requires the optional libxmljs2 peer dependency.

Schema inference mirrors the serializer: Invoice key → PEF, CreditNote → PEF_KOR, Naglowek.KodFormularza.systemCode → FA2 or FA3, default FA3. Pass --schema to override.


Limitations ​

  • Runtime XSD validation is opt-in, Node.js-only, and requires an optional peer dependency. The CLI's --validate-xsd flag and the validateAgainstXsd helper both depend on libxmljs2, a native add-on. As of v0.10.0, these are exposed via the ksef-client-ts/node entry point and will not work in other runtimes (like Deno or browsers). The package declares libxmljs2 as an optional peer dependency rather than bundling it: it is ~2 MB, native, and historically flaky across Node versions. Install it with npm install --save-optional libxmljs2 when you want XSD-level validation; otherwise the Zod-based XML validator (generated from the same XSDs) is always available.
  • No FA_RR structural builder. The FA_RR v1-1E schema shipped in December 2025. No reference implementation has a structural builder for it yet. FA_RR XML is supported via the pass-through path — build the XML yourself (or with another tool) and pipe it through serializeInvoiceXml to get a Buffer.
  • No round-trip parsing. The serializer is one-way: typed object → XML. Parsing XML back into a FakturaInput is out of scope.
  • No fluent builder DSL. Plain typed objects only. A fluent wrapper can be layered on top later without breaking the object-based API; demand has not materialised yet.
  • No custom form extensions. Unknown top-level elements in known parents are appended in insertion order after the XSD-defined children, but the serializer does not invent namespaces or schemas for form variants it does not know.

Troubleshooting ​

"Faktura input is missing required top-level key(s)" ​

Typed-object inputs are duck-typed against FakturaInput (must contain Naglowek and Fa) or PefUblDocumentInput (must contain exactly one of Invoice / CreditNote). If the input looks like a Faktura but is missing Naglowek or Fa, the error message names the first missing key.

BOM in string input ​

UTF-8 BOM (\uFEFF) at the start of a string input is stripped silently before wrapping in a Buffer. If your source (spreadsheet export, Windows text editor) prepends a BOM, you do not need to strip it yourself.

KodFormularza attributes missing ​

KodFormularza is accepted as a FormCode object { systemCode, schemaVersion, value } and rendered as <KodFormularza kodSystemowy="..." wersjaSchemy="...">FA</KodFormularza>. If you pass a plain string instead, the attributes are not emitted and the XSD will reject the document. Use the typed FormCode shape.

null vs undefined ​

undefined values are omitted from the output. null renders as an empty element (<P_X/>). This matches smekcio TS behavior.

Namespace override ​

The default FA2/FA3 namespaces are checked in and correct for the current KSeF schemas. The fakturaNamespace and etdNamespace options exist for advanced cases (e.g., testing against a draft schema) and should be left at their defaults in production.

Released under the MIT License.