Anchor is the invoicing system I use as a self-employed developer in Spain. I designed and built it from scratch to comply with VERI*FACTU, the new regulation from the Spanish Tax Agency (AEAT) that requires every invoice you issue to be recorded, cryptographically chained and, in my case, sent to the tax agency the very moment you issue it.

It’s a personal, private project: it isn’t a SaaS and it isn’t meant for anyone else to use. But that’s exactly what let me dig all the way into the regulation and make every technical decision without shortcuts. In this post I explain what the law asks for, how I turned it into code, and why I built things the way I did.

Some context: what is VERI*FACTU?

It all starts with Law 11/2021 on measures to prevent and fight tax fraud (the “Anti-Fraud Law”). Among other things, it bans so-called dual-use software: invoicing programs that let you hide or alter sales after they’ve been recorded. From there, the law requires any program that issues invoices to guarantee the integrity, retention, accessibility, legibility, traceability and unalterability of its records.

Those six requirements are spelled out in two regulations:

  • Royal Decree 1007/2023, which approves the Regulation on the requirements for computerised invoicing systems (what the AEAT calls a SIF, Sistema Informático de Facturación).
  • Order HAC/1177/2024, which goes down to the technical detail: which fields every record carries, how the hash is computed, what the QR code looks like, how the web service works…

The deadlines have already been pushed back twice. After Royal Decree-law 15/2025, the obligation starts on 1 January 2027 for companies paying Corporate Income Tax and on 1 July 2027 for everyone else, freelancers included. The AEAT’s services, on the other hand, are already up and running, both in the testing environment and in production.

Two ways to comply

The regulation allows a SIF to run in one of two modes:

Comparison between the VERI*FACTU and NON-VERI*FACTU modes

  • VERI*FACTU: every invoicing record is sent to the AEAT automatically, right away. In exchange, the system doesn’t need to e-sign each record or keep an event log, and invoices carry the legend «Factura verificable en la sede electrónica de la AEAT» (“Invoice verifiable on the AEAT website”), or simply «VERI*FACTU».
  • NON-VERI*FACTU: records stay in the system, signed with XAdES and accompanied by an equally chained event log, and they’re only handed over if the tax agency requests them.

Anchor is a VERI*FACTU-only system. For someone who doesn’t issue many invoices and wants peace of mind, it makes total sense: every invoice is validated by the AEAT on the spot, and the system is simpler to audit.

The producer is accountable too

A detail many people miss: the law doesn’t only bind whoever uses the software, it also binds whoever makes it. The producer of a SIF must sign a declaración responsable (a statement of compliance) certifying that the system meets the regulation, and that statement has to be available to its users. The penalties for using or producing non-compliant software are serious, so this is no decorative formality.

With Anchor I’m both producer and user. The statement of compliance is part of the system itself (it’s generated and viewed from within the app), and its contents describe what Anchor is: the system’s identifier, its version, its installation number, the fact that it only runs in VERI*FACTU mode, and that it serves a single taxpayer.

The heart of the system: the invoicing record

Every time you issue an invoice, the SIF must create an issuing record (registro de alta). If the invoice later has to be cancelled, it creates a cancellation record (registro de anulación). These records are what travels to the AEAT; the PDF invoice is a different thing altogether.

Each record is identified by the trio issuer’s tax ID + invoice number + issue date, and contains, among other data, the invoice type, the tax breakdown, the total tax and total amount, the date and time (with time zone) when the record was generated, and details of the invoicing system itself.

But the key piece is the hash (huella).

The hash and the chain

Every record carries a SHA-256 hash computed over some of its fields and over the previous record’s hash. It’s exactly the same idea as a blockchain, minus the distributed part: if someone tampers with an old record, its hash changes, it no longer matches the one stored in the next record, and the tampering is detected immediately.

Hash chaining between issuing and cancellation records

The AEAT’s specification is very precise about how to compute it:

  1. Take a fixed set of fields, in a fixed order. For an issuing record: IDEmisorFactura, NumSerieFactura, FechaExpedicionFactura, TipoFactura, CuotaTotal, ImporteTotal, Huella (the previous record’s hash) and FechaHoraHusoGenRegistro. A cancellation uses a different subset.
  2. Concatenate them as name=value, separated by &, trimming leading and trailing spaces from each value.
  3. If a field has no value (for example, the previous hash of the very first record), write the name and the = with nothing after it.
  4. Encode it as UTF-8, apply SHA-256 and express the result in uppercase hexadecimal (64 characters).

