DEKRA

DEKRA 1.0 — structure and rules

Normative companion to STANDARD.md. Everything here is enforced by App\Services\Dekra\DekraValidatorService; every rule names the code the validator reports, so a finding in the console can be traced straight back to a line in this document.

The machine-readable form of this document is dekra-1.0.schema.json, generated from the same constants the validator enforces. It carries shape, presence and enumerated values. It cannot carry the arithmetic cross-checks or the signatures — a file that satisfies the schema is well-formed, and only the validator can say it is valid.

Conventions used below:

  • required — the key must be present. A key whose value may be null is still required to be present: absent keys are an error, null values are not. This is what keeps every document the same shape regardless of type, and what lets null mean "the document does not say" instead of being indistinguishable from "nobody wrote this field".
  • decimal — an exact decimal string ("12.10"), never a JSON number. See §10.
  • money — a decimal string with exactly as many places as the currency's ISO 4217 minor unit.
  • percent — a decimal string, 2 places, 0.00100.00.
  • Levels: E = error (file invalid), W = warning (file valid, data suspicious).

Closed value lists live in registry/ and are named per field below.


1. Container (root object)

Key Type Req. Rule Codes
format string Exactly DEKRA E key.missing, E key.type, E key.empty, E format.unknown
version string ^\d+\.\d+$; a supported version (currently 1.0) E version.malformed, E version.unsupported
profile string Conformance profile, §9. From registry/profiles.json E profile.unknown
signature_profile string local or issuer, §8 E signature_profile.unknown
signature_algorithm string Must be the algorithm the declared profile uses E signature_algorithm.mismatch
key_id string|null Which key signed. Absent under issuer → W key_id.missing E key.type
issuer_key_url string|null Where the public key is published (§8) E key.type
generated_at string Parseable timestamp; SHOULD be ISO-8601 with offset E generated_at.format, W generated_at.loose
document_count integer Must equal documents.length E document_count.missing, E document_count.mismatch
documents array 1…1000 objects (§11 for larger batches) E documents.missing, E documents.empty, E documents.too_many, E document.not_object
extensions object|null §12 E extensions.type, E extensions.namespace
signature string 64 or 128 lowercase hex; must verify (STANDARD §4) E signature.missing, E signature.malformed, E signature.collection_invalid

Any other top-level key: W key.unknown. Readers must ignore unknown keys, never fail on them.

Whole-file failures: E file.unreadable, E json.invalid, E json.not_object.


2. Document (documents[i])

Key Type Req. Rule Codes
document_type string From registry/document-types.json E document_type.missing, E document_type.unknown
document_subtype string|null Sender-defined refinement, free text E key.missing_nullable, E key.type
document_number string|null May be null — a shop receipt often has no usable number E key.type, W document_number.absent
issuer object Party block, §3. Who wrote the document see §3
recipient object Party block, §3. Who it was written to see §3
references array 0…n references to other documents, §4 see §4
items array 0…n item objects, §5. May be empty E items.missing, W items.empty
allowances_charges array 0…n document-level allowances and charges, §6 see §6
totals object §7 see §7
dates object §7.3 see §7.3
payment object §7.4 see §7.4
fiscal object|null What an authority stamped on it, §7.5 see §7.5
source_document object|null Fingerprint of the file it was read from, §7.6 see §7.6
comment string|null A note about the document as a whole E key.type
extensions object|null §12 see §12
signature string 64 or 128 lowercase hex; must verify E signature.*

Any other key inside a document: W key.unknown.

2.1 Document types

invoice, credit_note, debit_note, corrective_invoice, self_billed_invoice, proforma, receipt, contract, waybill, other.

A credit note is a type, not a subtype. As free text no consumer could tell a refund from a charge, so every sum over an archive was too high by the value of its returns.

Amounts are always positive. Direction is carried by the type: a consumer summing an archive negates credit_note (DekraRegistry::CREDIT_TYPES) and nothing else. A sign convention is the fastest way to get two implementations disagreeing about a refund. The rule binds the producer; the validator does not check the sign of an amount outside payment (§7.4), so a negative figure elsewhere is a wrong file that still validates.

2.2 What a document never describes

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: a field carrying either would make the same signed bytes assert two different things depending on who opened the file. Which side of a transaction you are on is answered by comparing issuer and recipient with your own identity — a question only the holder can answer. Likewise a producer's own record number: it means something in exactly one database and carries out how many records that database holds.


3. Party (issuer, recipient)

The same key set for both sides and both directions — this is the rule that lets a reader treat invoices and receipts identically.

