Regulated QR Codes on Invoices & Payments

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A QR code on an invoice is not decoration. In Spain the tax authority prescribes its content, size, position and caption; in the Basque Country each provincial tax authority does the same for TicketBAI; the European Payments Council published a payload for SEPA credit transfers that banking apps understand; and in Switzerland the QR-bill replaced the red and orange payment slips. This page builds each of the four codes for the Bookshelf Billing invoice, draws it into a PDF with iText Core 9.8.0, and states for every rule where it comes from and when it was checked. Everything that follows is written "as of 2026-10-09". Rules, URLs and dates of this kind change by decree, and some of them were changing in the week this page was written, so treat the page as a reading guide to the primary sources linked in each section and not as a replacement for them.

The page does not repeat what a QR code is. Versions, error-correction levels, quiet zones and the vector versus raster decision are in Barcodes & QR Codes: Fundamentals, and the generic iText and ZXing recipes (BarcodeQRCode, sizing in tables, page events) are in Barcodes with iText, ZXing & Okapi. The reason the QR must be part of the finished file, and not added by a later step, is explained with the signature pages: a PAdES signature covers every byte, so the QR is drawn first and the signature comes last, as shown in Signing PDFs with iText. The low-level PdfCanvas operators used below are introduced in Low-Level Canvas.

This page documents technical formats. It is not legal or tax advice, and the invoice data in the examples is fictitious: the company, the invoice, the amounts and every identifier. The tax identifier 89890001K is the sample NIF that the AEAT itself prints in its QR specification, and it is used here only because that specification uses it; it does not identify the Bookshelf Billing company, which does not exist. The IBANs and references are the sample values of the EPC and SIX documents and are flagged where they appear. Never print a real taxpayer’s data into a test file.

What the page checked, and what it could not

Because the brief of this section is to state dates and rules from primary sources, here is the summary of what was read on 2026-10-09 and from where. The details and the links are in the sections below.

  • AEAT QR specification (Spanish, "Detalle de las especificaciones técnicas del código QR"), document version 0.5.0 dated 10/12/2025: read in full from the AEAT PDF. It states the four URL parameters, the pre-production and production hosts, the 30 x 30 to 40 x 40 mm size, error-correction level M, ISO/IEC 18004:2015, the caption "QR tributario:", the legend and the placement rules, and it quotes articles 20 and 21 of Orden HAC/1177/2024.

  • VeriFactu dates: the dates in force were read in the consolidated text of Real Decreto 1007/2023 on the BOE and in the AEAT information note. The planned postponement to October 2028 was read in the Ministerio de Hacienda press note of 5 October 2026. Whether that postponement has already been enacted could be confirmed only as far as the sources below say (see the section on the dates); the page states plainly what was not confirmed.

  • TicketBAI: the QR rules were read in the annex of the Araba (Álava) orden foral that regulates TicketBAI. The equivalent texts of Bizkaia and Gipuzkoa were not read, and the Basque Government portal has no QR specification.

  • EPC QR: version 3.0 of EPC069-12 (13 September 2022) was read in full. The EPC page for version 3.1 refused automated access, so the changes of 3.1 are not known to this page.

  • Swiss QR-bill: the PDF of the Implementation Guidelines version 2.3 was read, and the SIX product page and standards page were read for the entry-into-force dates.

VeriFactu: the Spanish invoice QR

Where the rule comes from

Spain regulates invoicing software, not only invoices. Real Decreto 1007/2023 (the Reglamento of the invoicing computer systems, "SIF") requires the systems used by businesses and professionals to produce tamper-evident invoice records. The technical and content rules are developed by Orden HAC/1177/2024, and Real Decreto 254/2025 modified parts of the Reglamento. A system may either send every invoice record to the AEAT at the moment of issue, in which case it is a "verifiable invoice" system and the VERI*FACTU brand applies, or keep the records locally with the integrity guarantees of the Reglamento. In both cases the invoice must carry a QR code, and the URL inside the code differs between the two. The cryptographic chain of invoice records (the "huella" or hash), the XML schemas and the web services are a separate subject that this page does not cover; only the printed QR is.

Articles 20 and 21 of the order, quoted in the AEAT document, state the obligations that matter for printing: an invoice, on paper or as a digital image, includes a QR that meets article 21; verifiable invoices also show the phrase "Factura verificable en la sede electrónica de la AEAT" or "VERI*FACTU" in a typeface and size at least like the rest of the invoice; the QR measures between 30 x 30 and 40 x 40 millimetres, follows ISO/IEC 18004:2015 and is generated with error-correction level M; its content is the URL of the verification service with the issuer NIF, the series and number, the issue date and the total amount.

The dates, as of 2026-10-09