Simplified, in Go it looks like this:

type field struct{ name, value string }

// hash joins the fields as name=value&name=value…,
// trimming spaces, and returns the SHA-256 as uppercase hex.
func hash(fields ...field) string {
	var b strings.Builder
	for i, f := range fields {
		if i > 0 {
			b.WriteByte('&')
		}
		b.WriteString(f.name)
		b.WriteByte('=')
		b.WriteString(strings.TrimSpace(f.value))
	}
	sum := sha256.Sum256([]byte(b.String()))
	return strings.ToUpper(hex.EncodeToString(sum[:]))
}

Using the data from the AEAT’s own first example:

hash(
	field{"IDEmisorFactura", "89890001K"},
	field{"NumSerieFactura", "12345678/G33"},
	field{"FechaExpedicionFactura", "01-01-2024"},
	field{"TipoFactura", "F1"},
	field{"CuotaTotal", "12.35"},
	field{"ImporteTotal", "123.45"},
	field{"Huella", ""}, // first record: no previous hash
	field{"FechaHoraHusoGenRegistro", "2024-01-01T19:20:30+01:00"},
)
// 3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60

…which is exactly the value given in the specification. The function itself is trivial; the hard part is everything around it. Each field’s value has to be exactly what goes into the XML (same date format, same decimals in the amounts), the timestamp being hashed has to be the same one that gets sent, and above all you have to guarantee that no two records ever chain onto the same predecessor. That’s what the rest of this post is about.

Architecture

Anchor’s architecture: React frontend, Go backend, PostgreSQL, Redis, AEAT and email

The project is split into two repositories:

  • Backend in Go with GoFiber, PostgreSQL 18 and Redis 8. It follows a clean architecture: thin HTTP handlers, domain services and repositories. Everything AEAT-specific lives in the internal/verifactu package.
  • Frontend in React with TanStack Router, Query, Table and Form, validation with Zod and shadcn/ui components on Tailwind v4, built on a custom Design System.

A few decisions I think are worth explaining:

Hand-written structs from the XSDs. The AEAT publishes the service’s XSD schemas. There are code generators for Go, but I chose to transcribe the structures by hand with encoding/xml. In a system where a misplaced namespace or an out-of-order element means a rejection, I want full control over tags, ordering and prefixes, and I want to be able to read that code.

