DEKRA interchange format — the standard
Version 1.0. Status: stable, published at https://dekra.online/standard. Readers accept 1.0; producers emit 1.0.
- STRUCTURE.md — the field-by-field rules a file must satisfy.
dekra-1.0.schema.json— the machine-readable shape.registry/— the closed value lists, generated from the same code the validator uses.dkr.xml— the shared-mime-info definition for.dkr/.dkrs(§8).
1. What DEKRA is
A .dkr file is a UTF-8 JSON container that carries the economic content of one or more
business documents, the identifiers a tax authority put on them, and tamper-evidence for both.
It is not a replacement for PEPPOL/UBL. UBL describes a single e-invoice for statutory delivery; DEKRA describes a batch of documents of mixed kinds, for storage and transfer — invoices, credit notes, receipts, waybills and contracts together, in one shape, between two parties who both understand it.
Design goals, in order:
- Self-describing. Every document says what it is (
document_type) and what it promises (profile). A reader never infers the kind from a filename or the context. - Homogeneous shape for heterogeneous content. An invoice and a receipt have exactly the same
key set. Absent information is
null, never an omitted key. One code path walksdocuments[]. - No jurisdiction is the default one. Every field that varies by country — the party's country, the tax model, the register a number comes from, the currency's number of decimal places — is carried explicitly and is never inferred from the producer's own location.
- Tamper-evident, not confidential. The file is plain readable JSON. Signatures prove nothing was changed; they do not hide anything.
- Verifiable in pieces. A single document lifted out of a collection is still verifiable on its own, and the collection separately proves nothing was added or removed.
2. Container
{
"format": "DEKRA",
"version": "1.0",
"profile": "fiscal",
"signature_profile": "issuer",
"signature_algorithm": "Ed25519",
"key_id": "acme-2026-08",
"issuer_key_url": "https://acme.example/.well-known/dekra-keys.json",
"generated_at": "2026-08-31T14:30:00+03:00",
"document_count": 2,
"documents": [ { … }, { … } ],
"extensions": {},
"signature": "…hex…"
}
A single document is a collection of one. There is no separate "one document" file shape, which means an importer has exactly one parser.
| Field | Rule |
|---|---|
format |
Always the literal DEKRA. |
version |
MAJOR.MINOR. MAJOR changes break readers; MINOR only adds keys that may be null. |
profile |
The weakest promise every document in the file keeps — see §5. |
signature_profile |
local or issuer — see §4. |
signature_algorithm |
Determined by the profile. Stated so a future version can change it without guessing. |
key_id |
Which key signed, so a compromised key can be retired without invalidating what came before. |
issuer_key_url |
Where the public key is published as a JWK set. |
generated_at |
ISO-8601 with offset. Informational — not covered by any signature. |
document_count |
Must equal documents.length. Redundant on purpose: a truncated file fails loudly. |
documents |
1…1000 documents, any mix of types. Larger batches use the streaming form, §6. |
extensions |
Namespaced room for what the core format does not carry — STRUCTURE §12. |
signature |
Collection signature, §4. |
3. Documents
Each element of documents[] is one business document, classified by document_type —
invoice, credit_note, debit_note, corrective_invoice, self_billed_invoice, proforma,
receipt, contract, waybill, other. That is the kind of paper, not the accounting treatment.
issuer and recipient always mean the same thing: who wrote the document / who it was written
to. On an invoice you issued, the issuer is your company; on a receipt you were given, the issuer
is the shop. There is one shape and a reader never has to learn a second.
Amounts are always positive; direction is carried by the type. A credit note reduces what is owed because it is a credit note, not because its figures are negative. A sign convention is the fastest route to two implementations disagreeing about a refund.
No field describes the archive holding the document, or the software that wrote the file. One
invoice is incoming to the buyer and outgoing to the seller, and it arrives on a different day
in each of their archives — so a field carrying either would make the same signed bytes assert two
different things depending on who opened it. Which side of a transaction you are on is answered by
comparing issuer and recipient with your own identity, which only the holder can do. Likewise a
producer's own record number: it means something in exactly one database and carries out how many
records that database holds.
Full field list and per-field rules: STRUCTURE.md.
4. Signatures
Two signatures per file, both hex-encoded lowercase.
Document signature (documents[i].signature) covers the canonical JSON of exactly these fields,
in this order:
document_type, document_subtype, document_number,
issuer, recipient, references, items, allowances_charges,
dates, totals, fiscal, source_document
document_type is inside deliberately: without it a receipt could be relabelled an invoice without
breaking anything. fiscal is inside for a stronger reason still — it is the authority's own claim
about the document, and a claim that can be swapped after signing is worth nothing.
Fields not covered:
| Field | Why it is outside |
|---|---|
payment |
Mutable after issue — a document that gets paid must not become "corrupt". |
comment, items[].comment |
Notes, not assertions. These are the fields somebody is most likely to add to after filing, and a record that reads as tampered with because a person wrote down why an amount looks odd is a record nobody will annotate. |
extensions |
An unknown key must never be a reason a file fails to verify — which is exactly what signing extension content would reverse. |
Collection signature (top-level signature) covers:
{"domain":"fiscal-document-container/1","version":"1.0",
"document_count":N,"document_signatures":[ … ]}
i.e. the list of document signatures, not the documents themselves. This is what makes a removed or inserted document detectable even when every remaining document verifies correctly.
No name appears in either signature payload. domain is a frozen identifier describing what
the file is; it is deliberately not the format's name, not the vendor's and not the media type.
Domain separation is a real requirement — without it a signature could be replayed as one under
another format — but it does not need a brand, and putting one there would mean that renaming the
format invalidates every signature ever issued under the old name. The name lives in the format
field, which is data and is outside both signatures, so it can change without a single stored
signature ceasing to verify.
Canonical JSON for both: json_encode with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
no pretty-printing, and object keys sorted, recursively. Array element order is preserved — the
sequence of line items is part of what the document says; the order its fields happen to be written
in is not. The file body itself is pretty-printed; only the signing input is compact.
Sorting is not cosmetic. JSON says nothing about key order and neither do the stores a document
passes through: PostgreSQL jsonb normalises key order on write, so a body signed in the producer's
order and read back in the database's order would hash differently while saying exactly the same
thing. A signature that only verifies until the document is stored is not tamper-evidence.
4.1 Signature profiles
| Profile | Algorithm | Signature length | What it proves |
|---|---|---|---|
local |
HMAC-SHA256 | 64 hex | The file is intact. Not who produced it |
issuer |
Ed25519 | 128 hex | Origin |
A shared-key signature stops proving anything the moment it becomes verifiable: whoever holds the
key to check it holds the key to produce it. Under local a signature is a corruption check between
parties who already trust each other, and presenting one to a third party as authentication is a
misuse the format names rather than permits.
Under issuer the producer signs with an Ed25519 secret key and publishes the public half as a JWK
set at issuer_key_url:
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA",
"kid": "acme-2026-08", "x": "…base64url…" } ] }
A verifier needs the public key and nothing else — no account, no secret, no request to the issuer beyond fetching a static file. Retired keys stay published for as long as files signed with them must still verify: a signature that stops verifying because a key was rotated is indistinguishable, to the reader, from a corrupted archive.
5. Conformance profiles
profile states what a file promises, so a consumer can refuse at the door rather than three fields
into processing.
| Profile | Adds |
|---|---|
core |
Structurally valid. Any document type |
billing |
Both parties named and placed in a country; an issue date; a filable tax breakdown |
fiscal |
Plus the authority's own identifiers |
archive |
Plus an issuer signature and the hash of the file the data was read from |
A collection is only as strong as its weakest document.
6. Large batches
The container form is capped at 1000 documents because it is built and verified in memory. An annual archive is larger than that, and "split it yourself" moves the problem to the holder.
The streaming form (.dkrs, application/vnd.dkr+jsonl) is the same data as JSON Lines:
{"format":"DEKRA","version":"1.0","signature_profile":…,"signature_algorithm":…,"key_id":…,"issuer_key_url":…,"generated_at":…}
{"document_type":"invoice", … ,"signature":"…"}
{"document_type":"receipt", … ,"signature":"…"}
{"profile":…,"document_count":2,"signature":"…"}
One header line, one line per document, one trailer carrying the profile, the count and the
collection signature over the same payload as §4. profile and document_count are in the trailer
rather than the header because neither is knowable before the last document has been read — the
header goes out before the first one has — and a producer that guessed them up front would put two
contradictory claims in one file. The trailer is recognised by carrying document_count; a reader
must not rely on it being the last non-empty line only.
Nothing about the two signature levels changes — which is the point: a document lifted out of a
stream verifies exactly as it would out of a container, and the trailer still proves the set. A
producer writes it without holding the batch in memory; a reader validates document by document
(each one checked as a container of one, so there is exactly one implementation of the document
rules) and checks the set at the end. Findings on a stream point at a line, not at
/documents/i; a stream has three codes of its own — E stream.line_invalid,
E stream.header_missing, E stream.trailer_missing.
7. Conformance
A file is valid when it has no error-level issues. warning-level issues never invalidate a
file — they report lossy or suspicious data the receiver should see.
| Level | Meaning | Examples |
|---|---|---|
error |
The file does not conform. Do not import it. | wrong format, unsupported version, unknown document_type, document_count mismatch, total_without_tax + total_tax ≠ total_amount, a tax breakdown that does not sum, a money field at the wrong scale, broken signature |
warning |
Conforms, but something is off. Import and flag. | missing party name, missing country, empty items, a credit note referencing nothing, an unlisted identifier scheme, line total inconsistent with quantity × price |
The sign of an amount is not checked: the validator accepts a negative money string anywhere
except in payment, where it is E payment.negative. "Amounts are always positive" (§3) is a rule
for producers, and a file that breaks it is wrong without being invalid.
Forward compatibility: a reader MUST ignore unknown keys (a warning, not an error) and MUST refuse an unknown MAJOR version.
The schema at dekra-1.0.schema.json carries shape, presence and
enumerated values. It cannot carry the arithmetic cross-checks or the signatures, so a file that
satisfies the schema is well-formed, not necessarily valid.
Validation is implemented in App\Services\Dekra\DekraValidatorService (validateJson() for
source text, validateFile() for a path, validateArray() for an already-decoded container;
DekraStreamService::validateFile() for the streaming form). The portal runs it in two places: on
every recognised document before it is stored — an error there is logged, because it means the
builder or the prompt is wrong — and on the document page and its API twin, which validate the
single-document container the export would produce, so the issues shown are exactly the ones a
recipient would see. There is no command-line entry point. php artisan dekra:publish --check
confirms the published schema and registries still match the code.
Every issue carries a stable code, an RFC 6901 pointer (/documents/2/totals/total_amount) and,
when the source text was validated, the line and column in the source file, so a failure points
at a place, not at a file. The format carries no display text: the code is the finding, and its
wording is looked up in the reader's language (lang/{locale}/check.php); a code with no wording is
shown as itself.
8. Files, media types and identification
| Container | Stream | |
|---|---|---|
| Extension | .dkr |
.dkrs |
| Media type | application/vnd.dkr+json |
application/vnd.dkr+jsonl |
Systems route on the media type, not on the extension. An extension is a hint to a desktop; a
media type is what an API, a mail gateway and a web server actually read, and a .dkr served as
application/octet-stream is a file everything downstream has to guess about. Servers must be
configured to emit it — deploy/nginx/ carries the types block for this portal.
The +json suffix (RFC 6839) is deliberate: generic tooling that knows nothing about DEKRA still
knows it is looking at JSON, so it can pretty-print, diff, search and validate it with a schema.
Inventing an opaque type would have thrown that away for nothing.
.dkr rather than .dk: the two-letter form is Denmark's country-code top-level domain, so
invoice.dk is turned into a clickable link by mail clients, chat apps and anything else that
autolinks text. A filename that reads as a URL is a filename somebody will click by mistake.
Identification without a filename. A file arriving as a mail attachment or an API body often has
no useful name. Both forms open with "format": "DEKRA" as the first key — the producer emits the
container keys in the order of §2 — so content sniffing works. dkr.xml in this directory is the
freedesktop.org shared-mime-info definition carrying both the glob and the magic; installing it
teaches a Linux desktop and anything using xdg-mime what these files are.
Registration in the IANA vendor tree (vnd.) requires no standards process — the vendor tree exists
for exactly this — and is worth doing before the first outside integrator, not after.
9. Registries and change
Every closed list — document types, reference types, tax types and categories, fiscal schemes,
identifier schemes, allowance reasons, profiles — is published under registry/ and
generated from the code the validator enforces. dekra:publish --check fails when the two drift,
which is how a published standard stays honest about what its reference implementation does.
Some lists are deliberately open: payment methods, entity types, exemption reasons, and the document's own wording everywhere. A format that stores what a document says cannot refuse a document because the world used a word a list had not heard of.
An identifier scheme outside the registry is a warning, not an error: there are more company registers than any list holds, and refusing a document because a country is missing from ours would be the provincialism the scheme qualifier exists to remove.
10. Converting to PEPPOL
An invoice converts to PEPPOL BIS Billing 3.0 (UBL 2.1) —
App\Services\Peppol\DekraPeppolConverter, CONVERTIBLE. Every other type is refused, including
the other billing types: a credit note is a different UBL document (CreditNote, type code 381),
not an Invoice with a flag, and this converter does not produce one yet. Emitting an Invoice
for anything but an invoice would be a well-formed file that lies about what it is. Receipts are
refused for a second reason — BIS needs a named buyer with a registration number and an address,
and a till receipt prints none of it, so no amount of correcting would make one pass.
Conversion produces a document, not a transmission. Sending over PEPPOL requires an accredited Access Point, which this portal does not have.
What the mapping does that is not obvious:
| DEKRA | UBL | Why |
|---|---|---|
issuer / recipient |
AccountingSupplierParty / AccountingCustomerParty |
One fixed meaning, so no role juggling |
party.country |
Country/IdentificationCode |
Read, never guessed. An absent country produces no code and a reported gap, rather than a default that silently decides the document's tax treatment |
identifiers[] |
PartyIdentification, PartyTaxScheme, EndpointID |
The register is in the scheme, so a US or Indian party maps as readily as a Latvian one |
totals.discount_percent |
folded into each line | BIS validates sum(lines) = LineExtensionAmount; a discount left at document level makes the two disagree by exactly the discount |
items[].taxes[] |
one ClassifiedTaxCategory |
BIS models one recoverable consumption tax per line. A withholding or excise entry is reported as a gap rather than mapped into the VAT slot, which would state something false about what the buyer owes |
payment.paid_amount |
PrepaidAmount |
A partly paid invoice then shows the right PayableAmount |
comment |
Note |
The document's own note, verbatim, and nothing else — no type label prepended |
items[].comment |
not mapped | BIS has no per-line note the holder's remark would honestly fit |
endpoint_id / endpoint_scheme |
EndpointID with schemeID |
Used as printed. Only when the document states none does the converter fall back to the VAT or registration number under the party's own country's EAS scheme — a routing convention of the converter, never written back into the signed body |
problems() also refuses a currency other than EUR: the converter does not carry tax-currency
conversion, and a non-EUR invoice would map to a message whose VAT totals it cannot vouch for.
problems() lists which BT/BG fields are missing, and the document page shows them next to the
download — a file that will be rejected is worth knowing about before it is sent, not after.
11. What DEKRA deliberately does not do
- No encryption. Anything private must not be put in a
.dkrfile that leaves a trusted channel. - No attachments. The original PDF or photo stays where it is; DEKRA carries the data and the original's hash, not the scan.
- No accounting semantics. Ledger accounts and deduction rights are the receiver's own rules.
- No transport. Producing the file and delivering it are separate problems.
- No tax calculation. DEKRA records the tax a document states. It does not decide what should have been charged — that depends on facts no document carries.