If you invoice out of Odoo in Spain — or you plan to before 2027 — VeriFactu is a legal obligation with a fine attached, and it reaches your billing software whether or not you invoice from Odoo daily. Here is how the hash chaining each record to the previous one is computed, the three calculation mistakes we made ourselves, and what a well-built system does when the AEAT rejects a record.
Key point: VeriFactu mandates neither e-invoicing nor a new invoice format. It requires the system that issues each invoice to generate, as it is issued — or immediately before, never after —, a tamper-proof record chained to the previous one through a hash. An overnight job does not comply.
What exactly is VeriFactu?
VeriFactu is the popular name for the technical requirements of the Reglamento de Sistemas Informáticos de Facturación (RRSIF), approved by Royal Decree 1007/2023, which develops article 29.2.j) of Spain's General Tax Law. It is not the Crea y Crece B2B e-invoicing mandate: that is a separate obligation, with its own timeline, about invoice format. VeriFactu governs how your software behaves internally.
There are two modes. Under VERI*FACTU, your Odoo sends every record to the AEAT in near real time and the invoice carries a QR code and the «VERI*FACTU» label. Under non-VERI*FACTU the record is generated and signed just the same, but kept locally.
The chained hash: what it is and how it is computed
The hash is a 64-character hexadecimal SHA-256 condensing a handful of invoice fields plus the previous record's hash. That «plus the previous hash» is the mechanism: if someone edits invoice 4,000, then 4,001 stops adding up, and every record after.
Which fields go in, and in which order, is set by article 13 of Orden HAC/1177/2024. A billing record takes eight:
- Issuer tax ID (IDEmisorFactura), no country prefix.
- Invoice number and series (NumSerieFactura).
- Issue date, in DD-MM-YYYY (FechaExpedicionFactura).
- Invoice type: F1, F2, F3 or R1 to R5 (TipoFactura).
- Total tax (CuotaTotal).
- Invoice total (ImporteTotal).
- Previous record's hash (Huella).
- Generation timestamp with zone, ISO 8601 (FechaHoraHusoGenRegistro).
A cancellation record uses five, and there is the first trap: those fields are called IDEmisorFacturaAnulada, NumSerieFacturaAnulada and FechaExpedicionFacturaAnulada, and the field name is part of the computation.
The BOE says which fields, not how: section 13.2 defers to the technical document on the AEAT's site, version 0.1.2 of 27 August 2024. That is where almost everyone gets lost, because the values are not glued one after another: they are concatenated as fieldName=value pairs joined by &, trimming the leading and trailing spaces of each value and with trailing zeros in numeric fields carrying no weight. The string goes in UTF-8, SHA-256 is applied, and the output is uppercase hexadecimal, 64 characters. On the first record the previous hash is present but empty:
IDEmisorFactura=89890001K&NumSerieFactura=12345678/G33&FechaExpedicionFactura=01-01-2024&TipoFactura=F1&CuotaTotal=12.35&ImporteTotal=123.45&Huella=&FechaHoraHusoGenRegistro=2024-01-01T19:20:30+01:00 SHA-256 → 3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60
Our hash function returns those exact values for the document's two billing records: 3C464DAF… and F7B94CFD…. That is the only check worth anything: your hash being 64 characters proves nothing; returning the published hash for the published inputs does.
The three calculation mistakes we have seen (all three ours)
All three give an impeccable-looking hash and a self-consistent chain; what does not match is the AEAT's computation:
| How it was computed | Hash you get | Why it happens |
|---|---|---|
| Values glued together, no field names, no separators | 9689947A… | Reading only the BOE, you see fields, not labels. Fixed 13-06-2026. |
| First-record seed: 64 zeros | BCA62A69… | The AEAT example disproves it: the label is left empty. Fixed 15-06-2026. |
| Issue date in ISO form (2024-01-01) | 048C3862… | Issue date in DD-MM-YYYY, generation in ISO: two formats in one string. Fixed 09-07-2026. |
The third was not a misreading but a value changing type along the way: when importing historical data, lines travel through a JSON field and a date object comes back as the string «YYYY-MM-DD». Every import was chained wrong and then failed its own integrity check, which re-reads the date from the real column.
The second had a dedicated test — «the first record carries an empty previous hash» — and it was green. It tested nothing: it never posted an invoice, so the genesis record did not exist inside it and the assertion sat behind an «if the genesis exists» guard that never held. On top of that it expected 64 zeros: had it run, it would have failed.
And this does not announce itself: the AEAT document states that when the reported hash does not match the one it computes, the record is flagged as «accepted with errors». It is not a rejection, so unless your system watches for that status no alarm goes off.
Rejections, corrections and resubmission
The AEAT can accept the record, accept it with errors, or reject it. In our module every record is born chained — it already has its number, hash and previous hash — and moves on to submitted, with the XML envelope stored; accepted, with its CSV; rejected, with the error code and full response; or retrying and dead letter.
The queue retries with growing back-off — 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 12 hours — and on the sixth attempt it moves to dead letter. An HTTP 429 is not that record's error: it stops the whole company's queue until the wait expires.
Resubmission is the counter-intuitive part: it recomputes nothing. The same envelope is rebuilt from the same record, with the same number and the same two hashes. The chain was sealed when the invoice was issued and does not depend on the AEAT answering; recomputing the hash on resubmission would break everything after it because of a network outage.
If the content is what is wrong, it is not edited either. The AEAT schema carries two flags: Subsanacion (S/N), marking that this record corrects an earlier one, and RechazoPrevio, which on a billing record takes N, S and X — X is the record that exists in your system but the AEAT never had, the case of moving from non-VERI*FACTU to VERI*FACTU —; on a cancellation it only takes S or N.
And if the invoice is void, it is not deleted: a cancellation record is appended with its own hash. Deleting a record raises an error saying precisely that, and the four chain fields are write-protected, from code too. A direct UPDATE leaves the chain broken and visible, and the event log — 24 types, including restoration from backup and attempted alteration — is append-only.
Deadlines and penalty: from when, and how much
These are the deadlines in force after Royal Decree-Law 15/2025:
| Who / scenario | Date or amount | Legal basis |
|---|---|---|
| Corporate Income Tax payers | Systems adapted before 1 January 2027 | RD 1007/2023, art. 3.1.a) and final provision 4 |
| Self-employed and the other art. 3.1 taxpayers | Systems operational before 1 July 2027 | RD 1007/2023, final provision 4 |
| Producers and resellers of billing systems | Already in force: adapted product, and a producer's responsible declaration per version | RD 1007/2023, final provision 4 and art. 13 |
| Using a system without the required certification | 50,000 EUR per tax year | Art. 201 bis.2 and .4 LGT |
| Producing or selling a non-compliant system | 150,000 EUR per tax year with sales and per system type | Art. 201 bis.1 and .4 LGT |
Article 201 bis LGT sets no ceiling, just fixed fines, and the infraction is the system itself: the AEAT does not need to find a manipulated invoice. If the only thing missing is the required certificate — under the RRSIF, the producer's responsible declaration —, the seller's fine drops to 1,000 EUR per system sold.
And it counts even if you rarely invoice from Odoo: issuing one invoice, credit note or export invoice from Odoo is enough to make that Odoo a billing software system subject to the regulation too. Native invoicing does not generate the chained record.
How to get VeriFactu-compliant in Odoo
The first step, before spending anything, is knowing where your setup stands: our Verifactu Readiness Check is a free audit of your Odoo invoicing, with concrete findings.
For compliance itself there is our flexigo_spain_verifactu module (VeriFactu Spain — AEAT RRSIF Compliance), for Odoo 17, 18 and 19, with 283 tests on each branch: chained record, timestamp, QR, real-time submission, event log and integrity verification. It is among our published modules, and we leave it configured through our implementation service.
If you also issue electronic invoices, what decides the architecture is who the legal issuer is: article 9 of the Regulation requires the record to be generated by the system that issues the invoice, «simultaneously or immediately before». It is worked through in B2Brouter and Odoo; the rest of the obligations are in solutions.
Frequently asked questions
How exactly is the VeriFactu hash computed?
With SHA-256 over fieldName=value pairs joined by &, using the eight fields of article 13 of Orden HAC/1177/2024 in order, trimming leading and trailing spaces in each value, in UTF-8, with the output in uppercase hexadecimal, 64 characters. On the first record, the previous-hash field is present and empty. To check it, reproduce the examples the AEAT publishes.
What are the exact VeriFactu deadlines in 2027?
Under final provision four of RD 1007/2023, as amended by RD-ley 15/2025: 1 January 2027 for Corporate Income Tax payers and 1 July 2027 for the other taxpayers listed in article 3.1, the self-employed included. Producers and resellers are already required to offer an adapted product.
Useful links inside FlexigoTech
What we do about this
Want to know if your Odoo complies with VeriFactu?
We start with the free audit, and on the first call we tell you where you stand and what is missing — including whether the hash your system generates matches the one the AEAT computes. Email comercial@flexigobe.com or call +34 616 809 504.

