Open source libraries and tools for the Hungarian Tax and Customs Administration's (NAV) Online Számla system — building invoice data that NAV accepts, reporting it, and pulling invoices issued to you.
Status: pre-1.0. Published on npm (see Installing). The query and pull operations are verified against NAV's live test system; invoice submission has not been exercised there yet, and the API may still change ahead of 1.0 — see Verification status.
📖 API reference: eshton.github.io/open-nav — generated from the source with TypeDoc.
Magyar összefoglaló a lap alján.
The Online Számla interface is not hard because it speaks HTTP. It is hard because of four independent things, each of which reliably costs a day:
requestId + timestamp + signKey plus a concatenation of
per-invoice hashes, and an exchange token that arrives AES-128-ECB
encrypted. Get any byte wrong and NAV answers INVALID_SIGNATURE without
telling you which part was wrong.manageInvoice returns a transaction id,
not a result. The verdict arrives later, per invoice, split across
technical and business validation messages.A library should absorb all four. That is the entire premise of this project.
In scope
InvoiceData, with computed VAT
summaries and exact decimal arithmetic.Out of scope
| Path | What it is |
|---|---|
packages/core |
Types, crypto, XML, payload encoding, exact decimals, validation |
packages/client |
Client for all ten service operations, plus transaction polling |
packages/invoicing |
Printable invoice documents and the NGM data export |
packages/mock-server |
A local stand-in for the service, for testing without credentials |
packages/cli |
open-nav command line tool, built for scripts and agents |
packages/mcp |
MCP server, so an AI agent can use all of the above |
packages/codegen |
Generates the types, schema metadata and fault catalogue from the XSDs |
schemas/ |
Official NAV XSDs and message catalogues, vendored verbatim |
conformance/ |
NAV's own 41 sample documents, used as the golden test corpus |
scripts/ |
Schema vendoring, drift detection, packaging and release |
Every package listed above is implemented. What is deliberately absent is covered under Scope.
The schema is generated, not hand-written. ~700 elements transcribed by hand is how a project like this dies, and how it fails to survive NAV's next interface revision. The XSDs are vendored from NAV's public repository with recorded checksums, and the TypeScript types plus the runtime metadata that drives serialisation, parsing and validation are generated from them. Moving to a future 3.x is then a regeneration and a reviewable diff.
Correctness is demonstrated against NAV's own documents. The 41 official
samples in conformance/ are the test corpus. Anything that fails to survive
a round trip through this library would have been rejected by NAV.
Being explicit, because it matters for anyone considering this in production:
| Area | State |
|---|---|
| Cryptographic primitives | Verified against published SHA-512 and SHA3-512 vectors |
| Request signature construction | Verified against all 11 of NAV's official request samples, and re-verified end to end by an independent implementation in the mock service |
| Schema round trip | Verified against all 41 official NAV sample documents |
| Schema validation | Accepts all 41 official documents; every facet kind covered by tests |
| Business rules | 24 of NAV's 30 sample invoices pass with no findings; the other 6 raise only the arithmetic faults that are wrong upstream |
| Summary reconciliation | Reproduces the summaries of those same 24 samples |
| Client, end to end | The real client drives the mock service over HTTP: token exchange, batch submission, polling and every query |
| Invoice document | All 30 samples render as HTML and as PDF through the native engine; the PDF's own ToUnicode maps are asserted to carry ő and ű, and its text was extracted and read back |
| Document theming | Colour, font, length and page-size values are validated; injection attempts are covered by tests |
| Data export | Every exported document parses back to the invoice it came from |
| MCP server | Driven by a real MCP client, and the built binary driven over stdio |
| Exchange token decryption | Round-trip tested for padded and unpadded tokens; no official vector exists |
| Published packages | Every tarball passes publint and attw, and all six were installed from tarballs into a clean project and exercised end to end |
| Live NAV test system | Query and pull (outbound + inbound) verified against the live test system; invoice submission (manageInvoice) not yet exercised there |
The InvoiceData type is faithful to NAV's schema, which means constructing one
by hand is a lot of nested objects. For the ordinary case — a normal invoice
with priced lines — buildInvoice takes a flat input and fills in the rest,
computing each line's net, VAT and gross and the whole summary so the
arithmetic reconciles:
import { buildInvoice, validateInvoice } from '@open-nav/core';
const invoice = buildInvoice({
invoiceNumber: 'A-2026-001',
issueDate: '2026-03-01',
supplier: {
name: 'My Company Kft',
taxNumber: '12345678',
address: {
postalCode: '1011',
city: 'Budapest',
streetName: 'Fő',
publicPlaceCategory: 'utca',
number: '1',
},
},
customer: {
name: 'Buyer Kft',
taxNumber: '98765432',
address: {
postalCode: '7600',
city: 'Pécs',
streetName: 'Rákóczi',
publicPlaceCategory: 'út',
number: '2',
},
},
lines: [{ description: 'Widget', quantity: 10, unitPrice: 1000, vatPercentage: 0.27 }],
});
const report = validateInvoice(invoice, { operation: 'CREATE' });
It covers the common invoice, not the whole schema — no aggregate or simplified
invoices, product-fee lines or margin schemes. It returns the plain
InvoiceData, so anything it does not cover you set on the result yourself.
To cancel a reported invoice, buildStorno(original, { invoiceNumber }) reverses
it — negated amounts, the invoiceReference and per-line chain numbering NAV
wants — producing a storno the service accepts (verified against NAV's test
system).
Local validation is the point of the library, not a sideline. Two layers run: the schema, generated from NAV's XSDs, and the business rules that are decidable from the document alone.
import { validateInvoice } from '@open-nav/core';
const report = validateInvoice(invoice, { operation: 'CREATE' });
if (!report.valid) {
for (const issue of report.errors) {
console.error(issue.code, issue.path, issue.message, issue.navMessage);
}
}
Every finding carries NAV's own fault code, taken from the error catalogue
NAV publishes and generated into a typed union of all 236 codes with their
Hungarian, English and German wording. A rule cannot cite a code NAV does not
define, and a local failure reads like the rejection it prevents. The handful
of findings we raise that NAV has no code for are marked origin: 'local'
rather than squeezed into an approximate one.
Errors and warnings are kept apart deliberately. A tax number that fails its
check digit is a warning: NAV validates tax numbers against its taxpayer
registry, not arithmetically, and two of the four tax numbers in its own
samples fail the check. Treating that as an error would reject documents the
service accepts — and a validator that flags valid documents gets switched
off. queryTaxpayer is the authority.
npx @open-nav/cli --help
Configuration comes from the environment or a .env file (see
.env.example); credentials are never taken as arguments,
because that would put them in shell history and in agent transcripts.
cp .env.example .env
open-nav config # what is set, secrets masked
open-nav token # are the credentials real?
open-nav validate invoice.xml --pretty
open-nav submit invoice.xml --wait
It is built to be driven by a program as much as by a person: JSON output
whenever stdout is not a terminal, one envelope for every result, meaningful
exit codes (3 invalid document, 4 rejected by NAV, 5 no verdict), and
--describe to emit the whole command surface as JSON so a caller can
discover it. validate and fault need no credentials at all. See
packages/cli/README.md.
Two things an invoicing program must be able to produce, in
packages/invoicing.
A printable invoice, as PDF or HTML, with the phrases the VAT Act requires derived from the data rather than left to a template — fordított adózás, the legal ground of an exemption, which margin scheme applies — each printed with the provision it comes from.
open-nav render invoice.xml --pdf invoice.pdf --theme theme.json
Non-invoice documents from the same data. --type proforma
(díjbekérő), --type delivery-note (szállítólevél, quantities without
prices) and --type receipt (nyugta, the gross total) render the business
documents that are printed but not reported through Online Számla; each carries
a "not a tax invoice" note instead of the NAV-provenance line. In code:
renderProformaHtml, renderDeliveryNoteHtml, renderReceiptHtml, or the
documentType option. (A nyugta's own data reporting, where required, goes
through NAV's online cash-register system, not this one.)
Language and layout. --language hu|en|de (default hu) writes the whole
document — including the VAT-Act phrases derived from the data — in that
language, with the number and date conventions to match. --template standard|compact picks the layout: compact tightens the spacing and type
scale to fit a dense invoice or a short receipt on less paper.
Branding is a JSON theme: template, logo, palette, fonts, page size and
margins, issuer contact lines, footer lines, and a customCss escape hatch.
See examples/invoice-theme.json. Theme values
are validated rather than interpolated into the stylesheet, because a theme is
still input.
No browser required. ő and ű lie outside the encoding the PDF core
fonts use, so a font must be embedded for a Hungarian invoice to spell itself.
The default engine embeds the Roboto that pdfmake bundles — Apache-2.0, full
Latin Extended-A — as a subset with a correct ToUnicode map, so the text stays
searchable and the output is about a quarter the size of a browser's. Bring
your own font with font: { name, normal, bold }.
A browser engine remains available for pixel fidelity to the HTML, locating
Chrome, Chromium or Edge itself or taking a convert function you supply. Its
sandbox stays on by default, since invoice data is input, so as root you
pass --no-sandbox deliberately.
The tax authority data export required by decree 23/2014. (VI. 30.) NGM:
open-nav export invoices/*.xml --out export/ --from 2024-01-01 --to 2024-12-31
Section 13/A(1) of that decree lets the taxpayer use the structure published
for the online invoice data service instead of the decree's own Annex 3, so
the export is produced in invoiceData.xsd — the schema already generated,
validated and round-tripped here. If an auditor asks for the Annex 3
structure specifically, this is not it.
open-nav pull --out inbox/ --from 2025-01-01 --to 2025-12-31
Inbound by default. Files land as
inbox/inbound/2025-03/BESZ-2025-002.xml with an index.json beside them,
and a re-run skips what is already on disk, so an interrupted pull resumes.
Two details the library absorbs, both confirmed from NAV's specification and its own error catalogue:
BAD_QUERY_PARAM_RANGE_EXCEEDED: "Date interval defined by the query
parameters must not exceed 35 days"). A year-long range is split into
windows automatically — asking for one directly is simply refused.BAD_QUERY_PARAM_SUPPLIER_NOT_EXPECTED). The direction decides, and the
mock service enforces both rules so the tests prove it.In code, as a stream rather than a list, so a large range does not have to fit in memory:
import { iterateInvoices } from '@open-nav/client';
for await (const { digest, invoice, xml } of iterateInvoices(client, {
direction: 'INBOUND',
dateFrom: '2025-01-01',
dateTo: '2025-12-31',
})) {
console.log(
digest.invoiceNumber,
invoice.invoiceMain.invoice?.invoiceHead.supplierInfo.supplierName,
);
}
iterateInvoiceDigests walks the summaries alone, which is one request per
hundred invoices rather than one per invoice — enough to survey a period
before deciding what to fetch in full.
packages/mcp exposes all of this over MCP.
claude mcp add open-nav -- npx -y @open-nav/mcp
Five tools need no credentials — validate an invoice, compute its VAT
summary, explain a NAV fault code, render a document, build the data export —
and five more appear once credentials are configured. Two resources —
nav://faults and nav://interface-errors — carry NAV's full error
catalogues, so an agent can read the whole fault space without a tool call.
Tools that cannot work are not registered, so an agent is never offered one
that is guaranteed to fail; a validation failure comes back as a result rather
than an error, because the fault list is the answer; and credentials are read
from the environment, never taken as tool arguments that would pass through a
transcript.
packages/mock-server is a local stand-in
for the invoice service. It matters more than usual here, because NAV's test
system needs a technical user that cannot live in a public repository.
npx @open-nav/mock-server # prints fake credentials to export
It is not a stub. It verifies the request signature the way NAV does —
including the per-operation hashes concatenated in index order for a batch —
rejects a replayed requestId, spends an exchange token exactly once, and
decides each invoice's fate by running it through this project's validator, so
a broken invoice comes back ABORTED with the fault code NAV would report.
The end-to-end tests drive the real client against it over real HTTP, which is what lets signature construction be checked by an independent implementation rather than only against itself.
NAV runs a real sandbox at api-test.onlineszamla.nav.gov.hu — isolated test
taxpayers, no real filings. With a test technical user configured in the
environment, a live smoke test exercises token exchange, a taxpayer lookup, and
a full submit-and-poll round trip; it skips with no credentials, so it never
runs in CI:
NAV_LOGIN=… NAV_PASSWORD=… NAV_SIGN_KEY=… NAV_EXCHANGE_KEY=… \
NAV_TAX_NUMBER=12345678 NAV_SOFTWARE_ID=… \
pnpm --filter @open-nav/client exec vitest run test/live.test.ts
It defaults to the test environment; it never touches production unless
NAV_ENVIRONMENT=production is set explicitly.
Node.js 20.10 or newer. The packages are ESM-only — a CommonJS consumer needs
await import(...).
npm install @open-nav/core @open-nav/client # report and query
npm install @open-nav/invoicing # printable documents, NGM export
npm install -D @open-nav/mock-server # test without credentials
The command line tool and the MCP server are meant to be run, not imported:
npx @open-nav/cli --help
npx @open-nav/mcp # speaks MCP over stdio
Take only what you need: @open-nav/core has no network access at all, so
validating and building invoice data does not pull in a client, and rendering
does not pull in either.
| Package | Depends on | Third-party dependencies |
|---|---|---|
@open-nav/core |
— | fast-xml-parser, @noble/hashes, @noble/ciphers |
@open-nav/client |
core | none |
@open-nav/invoicing |
core | pdfmake |
@open-nav/mock-server |
core | none |
@open-nav/cli |
core, client, invoicing | none |
@open-nav/mcp |
core, client, invoicing | @modelcontextprotocol/sdk, zod |
Every package ships its TypeScript sources next to the compiled output, so stepping into this code in a debugger lands in the real source.
@noble/hashes and @noble/ciphers back the Web crypto provider, which lives
at a separate @open-nav/core/web entry point — so a Node, Bun or Deno
consumer, whose node:crypto already covers everything, imports @open-nav/core
without any @noble/* code reaching its bundle.
workerd's node:crypto has no SHA-3, so install the Web provider once at
startup, before any request:
import { setCryptoProvider } from '@open-nav/core';
import { createWebCryptoProvider } from '@open-nav/core/web';
setCryptoProvider(createWebCryptoProvider());
The packages are ESM-only, so a top-level require('@open-nav/core') throws
ERR_REQUIRE_ESM. Reach them from CommonJS with a dynamic import(), which
returns a promise for the module:
async function main() {
const { validateInvoice } = await import('@open-nav/core');
const { NavClient } = await import('@open-nav/client');
// ...
}
main();
Two notes:
module: "nodenext" (or "node16"),
or it rewrites the import() back into a require() and the error returns.require() an ESM module directly, so
on those versions the bridge above is optional; below them it is required.ESM-only is deliberate, and revisited as the ecosystem moves: a dual
ESM/CommonJS build would double the compiled output and the test surface for a
shrinking audience, and the await import(...) bridge covers the CommonJS
case. See RELEASING.md.
pnpm 10, and:
pnpm install
pnpm verify # format, licences, typecheck, tests, and the packed tarballs
pnpm codegen # regenerate types from the XSDs
Releases are cut from a tag; see RELEASING.md.
The client takes each generated request type minus the header, user and software blocks, which it builds and signs itself. There is no hand-maintained parameter list to fall out of step with the schema.
import { NavClient, waitForTransaction } from '@open-nav/client';
const client = new NavClient({
environment: 'test',
credentials: {
login: process.env.NAV_LOGIN!,
password: process.env.NAV_PASSWORD!,
signKey: process.env.NAV_SIGN_KEY!,
exchangeKey: process.env.NAV_EXCHANGE_KEY!,
taxNumber: '12345678', // the 8 digit core, not the 11 digit number
},
software: {
softwareId: 'MYCOMPANY0000001',
softwareName: 'my invoicing app',
softwareOperation: 'LOCAL_SOFTWARE',
softwareMainVersion: '1.0.0',
softwareDevName: 'My Company',
softwareDevContact: 'dev@example.com',
},
});
const { transactionId } = await client.submitInvoices([{ operation: 'CREATE', invoice }]);
const outcome = await waitForTransaction(client, transactionId);
console.log(outcome.accepted.length, 'stored;', outcome.rejected.length, 'rejected');
Note what submitInvoices does for you: it exchanges a token, encodes each
invoice once and both sends and hashes that same base64 (hashing a
separately serialised copy is a signature failure waiting to happen), and
never retries a submission that reached NAV.
examples/report-invoice.mjs is the whole round trip in
one runnable file — validate, report, poll, render — against the bundled mock,
so node examples/report-invoice.mjs works with no credentials.
See CONTRIBUTING.md. Bug reports that include the NAV error
code and the (redacted) request that produced it are especially welcome — the
error catalogue in schemas/i18n/ is only as useful as the cases we have seen.
MIT — see LICENSE. The vendored NAV schemas and samples under
schemas/ and conformance/ are also MIT, © Nemzeti Adó- és Vámhivatal.
This project is not affiliated with, endorsed by, or supported by NAV.
Nyílt forráskódú könyvtárak és eszközök a NAV Online Számla rendszeréhez: számlaadat előállítása, adatszolgáltatás beküldése, valamint kimenő és bejövő számlák lekérdezése.
A projekt célja, hogy elvegye az interfész négy tényleges nehézségét: az aláírásképzést, a négy névtérre szétosztott sémát, az aszinkron tranzakciókezelést, és az összegek kerekítési és egyeztetési szabályait.
A séma nem kézzel írt: a NAV nyilvános tárolójából származó XSD fájlokból generáljuk a típusokat, így egy későbbi interfészverzió átvezetése újragenerálás és egy átnézhető diff.
Jelenleg fejlesztés alatt áll, éles használatra még nem alkalmas, és a NAV tesztrendszerével még nem volt tesztelve. A projekt nem áll kapcsolatban a NAV-val.