The dates of the obligation were changed several times, and a new change was announced a few days before this page was checked. Read this section as a snapshot.

  • In force. Real Decreto-ley 15/2025, of 2 December 2025, was published in the BOE of 3 December 2025, entered into force on 4 December 2025 and was validated by the Congress (resolution of 11 December 2025). Its first final provision rewrites the fourth final provision of Real Decreto 1007/2023: the taxpayers of article 3.1.a) of the Reglamento, that is the corporate income tax (Impuesto sobre Sociedades) payers, must have their systems adapted before 1 January 2027, and the rest of the obligated taxpayers of article 3.1 must have them operating before 1 July 2027. This was read in the BOE consolidated text of Real Decreto 1007/2023 (last consolidated update 3 December 2025) and in the BOE page of Real Decreto-ley 15/2025. The AEAT note on the new adaptation deadlines, updated on 7 October 2026, repeats the same two dates.

  • Announced, not enacted as far as the sources show. A press note of the Ministerio de Hacienda dated 5 October 2026 announces a "previsión" (forecast) to postpone the pending obligations of Real Decreto 1007/2023 until October 2028, to make them coincide with the compulsory electronic invoice for businesses and professionals whose annual turnover does not exceed 8 million euros. The note adds that, once the alignment is approved, a further modification will address the convergence of the scope and some technical aspects with the future digital reporting requirements of the EU ViDA proposal, and that the integrity, conservation, accessibility, legibility, traceability and inalterability guarantees of the Reglamento stay substantially equivalent. The AEAT home page of the subject, updated on 8 October 2026, links the communication under the title "Previsión de ampliación del plazo de adaptación" and a note on the new adaptation periods, but the note itself still shows the two dates of 2027.

  • What could not be confirmed. The page found no norm published in the BOE that moves the dates to October 2028: the consolidated text of Real Decreto 1007/2023 showed no modification after 3 December 2025 when it was read, and the AEAT note still states 1 January and 1 July 2027. The page could not search the BOE daily summaries after 5 October 2026, so a decree published after that cannot be excluded. Trade press of the same days disagreed on whether the postponement was already law; the primary sources above say it is a forecast. Before shipping anything that depends on the date, open the AEAT page and the BOE and check again.

The practical consequence for Bookshelf Billing is independent of the date: the QR and its caption are cheap to add and only the base URL differs between the test environment and production. The AEAT note says that, until the deadlines, the taxpayers that sent test records may stop sending them. The obligations of the software producers are separate and are not discussed here.

The content of the QR

The specification says that the URL contains only four parameters, all mandatory, and that the response of the service is HTML. The base URL depends on the environment and on the kind of system:

System and environment Base URL (the query string follows)

Verifiable invoices, pre-production ("Portal de Pruebas Externas")

https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR

Verifiable invoices, production

https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR (as of 2026-10-09; confirm in section 5.1 of the specification)

Not verifiable (local records only), pre-production

https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQRNoVerifactu

Not verifiable, production

https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQRNoVerifactu (as of 2026-10-09; confirm in section 5.2 of the specification)

The four parameters, in the order of the specification:

Parameter Format Meaning

nif

nine characters

NIF of the party obliged to issue the invoice

numserie

text, ASCII 32 to 126, at most 60 characters

series and number that identify the invoice

fecha

DD-MM-AAAA, ten characters

issue date

importe

digits, . as decimal separator, at most 12 integer and 2 decimal digits

total amount of the invoice

Two details cause most of the invalid codes seen in practice, and the specification addresses both. The values must be URL-encoded as UTF-8 following the usual web rules: the specification’s own example has the series text 12345678&G33 and shows that it must travel as 12345678%26G33, because a raw ampersand would start a new parameter. And the amount uses a point whatever the locale of the server, so formatting it with String.format("%.2f", total) on a Spanish JVM (which prints a comma) produces a code that the service rejects. The specification’s examples in other places use a hyphen in the number, 12345678-G33, which needs no encoding.

Size, placement, caption and legend

Section 3 of the specification adds the layout rules. They are the reason the figure below exists.

  • The code is 30 to 40 mm on a side. At least 2 mm of empty space must surround it on the four sides and 6 mm are recommended. Colour contrast must be high enough to read it.

  • It sits at the beginning of the invoice, before the content generated by the billing system, unless there is a justified obstacle, and then it must stay clearly visible and separated from the rest. It is the first QR code of the invoice when the invoice also carries other QR codes, such as a payment QR. In a multi-page invoice it appears once, on the first page.

  • In a portrait invoice it goes at the top, close to the top margin, preferably centred between the left and right margins, otherwise towards the upper left. In a landscape invoice it goes on the left, preferably near the upper left corner, otherwise centred between the top and bottom margins.

  • The text "QR tributario:" must always precede it, placed above the code, preferably centred over it. For verifiable invoices the phrase "Factura verificable en la sede electrónica de la AEAT" or "VERI*FACTU" goes directly below it, preferably centred, on several lines if it does not fit on one. Both texts use a typeface and size at least as large as the rest of the invoice data.

The specification closes with annexed examples for ticket-size paper (57 mm and 44 mm wide), a 140 mm landscape format and A4, using 30 and 40 mm codes.

Three invoice sheets: VeriFactu QR centred at the top with caption and legend, TicketBAI QR at the bottom under its identifier, and a payment part at the foot of the page

Generating the code with ZXing and drawing it with iText

The examples use these dependencies. iText Core 9.8.0 is released under the AGPLv3 or a commercial licence, which matters for a closed-source billing service; ZXing 3.5.4 is Apache-2.0, and it is used only to compute the matrix of modules, so the iText part could equally be replaced by Apache PDFBox 3.0.8 (Apache-2.0) drawing the same rectangles.

<dependencies>
  <dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-core</artifactId>
    <version>9.8.0</version>
    <type>pom</type>
  </dependency>
  <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>core</artifactId>
    <version>3.5.4</version>
  </dependency>
  <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>javase</artifactId>
    <version>3.5.4</version>
  </dependency>
</dependencies>

The first helper is shared by all the examples of this page. It encodes text with a chosen error-correction level and without a quiet zone (the page layout provides it), draws the matrix as black squares so that the symbol measures exactly the requested number of millimetres, and decodes a matrix again for a self-check that also reports the level the reader found. Drawing squares as vector operators keeps the code sharp at any print resolution and adds no image to the file.

package com.bookshelf.billing.qr;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.NotFoundException;
import com.google.zxing.Result;
import com.google.zxing.ResultMetadataType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;

import java.awt.image.BufferedImage;
import java.util.EnumMap;
import java.util.Map;