Key Type Req. Rule
name string|null Legal name. Empty → W party.name_missing
entity_type string|null Legal form as printed (SIA, GmbH, Ltd, LLC, …). Free text: the world's legal forms are not a list anyone can close
country string|null ISO 3166-1 alpha-2. Absent → W party.country_missing; malformed → E party.country_invalid
identifiers array §3.1
is_vat_payer bool|null null = unknown, not "no"
address string|null Address as printed, one line
address_parts object|null { street, city, postal_code, subdivision }, each string|null, all four required to be present
postal_address string|null Only when it differs from address
email string|null Malformed → W party.email_invalid
phone string|null As stored, not normalised
endpoint_id string|null Electronic address for e-invoicing (PEPPOL EndpointID). Only when the document states one
endpoint_scheme string|null Four-digit PEPPOL EAS code. Missing while endpoint_id is set → W party.endpoint_scheme_missing; not four digits → W party.endpoint_scheme_invalid
bank object { name, account, swift }, each string|null, all three required to be present

Codes: E party.missing, E party.key_missing, E party.type, E party.type_bool, E party.country_invalid, E party.bank_missing, E party.bank_key_missing, E party.bank_type, E address_parts.type, E address_parts.key_missing, E address_parts.type_field, E identifiers.missing, W party.name_missing, W party.country_missing, W party.email_invalid, W party.endpoint_scheme_missing, W party.endpoint_scheme_invalid.

country is never inferred. Not from the address text, not from the document's language, not from the producer's own location. Without it a consumer cannot decide which tax rules the document falls under, and a guessed country decides them wrongly while looking exactly as confident as a read one. subdivision is not decoration either: in the United States, Canada, Brazil and India the rate is decided by the subdivision rather than the country.

3.1 Identifiers (identifiers[k])

Key Type Req. Rule
scheme string VAT, GLN, LEI, DUNS, or <ISO 3166 alpha-2>:<local abbreviation> (LV:REGN, US:EIN, IN:GSTIN, BR:CNPJ)
value string As printed

Codes: E identifiers.missing, E identifier.not_object, E identifier.key_missing, E identifier.type, E identifier.scheme_malformed, W identifier.scheme_unknown.

Why a list and not two fields. registration_number plus vat_number is a European company's paperwork written into a format that claims to be international: it holds no EIN, no TIN, no GSTIN, no ABN, no CNPJ, no UEN, no CUIT, and no company that has three at once. And an unqualified string says nothing about whose register it came from — short numbers collide across countries, and a consumer matching one against its own database is matching two different things.

A scheme outside registry/identifier-schemes.json is a warning, not an error: there are more registers in the world than in that list, and refusing a document because a country is missing from it would be exactly the provincialism the qualifier exists to remove. The shape is still required.

Empty strings are not allowed as "unknown". A producer must emit null. The validator enforces this on identifiers[].scheme and value (E identifier.type) and reports an empty name (W party.name_missing); an empty string in any other party field passes the type check and is simply a wrong file.

Neither party may be inferred. Both blocks describe what the source document says. A producer that fills recipient from whoever happens to be filing the document is writing a claim the paper does not make into a signed record — and a cash receipt names no buyer at all, so "not stated" has to be representable. An all-null party block is the correct output in that case.


4. References (references[k])

Key Type Req. Rule
type string From registry/reference-types.json
document_number string The referenced document's number, as printed
issue_date string|null YYYY-MM-DD
signature string|null The referenced document's DEKRA signature, when the producer holds it

Codes: E references.missing, E reference.not_object, E reference.key_missing, E reference.number_required, E reference.type_unknown, E reference.date_format, E reference.signature_malformed, W references.correction_unlinked.

A credit_note, debit_note or corrective_invoice with no reference of type corrects or replaces gets W references.correction_unlinked: the receiver can book the amount and can tie it to nothing.

signature is the strongest field in this block. A number can be reused, reissued or typed wrongly; a signature pins the reference to one exact prior version. With it, a chain of corrections is verifiable by anyone holding the documents, without access to the issuer's database — which is what an archive needs and what a bare document number cannot give.


5. Item (items[j])

Key Type Req. Rule
description string Empty → W item.description_empty
quantity decimal 4 places
unit string UN/ECE Recommendation 20 code, the list PEPPOL uses in unitCode (H87, KGM, LTR, HUR, …). 1–3 chars, [A-Z0-9]
unit_name string|null The unit as a person reads it: the word the document printed (gab., vnt., tk, шт), or the producer's own short name for the code. Never the code repeated
price decimal Unit price excluding tax, 4 places
discount_percent percent Line-level discount
taxes array 0…n tax entries, §5.1
total money Line net: quantity × price × (1 − discount_percent/100). Excludes tax. Excludes the document-level discount.
comment string|null A note about this line

