# 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](STRUCTURE.md) — the field-by-field rules a file must satisfy.
- [`dekra-1.0.schema.json`](dekra-1.0.schema.json) — the machine-readable shape.
- [`registry/`](registry/) — the closed value lists, generated from the same code the validator uses.
- [`dkr.xml`](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:

1. **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.
2. **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 walks `documents[]`.
3. **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.
4. **Tamper-evident, not confidential.** The file is plain readable JSON. Signatures prove nothing
   was changed; they do not hide anything.
5. **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

```json
{
  "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](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`:

```json
{ "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`](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/`](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 `.dkr` file 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.