/** Shared helpers: encode text as a QR matrix, draw it as vector squares, decode it back for a self-check. */
public final class QrSupport {

    /** 1 mm expressed in PDF points (1 pt = 1/72 inch). */
    public static final float MM = 72f / 25.4f;

    private QrSupport() {
    }

    /** Encodes the text with the given error-correction level, UTF-8, and no quiet zone (the layout adds it). */
    public static BitMatrix encode(String text, ErrorCorrectionLevel level) throws WriterException {
        Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
        hints.put(EncodeHintType.ERROR_CORRECTION, level);
        hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
        hints.put(EncodeHintType.MARGIN, 0);
        // width/height 0 asks ZXing for the smallest matrix, one pixel per module
        return new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, hints);
    }

    /**
     * Draws the matrix as black squares so that the whole symbol measures exactly sizeMm millimetres.
     * (x, y) is the lower-left corner of the symbol in points.
     */
    public static void drawVector(PdfCanvas canvas, BitMatrix matrix, float x, float y, float sizeMm) {
        float module = sizeMm * MM / matrix.getWidth();
        canvas.saveState().setFillColor(ColorConstants.BLACK);
        for (int row = 0; row < matrix.getHeight(); row++) {
            for (int col = 0; col < matrix.getWidth(); col++) {
                if (matrix.get(col, row)) {
                    float top = y + sizeMm * MM - row * module;
                    canvas.rectangle(x + col * module, top - module, module, module);
                }
            }
        }
        canvas.fill().restoreState();
    }

    /** Decodes a matrix again and returns the text plus the error-correction level the reader found. */
    public static String[] decode(BitMatrix matrix) throws NotFoundException {
        BufferedImage image = MatrixToImageWriter.toBufferedImage(addQuietZone(matrix, 4));
        BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(new BufferedImageLuminanceSource(image)));
        Result result = new MultiFormatReader().decode(bitmap,
                Map.of(DecodeHintType.POSSIBLE_FORMATS, java.util.List.of(BarcodeFormat.QR_CODE),
                        DecodeHintType.CHARACTER_SET, "UTF-8"));
        Object level = result.getResultMetadata().get(ResultMetadataType.ERROR_CORRECTION_LEVEL);
        return new String[] {result.getText(), String.valueOf(level)};
    }

    private static BitMatrix addQuietZone(BitMatrix source, int modules) {
        // scale each module to 8 px and add a quiet zone so that the reader can lock on
        int scale = 8;
        int size = (source.getWidth() + 2 * modules) * scale;
        BitMatrix out = new BitMatrix(size, size);
        for (int row = 0; row < source.getHeight(); row++) {
            for (int col = 0; col < source.getWidth(); col++) {
                if (source.get(col, row)) {
                    out.setRegion((col + modules) * scale, (row + modules) * scale, scale, scale);
                }
            }
        }
        return out;
    }
}

Derived from the ZXing API: QRCodeWriter and EncodeHintType, and from the iText PdfCanvas API.

Next, the URL builder for the AEAT code. It checks the documented limits, encodes every value as UTF-8 percent-encoding (everything but the unreserved characters of RFC 3986 is escaped), and formats the date and the amount independently of the default locale. Its main method rebuilds the specification’s own worked example and compares it with the text the AEAT prints, then encodes the URL with level M and decodes it back.

package com.bookshelf.billing.qr;

import com.google.zxing.WriterException;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

/** Builds the content of the AEAT invoice QR (specification v0.5.0, 10/12/2025). */
public final class VerifactuQr {

    /** Pre-production ("Portal de Pruebas Externas") base URLs from section 5 of the AEAT specification. */
    public static final String PRE_VERIFIABLE = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR";
    public static final String PRE_NOT_VERIFIABLE = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQRNoVerifactu";

    private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu");

    private VerifactuQr() {
    }

    /**
     * Builds the URL. The four parameters are nif, numserie, fecha and importe, in that order, and each value is
     * percent-encoded as UTF-8 (the spec shows an ampersand in numserie becoming %26).
     */
    public static String url(String baseUrl, String nif, String numSerie, LocalDate issued, BigDecimal total) {
        if (nif == null || nif.length() != 9) {
            throw new IllegalArgumentException("nif must have 9 characters");
        }
        if (numSerie.isEmpty() || numSerie.length() > 60
                || numSerie.chars().anyMatch(c -> c < 32 || c > 126)) {
            throw new IllegalArgumentException("numserie: 1..60 characters, ASCII 32-126 only");
        }
        BigDecimal amount = total.setScale(2, RoundingMode.HALF_UP);
        if (amount.precision() - amount.scale() > 12) {
            throw new IllegalArgumentException("importe: at most 12 integer digits");
        }
        return baseUrl + "?nif=" + encode(nif)
                + "&numserie=" + encode(numSerie)
                + "&fecha=" + DATE.format(issued)
                + "&importe=" + amount.toPlainString(); // "." decimal separator, never a locale-dependent format
    }