Codes: E item.not_object, E item.key_missing, E item.type, E item.unit_code, E key.missing_number, E key.not_decimal, E key.scale, E percent.range, W item.description_empty, W item.total_mismatch, W item.unit_unknown, W key.unknown.

item.total_mismatch is a warning, tolerance ±0.02: sources round differently, and an incoming document's lines come from a parser. The total semantics above are nevertheless normative — a producer that puts a tax-inclusive figure there is producing a wrong file, and the warning is how it shows up.

Why the unit is a code. A document prints gab., vnt., tk, шт or pcs and means the same thing every time; stored as printed, that fact is available to a human reader and to nobody else — quantities cannot be summed across two documents, and a billing message cannot be produced without guessing. So the code is normative and a readable word is kept beside it: the document's own where it printed one, because a producer does not get to overwrite what the paper said. A malformed unit is an error; a well-formed code outside a reader's table is a warning, since Rec 20 has some 1800 codes and no implementation carries all of them. unit_name is checked for type only — a producer that repeats the code there is not caught, only wrong.

comment is a note, not data. It is where a person writes what the paper does not say — why a quantity looks wrong, which order it belongs to, that the price was agreed by phone. Free text, no rule beyond its type, and a reader must not parse it. Like the document's own comment it is outside the signature (STANDARD §4), so a filed document can be annotated without becoming unverifiable.

5.1 Tax (items[j].taxes[t])

Key Type Req. Rule
type string From registry/tax-types.json: VAT, GST, SALES, EXCISE, WITHHOLDING, OTHER
category string UNTDID 5305, from registry/tax-categories.json
rate percent The rate, not the amount
amount money Tax on this line at this rate
jurisdiction string|null CC or CC-SUB (ISO 3166-1 / 3166-2) — which authority it is owed to
tax_type_name string|null What the document called it, when type is OTHER
exemption_reason string|null Free text, in the document's own words
exemption_code string|null The legal reference, when the document states one

Codes: E taxes.missing, E tax.not_object, E tax.key_missing, E tax.type_unknown, E tax.category_unknown, E tax.zero_category_rate, E tax.jurisdiction_invalid, E tax.type_field, W tax.exemption_unexplained.

Why an array. One rate per line is a European assumption. A Canadian line carries GST and a provincial tax at once; a Brazilian line carries ICMS, IPI, PIS and COFINS with different bases; a United States line carries state, county and city rates that are not a VAT at all and are not recoverable by the buyer. A single rate field cannot express any of them, and a format that requires one is a European format wearing an international name.

A category meaning "no tax is due" (Z, E, AE, K, G, O) with a rate above zero asserts two opposite things at once: E tax.zero_category_rate. A category of E, AE or O with neither exemption_reason nor exemption_code gets W tax.exemption_unexplained — a tax authority asks for the provision, and a receiver cannot invent it.


6. Allowances and charges (allowances_charges[c])

Key Type Req. Rule
is_charge bool true = a charge, false = an allowance
amount money The absolute amount
base_amount decimal|null What percent was applied to
percent decimal|null When the document expressed it as a percentage. Checked as a decimal only — neither the 2-place scale nor the 0…100 range of a percent is enforced here
reason string|null The document's own wording
reason_code string|null UNTDID 5189 / 7161, from registry/allowance-reasons.json
taxes array Tax on the allowance or charge, §5.1. The key must be present (E charge.key_missing); a null value is not reported — the §5.1 checks run only when it is an array

Codes: E allowances_charges.missing, E charge.not_object, E charge.key_missing, E charge.type_bool, E charge.type_field, E key.not_decimal, E key.scale, W charge.reason_unknown.

Delivery, packing and insurance are not goods. While they had to be written as line items they distorted both the item list and the tax base, and an absolute discount had to be converted into a percentage, losing what was printed. An unlisted reason is stated in reason with a null reason_code rather than forced into the nearest match.


7. Totals, dates, payment, fiscal, source

7.1 Totals (totals)

Key Type Req. Rule
currency string ISO 4217, three uppercase letters. Decides the scale of every money field in the document
tax_currency string|null Only when the tax is owed in a different currency. ISO 4217 shape is checked (E totals.tax_currency); that it differs from currency is not
exchange_rate decimal|null The rate actually applied
exchange_rate_date string|null YYYY-MM-DD
discount_percent percent Document-level discount, applied on top of line discounts
total_without_tax money Sum of line nets after the document discount
tax_breakdown array §7.2
total_tax money Total tax
total_tax_in_tax_currency decimal|null The same figure in tax_currency. Checked as a decimal only — its scale would come from tax_currency, and the validator does not apply it
total_amount money total_without_tax + total_tax