A custom SOAP client. SOAP support in Go is thin and immature, so the client is just an http.Client with mutual TLS (my digital certificate, loaded from a PKCS#12 file with go-pkcs12) that builds the SOAP envelope and sends it. A handful of lines and zero magic.

Storing the exact bytes. The XML that’s sent and the XML that comes back are stored as-is in BYTEA columns. Nothing gets rebuilt from structs: if I ever have to prove what was sent, the original is there byte for byte. The rest of the invoice data that doesn’t take part in the hash lives in JSONB, so the model can evolve without painful migrations.

Public IDs and numbering. Primary keys never leave the database. Externally, customers get a short Crockford Base32 identifier (no ambiguous characters like O/0 or I/1), and invoice numbering is sequential per customer: {customer_id}-{0001}.

Issuing an invoice: all or nothing

In Anchor an invoice starts life as a draft, freely editable. Once issued, it can no longer be modified and becomes something with legal consequences. That step happens inside a single PostgreSQL transaction:

Steps of the invoice issuing transaction

The tricky part is the first step. To compute a new record’s hash I need the last record’s hash, and if two issues run at the same time, both would read the same one and the chain would fork. The fix is a chain_state row that holds the latest link and gets locked with SELECT … FOR UPDATE at the start of the transaction: any other issue waits its turn.

The invoice number is assigned in that same transaction. If any step fails, the ROLLBACK undoes everything: no number is used up, no hash is generated and nothing is enqueued. No gaps in the numbering and no dangling links. Cancellations follow exactly the same path, generating their own chained record.

Notice that issuing doesn’t send anything. The record is left in an outbox table, and a separate process takes care of delivering it to the AEAT. That way, issuing an invoice doesn’t depend on the tax agency answering at that moment.

Submitting to the AEAT

Dispatcher cycle: outbox, flow control, SOAP submission and response statuses

The outbox is handled by a dispatcher: a loop that claims pending records with FOR UPDATE SKIP LOCKED. That pattern means that even with several instances, each record is picked up by only one of them, and if the process dies halfway, the locks are released and the work becomes available again.

Flow control

The order requires a flow control mechanism that can’t be skipped: after each submission, the AEAT responds with a TiempoEsperaEnvio (wait time, initially 60 seconds), and the next submission can only happen once that time has passed or once 1,000 records have piled up (the maximum per submission), whichever comes first. The dispatcher keeps in Redis the moment it’s allowed to send again, and honours the value returned by each response.

Reading the response properly

The response has a global status (Correcto, ParcialmenteCorrecto or Incorrecto) and one line per record with its own status:

  • Correcto: registered, nothing else to do.
  • AceptadoConErrores: registered, but with errors that must be corrected.
  • Incorrecto: rejected, with an error code.

To match each line to its record I use the RefExterna field, which lets you send a free-text value that the AEAT echoes back untouched: that’s where the record’s internal ULID goes. The response also includes a CSV (código seguro de verificación, a secure verification code) for the submission, which according to the documentation must be stored right then, because it can’t be retrieved through later queries.

A priority I set from day one: every error has to reach the UI. AEAT validation errors, TLS failures, timeouts, SOAP Faults… The backend classifies them and exposes them so the frontend can show exactly what happened to each record. In a system like this, a silent failure is the worst thing that can happen.

The invoice: QR, PDF and email

The tax QR code

Invoices from a VERI*FACTU system carry a QR code that anyone can scan to check on the AEAT website that the invoice has been registered. Once again, the specification is very precise:

Anatomy of the tax QR code and the URL it encodes

  • Between 30×30 and 40×40 mm, following ISO/IEC 18004, with error correction level M.
  • Preceded by the text «QR tributario:» and followed by the VERI*FACTU legend.
  • At the top of the invoice, on the first page, with blank margin around it.
  • It encodes a verification URL with four parameters: nif, numserie, fecha and importe, properly URL-encoded. Watch out here: an unencoded & inside the invoice number would split the URL into two parameters.

Templates and PDFs

PDFs are generated with html/template + chromedp: the backend renders HTML and a headless Chrome instance prints it to PDF. Templates are edited from the web app itself with a code editor (HTML, CSS and Go template syntax), with a live preview against a real invoice or a draft. I can have several templates (for example, an English one for foreign customers), set a default for each customer and override it on a specific invoice.

Sending by email

Invoices are emailed with Resend, also using editable templates and per-customer subjects. And here there’s a rule I consider important: an invoice is only sent to the customer once the AEAT has accepted its record. There’s no point sending an invoice whose QR code, when scanned, doesn’t show anything yet.

The interface

The frontend has its own Design System with an aesthetic inspired by nautical instruments (hence the name): deep navy blue, brass accents, IBM Plex typography and semantic tokens for invoice states (paid, pending, overdue), with light and dark mode.

Besides the usual sections (dashboard, invoices, customers, products and settings), it includes a VERI*FACTU monitor with the submission history, the status of each record and the details of every response, plus a logs view to audit what has happened in the system.

Current status

Right now Anchor is at version 0.4.0 and running against the AEAT’s testing environment:

  • ✅ Customers (Spanish, EU and non-EU), products and tax treatments, including income tax (IRPF) withholding.
  • ✅ Drafts and issuing with numbering, hashing and chaining.
  • ✅ Submission to the AEAT with flow control and full response handling.
  • ✅ Invoice cancellation.
  • ✅ QR code, PDF templates and email delivery.
  • 🔜 Corrective invoices.
  • 🔒 Issuing in production is deliberately blocked until the system is finished. An invoice issued in production is real and there’s no going back.

What I take away

The most interesting part of this project hasn’t been writing Go, but reading regulations and turning them into code invariants. Every sentence in the order ends up as a constraint: a database lock, the order of elements in an XML document, a field that can never change, or an error that must never stay hidden.

It also taught me to think about a system where mistakes can’t be undone. In most applications a bug is fixed with a deploy; here, a wrongly submitted record stays at the tax agency and has to be corrected through the proper procedure. That changes the way you design a lot: atomic transactions, an outbox, keeping evidence of everything, and testing in pre-production before touching anything real.