    /** RFC 3986 percent-encoding of UTF-8 bytes: everything except unreserved characters is escaped. */
    static String encode(String value) {
        StringBuilder out = new StringBuilder();
        for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
            char c = (char) (b & 0xff);
            if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                    || c == '-' || c == '.' || c == '_' || c == '~') {
                out.append(c);
            } else {
                out.append('%').append(String.format("%02X", b & 0xff));
            }
        }
        return out.toString();
    }

    public static void main(String[] args) throws WriterException, com.google.zxing.NotFoundException {
        // The AEAT specification's own worked example (NIF 89890001K is the documented sample value)
        String url = url(PRE_VERIFIABLE, "89890001K", "12345678&G33", LocalDate.of(2024, 1, 1), new BigDecimal("241.4"));
        System.out.println(url);
        // The example exactly as the specification prints it, with importe=241.4
        String specExample = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR"
                + "?nif=89890001K&numserie=12345678%26G33&fecha=01-01-2024&importe=241.4";
        int specAt = specExample.indexOf("&importe=") + "&importe=".length();
        int ourAt = url.indexOf("&importe=") + "&importe=".length();
        System.out.println("matches the spec example up to importe: "
                + url.substring(0, ourAt).equals(specExample.substring(0, specAt)));
        System.out.println("importe equal as an amount: "
                + (new BigDecimal(url.substring(ourAt)).compareTo(new BigDecimal(specExample.substring(specAt))) == 0));

        BitMatrix matrix = QrSupport.encode(url, ErrorCorrectionLevel.M);
        String[] decoded = QrSupport.decode(matrix);
        System.out.println("modules per side: " + matrix.getWidth());
        System.out.println("round trip ok: " + decoded[0].equals(url) + ", level read back: " + decoded[1]);
    }
}

Derived from sections 4 to 6 of the AEAT specification, Detalle de las especificaciones técnicas del código QR.

Run against ZXing 3.5.4, the main method printed the following. The comparison is made with the text of the specification as printed: everything up to importe= is reproduced character by character, and the amount 241.4 of the specification is written 241.40 by the builder, which is the same amount with the two decimals the format allows.

https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678%26G33&fecha=01-01-2024&importe=241.40
matches the spec example up to importe: true
importe equal as an amount: true
modules per side: 45
round trip ok: true, level read back: M

The invoice itself follows. It reserves a block at the top of page 1 with an empty Div whose height is the caption, the quiet zones, the 35 mm code and the legend, draws the code and the two texts into that block through a PdfCanvas on the first page, and then lets the normal layout elements continue below. Adding the reserved block makes the Document create page 1, and the code is then drawn on pdf.getPage(1), so the block and the code are on the same page.

package com.bookshelf.billing.qr;

import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Div;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.layout.properties.UnitValue;

import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;

import static com.bookshelf.billing.qr.QrSupport.MM;

/** A one-page Bookshelf Billing invoice with the AEAT QR centred at the top of page 1. */
public final class VerifactuInvoice {

    static final float QR_MM = 35f;          // allowed range 30..40 mm
    static final float QUIET_MM = 6f;        // minimum 2 mm, 6 mm recommended
    static final float BLOCK_MM = 5f + QUIET_MM + QR_MM + QUIET_MM + 8f; // caption + quiet + QR + quiet + legend

    public static void main(String[] args) throws Exception {
        Path out = Path.of(args.length > 0 ? args[0] : "invoice-verifactu.pdf");

        // Fictitious data. 89890001K is the AEAT test NIF for its pre-production environment, not a real taxpayer.
        String nif = "89890001K";
        String numSerie = "BB-2026-0042";
        LocalDate issued = LocalDate.of(2026, 10, 9);
        BigDecimal total = new BigDecimal("48.40");

        String url = VerifactuQr.url(VerifactuQr.PRE_VERIFIABLE, nif, numSerie, issued, total);
        BitMatrix matrix = QrSupport.encode(url, ErrorCorrectionLevel.M);

        try (PdfDocument pdf = new PdfDocument(new PdfWriter(out.toString()));
             Document document = new Document(pdf, PageSize.A4)) {
            PdfFont regular = PdfFontFactory.createFont(StandardFonts.HELVETICA);
            PdfFont bold = PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD);
            document.setFont(regular).setFontSize(10);
            document.setMargins(20 * MM, 20 * MM, 20 * MM, 20 * MM);

            // 1) Reserve the block at the very top, before any invoice content
            Div reserved = new Div().setHeight(UnitValue.createPointValue(BLOCK_MM * MM));
            document.add(reserved);
            float pageWidth = PageSize.A4.getWidth();
            float qrSize = QR_MM * MM;
            float qrX = (pageWidth - qrSize) / 2;
            float qrBottom = PageSize.A4.getHeight() - (20 + 5 + QUIET_MM + QR_MM) * MM;

            PdfCanvas page = new PdfCanvas(pdf.getPage(1));
            QrSupport.drawVector(page, matrix, qrX, qrBottom, QR_MM);
            try (Canvas captions = new Canvas(page, PageSize.A4)) {
                captions.setFont(regular).setFontSize(10);
                // caption above the code: "QR tributario:" is always required
                captions.showTextAligned(new Paragraph("QR tributario:").setFont(bold), pageWidth / 2,
                        qrBottom + qrSize + QUIET_MM * MM + 2, TextAlignment.CENTER);
                // legend directly below the code (verifiable invoices only)
                captions.showTextAligned(new Paragraph("VERI*FACTU").setFont(bold), pageWidth / 2,
                        qrBottom - QUIET_MM * MM - 8, TextAlignment.CENTER);
            }

            // 2) Invoice content starts below the reserved block
            document.add(new Paragraph("Bookshelf Billing S.L. (fictitious)").setFont(bold).setFontSize(14));
            document.add(new Paragraph("Invoice " + numSerie + " of " + issued + " | NIF " + nif + " (sample)"));
            Table table = new Table(UnitValue.createPercentArray(new float[] {6, 1, 2})).useAllAvailableWidth();
            table.addHeaderCell("Item").addHeaderCell("Qty").addHeaderCell("Amount (EUR)");
            table.addCell("Catalogue hosting, October 2026").addCell("1").addCell("40.00");
            table.addCell("VAT 21 % (rounded)").addCell("").addCell("8.40");
            table.addCell(new Paragraph("Total").setFont(bold)).addCell("").addCell(new Paragraph(total.toPlainString()).setFont(bold));
            document.add(table);
        }
        System.out.println("QR content: " + url);
        System.out.println("written " + out.toAbsolutePath());
    }
}