Two cross-checks:

  • E totals.mismatch|total_without_tax + total_tax − total_amount| > 0.01. Pure self-consistency, independent of any source convention, therefore an error.
  • W totals.items_mismatch — the line nets after the document discount disagree with total_without_tax by more than 0.02. A warning: rounding and discount conventions legitimately differ between sources.

tax_currency set with no exchange_rate → W totals.exchange_rate_missing. An amount in another currency with no rate is a claim nobody can check and nobody can reproduce.

Other codes: E totals.missing, E totals.key_missing, E totals.currency, E totals.tax_currency, E totals.exchange_rate_date, E key.missing_number, E key.not_decimal, E key.scale, E percent.range. The currency is read first: it sets the scale every money field in the document is checked against, and a document with no readable currency is checked at 2 places.

7.2 Tax breakdown (totals.tax_breakdown[b])

{ type, category, rate, jurisdiction, taxable_amount, tax_amount } — the same vocabulary as §5.1.

The rows must sum to total_tax within 0.01, or E tax_breakdown.mismatch. A document with tax and an empty breakdown is E tax_breakdown.empty. Each row's type, category and rate are checked with the §5.1 rules and codes; the block's own are E tax_breakdown.missing, E tax_breakdown.not_object, E tax_breakdown.key_missing. The sum is only compared when every row parsed — a row with an unreadable amount produces its own error, not a second one for the total.

This is what a tax return is filed from. A document with lines at 21 % and 12 % cannot state its base per rate with a single total: it cannot be filed, cannot be checked, and cannot be converted to a billing message without the reader re-deriving the split and hoping it matches.

Where a producer cannot honestly decompose the tax — no lines, or lines whose rates do not explain the printed total — it emits one row at the effective rate covering the whole base. That keeps the rate and the amount agreeing with each other and with the totals. Bending one group's amount to close the gap would leave a row whose rate contradicts its own figure.

7.3 Dates (dates)

Key Type Req. Rule
invoice_date string YYYY-MM-DD, a real date, must not be null
tax_point_date string|null When the tax became due. null = the same as invoice_date
supply_date string|null When the goods or services were supplied
due_date string|null Earlier than invoice_date → W dates.due_before_invoice
period_start string|null For a periodic document
period_end string|null Before period_start → E dates.period_reversed

Dates are calendar dates without a time zone. Anything with a time in it is E dates.format. Codes: E dates.missing, E dates.key_missing, E dates.format, E dates.invoice_date_required, E dates.period_reversed, W dates.due_before_invoice.

tax_point_date is separate because in most tax regimes it is legally distinct from the date the document was written, and it is the one that decides which period the document is filed in.

Both dates are dates printed on the document. When a copy arrived somewhere is not one: two archives holding the same invoice disagree about that and both are right.

7.4 Payment (payment)

Payment state is deliberately outside the signature — a document that gets paid must not become "corrupt".

{ method, status, paid_amount, remaining_amount }. method and status are free text as stored by the source, not code lists. Amounts are money, ≥ 0 — the one place the validator checks a sign (E payment.negative). paid_amount exceeding total_amount by more than 0.01 → W payment.overpaid.

Codes: E payment.missing, E payment.key_missing, E payment.type, E payment.negative, E key.missing_number, E key.not_decimal, E key.scale, W payment.overpaid.

7.5 Fiscal (fiscal)

null when the document is not fiscal. Otherwise:

Key Type Req. Rule
scheme string From registry/fiscal-schemes.json
id string|null The identifier the authority issued
issued_at string|null When the authority registered it
device_id string|null Fiscal register / cash register / fiscal memory serial
sequence string|null Receipt or shift number
qr string|null The QR payload exactly as printed
verification_url string|null Where the authority verifies it
authority_signature string|null The authority's own signature, verbatim
previous_hash string|null For chained regimes (PT, ES)

Codes: E fiscal.type, E fiscal.key_missing, E fiscal.type_field, E fiscal.scheme_unknown, E fiscal.empty, E fiscal.issued_at_format.

A scheme naming neither an id nor a qr names nothing: E fiscal.empty.

This block is inside the signature, and nothing in it is normalised. It is the only part of a DEKRA body that is not the producer's reading of paper but another party's claim about the document — and it is the only thing that lets a holder prove the document against the authority that issued the identifier. A "tidied up" Portuguese QR payload or a reformatted Brazilian access key no longer verifies against the issuer, which destroys the one property that makes a stored document evidence rather than a transcript.

7.6 Source document (source_document)