Derived from the iText Document and PdfCanvas APIs and from section 3 of the AEAT specification.

The generated page was rendered with Apache PDFBox 3.0.8 and inspected: the caption "QR tributario:" is above the code, "VERI*FACTU" is below it, both are centred on the A4 width, and the code measures 35 mm when its width is measured on the rendering at 100 dpi (about 139 pixels). The code was decoded from its matrix, not from a photograph of the printed page; test the printed result with a phone before going live, because contrast and print resolution are part of the rule.

Two follow-up notes. First, if the invoice must also be signed, add the QR before signing and sign the finished bytes as described in Signing PDFs with iText. Second, if the output has to be PDF/A, vector squares and standard fonts are fine, but the font must be embedded, which the examples above do not do (see PDF/A & PDF/UA with iText).

Verifying a code against the AEAT service

The AEAT publishes a pre-production environment so that software can be tested before production. A code produced with the ValidarQR pre-production base URL can be opened in a browser; with fictitious data the service answers that the invoice is not found, and the interesting answers are the validation errors listed in the specification (for example, an invalid date format is reported with a specific error code). The page did not call the pre-production service from the sandbox where the examples were compiled, so the behaviour of the service is described from the specification and not from a call. The AEAT also publishes a test portal and the XML schemas that a verifiable-invoice system sends; those are outside this page.

TicketBAI: the Basque QR

TicketBAI is the Basque Country’s invoicing control system. It is regulated separately by the three historical territories, Araba (Álava), Bizkaia and Gipuzkoa, each with its own orden foral, tax authority and verification service, although the software registries are interconnected and the Basque Government portal presents the programme as a common project. This page read the annex of the Araba orden foral, titled "Especificaciones del código TicketBAI y del código QR de las facturas o justificantes generados por el software TicketBAI". It did not read the equivalent texts of Bizkaia or Gipuzkoa, so everything below is the Araba specification, and a billing system that issues invoices in the other territories must read their texts and their verification URLs.

An invoice issued by TicketBAI software carries two elements:

  • the TicketBAI identifier, a 39-character code of the form TBAI-NNNNNNNNN-DDMMAA-FFFFFFFFFFFFF-CRC: the fixed text TBAI, the issuer NIF (nine characters), the issue date as day, month and two-digit year, the first 13 characters of the SignatureValue of the TicketBAI record, and a CRC-8 as three decimal digits, all separated by hyphens;

  • the QR code, between 30 x 30 and 40 x 40 mm, with error-correction level M, UTF-8 encoding, high contrast and a recommended 6 mm of empty space on all sides. The invoice with an unreadable QR is not valid for the purposes of the TicketBAI obligation, and the taxpayer is responsible for its readability.

The QR content is a URL, in the Araba annex https://ticketbai.araba.eus/tbai/qrtbai/ (the final slash is part of what the CRC covers), with these parameters in this order: id (the identifier), s (invoice series), nf (invoice number), i (total amount with VAT, in the same value and format as the ImporteTotalFactura of the TicketBAI record) and cr (the CRC-8 of everything that precedes it, without the &cr= part, computed over UTF-8 bytes and written in decimal). The CRC-8 uses the polynomial x^8 + x^2 + x + 1, initial value 0, no reflection and no final XOR; the annex publishes a Java implementation with a 256-entry table so that software can obtain the same values as the tax administration, and the example below computes the same function bit by bit.

The layout rule is simpler than the AEAT one. In a portrait invoice the identifier and the QR go at the very bottom, the identifier on a single line (several lines are allowed, each but the last ending with the hyphen separator) and the QR below it; in a landscape invoice they go at the far right. Unlike VeriFactu, the annex asks for no caption or legend.

package com.bookshelf.billing.qr;

import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;

/** TicketBAI identifier and QR content as described in the annex of the Araba orden foral (id, s, nf, i, cr). */
public final class TicketBaiQr {

    /** Base URL from the annex of the Araba orden foral; Bizkaia and Gipuzkoa publish their own. */
    public static final String ARABA_BASE = "https://ticketbai.araba.eus/tbai/qrtbai/";

    private TicketBaiQr() {
    }

    /** CRC-8, polynomial x^8 + x^2 + x + 1 (0x07), init 0, no reflection, no final XOR; three decimal digits. */
    public static String crc8(String input) {
        int crc = 0;
        for (byte b : input.getBytes(StandardCharsets.UTF_8)) {
            crc ^= b & 0xff;
            for (int bit = 0; bit < 8; bit++) {
                crc = (crc & 0x80) != 0 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
            }
        }
        return String.format("%03d", crc);
    }

    /**
     * TBAI-NIF-DDMMYY-first 13 characters of SignatureValue-CRC. The first 36 characters, including the dash
     * that precedes the CRC, feed the algorithm.
     */
    public static String identifier(String nif, String ddmmyy, String signatureValue) {
        // 36 characters: the separator before the CRC belongs to the CRC input
        String head = "TBAI-" + nif + "-" + ddmmyy + "-" + signatureValue.substring(0, 13) + "-";
        return head + crc8(head);
    }

    /** id, s, nf, i, then cr, which is computed over everything that precedes the cr parameter. */
    public static String url(String baseUrl, String id, String series, String number, BigDecimal total) {
        String content = baseUrl + "?id=" + VerifactuQr.encode(id)
                + "&s=" + VerifactuQr.encode(series)
                + "&nf=" + VerifactuQr.encode(number)
                + "&i=" + total.setScale(2, RoundingMode.HALF_UP).toPlainString();
        return content + "&cr=" + crc8(content);
    }

    public static void main(String[] args) throws Exception {
        // The annex gives this example and says its NIF and signature are not valid, only the format is.
        String example = "TBAI-00000006Y-251019-btFpwP8dcLGAF-";
        System.out.println("annex example CRC (expected 237): " + crc8(example));

        // Fictitious Bookshelf Billing values. The signature text below is an invented placeholder of 13+ characters.
        String id = identifier("00000006Y", "091026", "PLACEHOLDER-SIGNATURE-VALUE");
        String url = url(ARABA_BASE, id, "BB", "0042", new BigDecimal("48.40"));
        System.out.println(id);
        System.out.println(url);
        BitMatrix matrix = QrSupport.encode(url, ErrorCorrectionLevel.M);
        String[] decoded = QrSupport.decode(matrix);
        System.out.println("round trip ok: " + decoded[0].equals(url) + ", level read back: " + decoded[1]);
    }
}

Derived from sections 1, 2 and 4 of the Araba annex, Especificaciones del código TicketBAI y del código QR (the file is named anexo-v-1), and from the Basque Government portal TicketBAI.

The method was validated against the annex itself: the annex gives the example identifier prefix TBAI-00000006Y-251019-btFpwP8dcLGAF- (and says its NIF and signature are not valid, only their format) with the CRC 237, and the example’s main reproduces it, taking the dash before the CRC as part of the 36 characters that feed the algorithm. The invented signature placeholder in the example is not a real signature value, and the resulting identifier and URL are only fictitious illustrations of the format. In a real system the identifier is derived from the signed XML record, which is the subject of the TicketBAI software specifications and not of this page.

EPC QR: SEPA credit transfer

The EPC QR code ("Quick Response Code: Guidelines to Enable the Data Capture for the Initiation of a SEPA Credit Transfer", EPC069-12) is an informative guideline of the European Payments Council. It is optional for payment service providers, it is designed for cases such as an invoice that prints the same payment data in plain text beside the code, and it is not suitable when only the QR is displayed to the payer. Banking apps in the euro area read it to fill a transfer, which makes it popular on invoices as a second QR that must never replace the tax QR.

The payload of version 3.0 (13 September 2022), which is the version read for this page, is a text of lines separated by a line feed or a carriage return plus line feed:

Line Content Notes

1

BCD

service tag

2

001 or 002

version; in 002 the BIC may be empty

3

1

character set; 1 is UTF-8

4

SCT

identification code

5

BIC

8 or 11 characters, mandatory in 001

6

beneficiary name

up to 70 characters

7

IBAN

up to 34 characters, IBAN only

8

amount

EUR followed by the amount, 0.01 to 999999999.99, with a point

9

purpose

up to 4 characters, optional

10

structured remittance reference

up to 35 characters (an ISO 11649 RF creditor reference may be used)

11

unstructured remittance text

up to 140 characters; use either line 10 or line 11, not both

12

beneficiary to originator information

up to 70 characters, optional

The last populated element has no separator after it. The whole payload is limited to 331 bytes (UTF-8 counts bytes, not characters), and the guideline defines the symbol as error-correction level M with a maximum QR version of 13, 69 modules per side. The document’s own example uses the IBAN DE71110220330123456789, which is a flagged sample value and is reused in the code below for that reason only.

package com.bookshelf.billing.qr;

import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/** EPC069-12 version 3.0 "SEPA credit transfer" QR payload, payload version 002 (BIC optional), UTF-8. */
public final class EpcQr {

    private EpcQr() {
    }

    public static String payload(String beneficiaryName, String iban, BigDecimal amountEur, String reference, String unstructured) {
        if (beneficiaryName.isBlank() || beneficiaryName.length() > 70) {
            throw new IllegalArgumentException("name: 1..70 characters");
        }
        String cleanIban = iban.replace(" ", "");
        if (cleanIban.length() > 34) {
            throw new IllegalArgumentException("IBAN: at most 34 characters");
        }
        if (reference != null && !reference.isEmpty() && unstructured != null && !unstructured.isEmpty()) {
            throw new IllegalArgumentException("structured and unstructured remittance are alternatives");
        }
        BigDecimal amount = amountEur.setScale(2, RoundingMode.HALF_UP);
        if (amount.compareTo(new BigDecimal("0.01")) < 0 || amount.compareTo(new BigDecimal("999999999.99")) > 0) {
            throw new IllegalArgumentException("amount must be 0.01..999999999.99");
        }
        List<String> lines = new ArrayList<>(List.of(
                "BCD",                          // service tag
                "002",                          // version 2: BIC may be empty
                "1",                            // character set 1 = UTF-8
                "SCT",                          // identification code
                "",                             // BIC (optional in version 002)
                beneficiaryName,
                cleanIban,
                "EUR" + amount.toPlainString(), // "EUR" + amount with "." separator
                "",                             // purpose code
                reference == null ? "" : reference,
                unstructured == null ? "" : unstructured));
        // the last populated element is not followed by a separator, so trim trailing empty lines
        while (lines.size() > 7 && lines.get(lines.size() - 1).isEmpty()) {
            lines.remove(lines.size() - 1);
        }
        String text = String.join("\n", lines);
        if (text.getBytes(StandardCharsets.UTF_8).length > 331) {
            throw new IllegalArgumentException("payload is limited to 331 bytes");
        }
        return text;
    }