null, or { hash_algorithm, hash, media_type, byte_size, filename } — every key present, byte_size an integer or null, the rest a string or null. hash is lowercase hex, 32–128 characters.

Codes: E source_document.type, E source_document.key_missing, E source_document.type_field, E source_document.algorithm_missing, E source_document.hash_malformed.

Not an attachment — DEKRA carries data, not scans. But without the fingerprint the .dkr record and the file it was read from are two unrelated objects that happen to share a folder, and an archive cannot show that one came from the other. A hash with no algorithm named is not verifiable: E source_document.algorithm_missing.


8. Signature profiles

Profile Algorithm What it proves
local HMAC-SHA256 The file has not been corrupted. Not origin: whoever holds the key to verify it holds the key to forge it
issuer Ed25519 Origin. A verifier needs only the public key, published as a JWK set at issuer_key_url

The profile is written into the file rather than left for a reader to infer from the algorithm, because the difference between "this file is intact" and "this file came from them" is the whole question a signature is asked, and a reader that guesses wrong guesses in the dangerous direction.

key_id names the key so a compromised one can be retired without invalidating everything signed before it. A retired public key stays published for as long as files signed with it must verify.


9. Conformance profiles

Profile Adds
core Structurally valid. Any document type; no promise beyond the shape
billing A billing type; both parties named and placed in a country; an issue date; a non-empty tax breakdown
fiscal billing (or a receipt), plus a fiscal block with a scheme and an identifier or QR
archive fiscal, plus a source_document hash

A collection is only as strong as its weakest document. A batch claiming fiscal with one document inside carrying nothing from an authority would be exactly the false assurance the profiles exist to prevent.


10. Numbers

Money and quantities are decimal strings, never JSON numbers.

A JSON number is an IEEE-754 double: it cannot represent 0.10 exactly. A format whose entire purpose is proving an amount did not change cannot store that amount in a type that changes it. The danger is not theoretical — it appears the first time an implementation in another language parses and re-serialises a file, and it appears as a signature that no longer verifies.

Scale comes from the currency, not from a constant. "Two decimal places" is a statement about the euro: JPY and KRW have no minor unit, KWD, BHD, OMR and TND have three. A money field must carry exactly the number of places ISO 4217 gives its currency — E key.scale otherwise, in both directions. Quantities carry 4 places, unit prices 4, percentages 2.

Rounding is half-up, applied at the scale of the field being written. The rule is stated because without it two implementations differ by a cent and the signature stops matching for a reason neither can see.


11. Rules a producer must follow

  1. Emit every key, using null for unknown — never omit and never use "".
  2. items[].total is net, pre-document-discount. Getting this wrong is the single most common way to produce a file that validates as "warnings only" but means something else. The second is a negative amount: outside payment the validator does not check the sign, so a refund written as -100.00 instead of as a credit_note produces a file that validates and sums wrongly.
  3. Sign after building, never edit a signed field afterwards. The exceptions are payment, generated_at, the comments and extensions, which are outside both signatures by design.
  4. Make the breakdown add up. A file whose tax_breakdown does not sum to total_tax cannot be filed by its receiver.
  5. Never infer a country, a recipient or an electronic address. All three look exactly as confident when guessed as when read.
  6. One file, one collection signature. Concatenating two .dkr files is not a valid file.
  7. Cap at 1000 documents in the container form; larger batches use the streaming form (STANDARD §6), which carries the same two signature levels without holding the batch in memory.

12. Extensions

extensions is an object whose keys are reverse-DNS namespaces (^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z0-9-]+)+$pl.gov.ksef, br.gov.nfe). It exists at the container level and per document.

Without namespaces, "a reader ignores unknown keys" means that sooner or later Poland and Brazil add their own field and name it the same thing. With them a collision is impossible.

Extensions are outside both signatures. An unknown key must never be a reason a file fails to verify — that is the whole point of the ignore-unknown-keys rule, and signing extension content would quietly reverse it.

Codes: E extensions.type, E extensions.namespace.

13. Rules a reader must follow

  1. Reject an unknown MAJOR version. Accept an unknown MINOR — it only adds keys that may be null.
  2. Ignore unknown keys. They are a warning at most, never a reason to refuse a file.
  3. Verify both signature levels before trusting anything, and check signature_profile: a local signature is a corruption check and proves nothing about who produced the file.
  4. Read the profile before the content. If you need fiscal and the file says billing, refuse at the door.
  5. Treat the codes as authoritative. The format carries no display text: translate the codes.
  6. Do not parse the comments. Free text written by a person, in whatever language they used.
  7. Do not infer accounting treatment. DEKRA carries no ledger account; book the document under your own rules.