    public static void main(String[] args) throws Exception {
        // DE71110220330123456789 is the example IBAN printed in EPC069-12; it is a sample, not a real account.
        String text = payload("Bookshelf Billing S.L.", "DE71110220330123456789", new BigDecimal("48.40"),
                null, "BB-2026-0042");
        System.out.println(text.replace("\n", "|"));
        System.out.println("bytes: " + text.getBytes(StandardCharsets.UTF_8).length);
        BitMatrix matrix = QrSupport.encode(text, ErrorCorrectionLevel.M);
        String[] decoded = QrSupport.decode(matrix);
        System.out.println("modules per side: " + matrix.getWidth() + ", round trip ok: " + decoded[0].equals(text)
                + ", level read back: " + decoded[1]);
    }
}

Derived from sections 2.1 to 2.3 of EPC069-12 version 3.0, Quick Response Code: Guidelines to Enable the Data Capture for the Initiation of a SEPA Credit Transfer, from the EPC document library entry (the page returned a 403 error to command line tools, open it in a browser), and from the ZXing API linked above.

The example printed an 84-byte payload and a symbol of 41 modules per side (QR version 6), which decoded back to the same text with level M. The EPC guideline does not prescribe a printed size or a placement; national variants of the format exist and change some fields, so a service that must serve one country should read that country’s own document too.

A practical rule for Bookshelf Billing is to print the EPC code in the payment block at the foot of the invoice, with the same data in plain text next to it, and to keep the tax QR at the top.

Swiss QR-bill

The QR-bill is the standard for written invoices and payments in Switzerland and Liechtenstein, defined by the Swiss Implementation Guidelines for the QR-bill published by SIX. Version 2.3 replaces versions 2.2 and 2.1 and is the one this page read. It introduces the structured address as the only address type of the code and widens the permitted character set. Dates, as of 2026-10-09:

  • The guidelines PDF states that version 2.3 is valid from 21 November 2025 and that the introduction takes place with the SIC release of that day. The SIX product page gives 22 November 2025 in one place and 21 November 2025 in another, so this page says "21 or 22 November 2025" and recommends reading the standards page.

  • The same SIX page says that version 2.3 remains valid until November 2027, when version 2.4 takes over, and, in another passage, still lists the introduction of the structured address as scheduled for November 2026. The two statements contradict the guidelines PDF, so they were not confirmed in any other source.

  • Two changes of the document came into force earlier, on 1 January 2024: a chapter on the layout of the QR-bill online and a clarification of how the billing information is printed. A Style Guide version 1.1 is published for 1 January 2026.

The data of the code are lines separated by CR+LF or LF, with the same separator used throughout. All elements must be present as lines, even empty ones, except the elements marked as additional (billing information and the alternative procedures), which are omitted when they and all the following ones are unused. There is no separator after the final element. The elements, in order, are:

Element Content

header

SPC, version 0200 (the only value permitted for main version 02), coding 1 (UTF-8 restricted to the Latin character set)

creditor account

IBAN or QR-IBAN, 21 characters, no spaces, country code CH or LI only

creditor

address type S (structured), name (up to 70), street, building number, postal code, town, country (ISO 3166-1)

ultimate creditor

seven empty lines: the group must be sent and must not be filled in

amount and currency

amount with two decimals and a point (optional, empty for a free amount), currency CHF or EUR

debtor

the same seven elements as the creditor, all empty if the debtor is not stated

reference

type QRR (QR reference, mandatory with a QR-IBAN), SCOR (ISO 11649 creditor reference) or NON, and the reference

additional information

unstructured message (up to 140 characters), trailer EPD, then optional billing information and up to two alternative procedures

The binding parameters for generation are: error-correction level M; at most 997 characters of data including the separators, which produces at most QR version 25 (117 x 117 modules) with level M; a minimum module size of 0.4 mm; a printed size of 46 x 46 mm regardless of the version, excluding the quiet zone, obtained by scaling a vector drawing of the smallest version; a quiet zone of at least four modules (1.6 mm or more) with 5 mm recommended in the design guidelines; and a recognition symbol, a black and white cross of 7 x 7 mm, overlaid in the middle. The permitted combinations of account and reference are an IBAN with SCOR or NON, and a QR-IBAN with QRR.

The next example builds the payload with the sample data of the guidelines (IBAN CH5800791123000889012, creditor reference RF720191230100405JSH0438, and the sample names), draws the code at 46 mm and overlays the cross. It does not draw the payment part, the receipt and the layout of the 105 mm high slip with its mandatory headings, which the guidelines and the Style Guide describe at length and which is not reproduced here. A project that must print the full slip should use either a dedicated library, after checking that it supports version 2.3, or the design rules of the Style Guide. The amount of the example, 48.40, is the invoice total.

package com.bookshelf.billing.qr;

import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;

import java.math.BigDecimal;
import java.math.RoundingMode;

import static com.bookshelf.billing.qr.QrSupport.MM;

/** Swiss QR code payload (Implementation Guidelines v2.3, structured addresses only) and its 46 x 46 mm drawing. */
public final class SwissQrBill {

    record Party(String name, String street, String houseNo, String postalCode, String town, String country) {
        String[] lines() {
            return new String[] {"S", name, street, houseNo, postalCode, town, country};
        }
    }

    private SwissQrBill() {
    }

    static String payload(String iban, Party creditor, BigDecimal amount, String currency, Party debtor,
                          String referenceType, String reference, String message) {
        StringBuilder sb = new StringBuilder();
        append(sb, "SPC", "0200", "1", iban.replace(" ", ""));
        append(sb, creditor.lines());
        append(sb, "", "", "", "", "", "", "");                       // ultimate creditor: must stay empty
        append(sb, amount == null ? "" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString(), currency);
        append(sb, debtor == null ? new String[] {"", "", "", "", "", "", ""} : debtor.lines());
        append(sb, referenceType, reference == null ? "" : reference, message == null ? "" : message, "EPD");
        // no billing information, no alternative procedures: the QR code ends after the trailer lines
        sb.setLength(sb.length() - 2);                               // drop the final CRLF
        return sb.toString();
    }

    private static void append(StringBuilder sb, String... lines) {
        for (String line : lines) {
            sb.append(line).append("\r\n");
        }
    }

    public static void main(String[] args) throws Exception {
        // IBAN, reference and names are the sample values of the SIX Implementation Guidelines v2.3 (annex A).
        String payload = payload("CH5800791123000889012",
                new Party("Max Muster & Soehne (sample company)", "Musterstrasse", "123", "8000", "Seldwyla", "CH"),
                new BigDecimal("48.40"), "CHF",
                new Party("Sarah Beispiel", "Musterstrasse", "1", "8000", "Seldwyla", "CH"),
                "SCOR", "RF720191230100405JSH0438", "Invoice BB-2026-0042");
        System.out.println(payload.replace("\r\n", "|"));
        System.out.println("characters: " + payload.length() + " (limit 997)");

        BitMatrix matrix = QrSupport.encode(payload, ErrorCorrectionLevel.M);
        String[] decoded = QrSupport.decode(matrix);
        System.out.println("modules per side: " + matrix.getWidth() + ", level read back: " + decoded[1]
                + ", round trip ok: " + decoded[0].equals(payload));

        try (PdfDocument pdf = new PdfDocument(new PdfWriter("swiss-qr-bill.pdf"))) {
            PdfCanvas canvas = new PdfCanvas(pdf.addNewPage(PageSize.A4));
            float x = 67 * MM;
            float y = 20 * MM;
            QrSupport.drawVector(canvas, matrix, x, y, 46f);
            // the recognition cross: black 7 x 7 mm square with a white cross, centred on the symbol
            float cx = x + 23 * MM;
            float cy = y + 23 * MM;
            canvas.saveState()
                    .setFillColor(ColorConstants.WHITE).rectangle(cx - 4.5f * MM, cy - 4.5f * MM, 9 * MM, 9 * MM).fill()
                    .setFillColor(ColorConstants.BLACK).rectangle(cx - 3.5f * MM, cy - 3.5f * MM, 7 * MM, 7 * MM).fill()
                    .setFillColor(ColorConstants.WHITE)
                    .rectangle(cx - 0.6f * MM, cy - 2.8f * MM, 1.2f * MM, 5.6f * MM)
                    .rectangle(cx - 2.8f * MM, cy - 0.6f * MM, 5.6f * MM, 1.2f * MM).fill()
                    .restoreState();
        }
        System.out.println("written swiss-qr-bill.pdf");
    }
}

Derived from sections 4 and 6 of the Implementation Guidelines, Swiss Implementation Guidelines for the QR-bill, version 2.3, the SIX QR-bill standards page and the Style Guide.

The example printed a payload of 258 characters, a symbol of 65 modules per side (module size about 0.71 mm at 46 mm, above the 0.4 mm minimum), and decoded back to the same text with level M. A separate check overlaid a 7 x 7 mm cross on the matrix, with a white arm of 1.2 mm, and decoded it successfully; the cross covers a small fraction of the modules, well within what level M can restore. The cross drawn by the example is a simplified shape, and a real slip should use the official logo file that SIX offers in its download centre. The page did not run the SIX validation tools, and the printed slip must be checked with them.

Choosing between the codes on one invoice

An invoice can legitimately carry several of these codes, and the order matters.

  • The Spanish tax QR goes first, at the top of the first page, with its caption, and nothing else may displace it. In the Basque territories the TicketBAI QR and identifier go at the bottom or the right edge instead.

  • A payment QR (EPC for the euro area, Swiss for Switzerland and Liechtenstein) belongs to the payment block at the foot of the page, with the payment data also in plain text and a heading that tells the reader what it is.

  • Do not put the tax data into the payment code or the payment data into the tax code: each has a fixed content that the receiving service validates.

  • Keep the code generator in one class that records which rule set and which date it implements, so that a change of the Spanish decree, of the Swiss guidelines or of the EPC document is a controlled change. The example classes above name the source document they implement in their Javadoc for this reason.

  • Test with the receiving tools: the AEAT pre-production environment, a banking app for EPC and Swiss codes, and the SIX validation tools for the QR-bill, and keep the printed sample of each release.

Outdated points to avoid

This page has no outdated finding of the source analysis assigned to it, but the following points are frequent in older articles and in tutorials.

  1. The VeriFactu dates of 1 January and 1 July 2026, and the dates of the early drafts of the Reglamento: replaced by the dates above, and possibly by a later change.

  2. Treating the announced postponement to October 2028 as law: as of 2026-10-09 the sources read here call it a forecast.

  3. An AEAT code generated with error-correction level L, or with a size outside 30 to 40 mm: the specification requires level M, and its change log records that the examples of version 0.4.1 were corrected from level L to M.

  4. A VeriFactu QR without the "QR tributario:" caption, or the legend on a non-verifiable invoice: the legend is for verifiable invoices, the caption is always required.

  5. Concatenating the URL without encoding the series text, or formatting the amount with a locale-dependent comma.

  6. A Swiss QR code with an unstructured (K) or combined address: version 2.3 supports only the structured address, and the invoice must send the address elements separately.

  7. Swiss code sizes that change with the QR version: the printed size is always 46 x 46 mm, so scale a vector drawing of the smallest version.

  8. EPC codes with a BIC for every transfer: in version 002 of the payload the BIC is optional.

  9. Raster images of the QR in the invoice generated at screen resolution: draw vector squares or generate an image with enough resolution for a printer.