From hash to mTLS submission: anatomy of a VeriFactu record

The previous post covered the SHA-256 hash chain. This one covers the rest: what the full XML looks like, why AEAT uses XMLDSig instead of XAdES, how to configure mTLS in .NET, which error codes you'll actually encounter, and how to handle retries without creating duplicate registrations.

Server and API data exchange
Photo by Deng Xiang on Unsplash

The full flow

A VeriFactu submission goes through six stages:

  1. Build the RegistroFacturacion XML block with the invoice data.
  2. Compute the SHA-256 hash of the canonical string (covered in the previous post) and embed it as HuellaAnterior in the EncadenandoRegistro element of the next record.
  3. Wrap the record in a SuministroLRFacturasEmitidas envelope and sign the whole document with XMLDSig using the client's certificate.
  4. Send via HTTPS with mTLS (the client certificate authenticates the connection at the transport layer).
  5. Parse the synchronous AEAT response: either an acceptance, a partial acceptance, or an error per record.
  6. Persist the hash of the sent record so it can be chained into the next submission.

Steps 2 and 6 are where most implementations fall down. We'll cover them explicitly below.


The XML structure

A high-registration (alta) VeriFactu submission has this shape. We've kept it to the mandatory fields; optional elements (breakdown by recipient, settlement data, etc.) are documented in the AEAT spec.

<?xml version="1.0" encoding="UTF-8"?>
<T:SuministroLRFacturasEmitidas
    xmlns:T="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd">

  <T:Cabecera>
    <T:ObligadoEmision>
      <T:NombreRazon>Empresa Ejemplo, S.L.</T:NombreRazon>
      <T:NIF>B12345678</T:NIF>
    </T:ObligadoEmision>
  </T:Cabecera>

  <T:RegistroFacturacion>

    <T:IDVersion>1.0</T:IDVersion>

    <T:IDFactura>
      <T:IDEmisorFactura>B12345678</T:IDEmisorFactura>
      <T:NumSerieFactura>2026-00042</T:NumSerieFactura>
      <T:FechaExpedicionFactura>09-05-2026</T:FechaExpedicionFactura>
    </T:IDFactura>

    <T:NombreRazonEmisor>Empresa Ejemplo, S.L.</T:NombreRazonEmisor>

    <T:TipoFactura>F1</T:TipoFactura>

    <T:DescripcionOperacion>Servicios de consultoría mayo 2026</T:DescripcionOperacion>

    <T:Destinatarios>
      <T:IDDestinatario>
        <T:NombreRazon>Cliente Receptor, S.A.</T:NombreRazon>
        <T:NIF>A87654321</T:NIF>
      </T:IDDestinatario>
    </T:Destinatarios>

    <T:Desglose>
      <T:DesgloseFactura>
        <T:Sujeta>
          <T:NoExenta>
            <T:TipoNoExenta>S1</T:TipoNoExenta>
            <T:DesgloseIVA>
              <T:DetalleIVA>
                <T:TipoImpositivo>21.00</T:TipoImpositivo>
                <T:BaseImponible>1000.00</T:BaseImponible>
                <T:CuotaRepercutida>210.00</T:CuotaRepercutida>
              </T:DetalleIVA>
            </T:DesgloseIVA>
          </T:NoExenta>
        </T:Sujeta>
      </T:DesgloseFactura>
    </T:Desglose>

    <T:CuotaTotal>210.00</T:CuotaTotal>
    <T:ImporteTotal>1210.00</T:ImporteTotal>

    <T:Encadenamiento>
      <T:EncadenandoRegistro>
        <T:IDEmisorFacturaAnterior>B12345678</T:IDEmisorFacturaAnterior>
        <T:NumSerieFacturaAnterior>2026-00041</T:NumSerieFacturaAnterior>
        <T:FechaExpedicionFacturaAnterior>08-05-2026</T:FechaExpedicionFacturaAnterior>
        <T:HuellaAnterior>3C9D2A1F...</T:HuellaAnterior>
      </T:EncadenandoRegistro>
    </T:Encadenamiento>

    <T:SistemaInformatico>
      <T:NombreRazon>Kreyo</T:NombreRazon>
      <T:NIF>B-KREYO-NIF</T:NIF>
      <T:NombreSistemaInformatico>Kreyo VeriFactu API</T:NombreSistemaInformatico>
      <T:IdSistemaInformatico>KREYO-VF</T:IdSistemaInformatico>
      <T:Version>1.0</T:Version>
      <T:NumeroInstalacion>1</T:NumeroInstalacion>
      <T:TipoUsoPosibleSoloVerifactu>S</T:TipoUsoPosibleSoloVerifactu>
      <T:TipoUsoPosibleMultiOT>N</T:TipoUsoPosibleMultiOT>
      <T:IndicadorMultiplesOT>N</T:IndicadorMultiplesOT>
    </T:SistemaInformatico>

    <T:FechaHoraHusoGenRegistro>2026-05-09T10:30:00+02:00</T:FechaHoraHusoGenRegistro>

    <T:TipoHuella>01</T:TipoHuella>
    <T:Huella>A4C8E2B1...</T:Huella>

  </T:RegistroFacturacion>

</T:SuministroLRFacturasEmitidas>

Cancellation records

A cancellation (anulación) uses a different element type: SuministroLRFacturasEmitidas contains a RegistroAnulacion instead of RegistroFacturacion. It references the original invoice by IDEmisorFactura, NumSerieFactura, and FechaExpedicionFactura, plus its own chain link. The cancellation itself also gets chained.


XMLDSig, not XAdES

This surprises developers who have worked with FacturaE, which uses XAdES-EPES. VeriFactu uses plain XMLDSig (XML Signature Syntax and Processing, W3C standard).

Why the difference? XAdES is a profile of XMLDSig that adds long-term archival properties: timestamp tokens, certificate revocation data embedded in the signature, validation data for future proofing. That makes sense for FacturaE because the signed document itself is the legally binding invoice; it has to be verifiable years from now without contacting any external service.

VeriFactu is different in nature. The registration record is not the invoice; it's a real-time notification to AEAT. AEAT validates it immediately on receipt, stores it, and issues its own acknowledgement. The signed document doesn't need to be self-contained for future verification because AEAT holds the authoritative copy. XMLDSig is simpler, faster to produce, and sufficient for this use case.

The signature covers the RegistroFacturacion element (or RegistroAnulacion), referenced by a URI with an enveloped transform. The certificate used to sign is the client's own certificate, the same one that authenticates the mTLS connection at the transport layer.

// C# — signing a VeriFactu document with XMLDSig
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Xml;

static void SignVeriFactuDocument(XmlDocument doc, X509Certificate2 cert)
{
    var signedXml = new SignedXml(doc)
    {
        SigningKey = cert.GetRSAPrivateKey()
    };

    // Reference the RegistroFacturacion element by ID attribute
    var reference = new Reference("#registro-0");
    reference.AddTransform(new XmlDsigEnvelopedSignatureTransform());
    reference.AddTransform(new XmlDsigC14NTransform());
    signedXml.AddReference(reference);

    // Embed the signing certificate
    var keyInfo = new KeyInfo();
    keyInfo.AddClause(new KeyInfoX509Data(cert));
    signedXml.KeyInfo = keyInfo;

    signedXml.ComputeSignature();

    // Append the Signature element to the document root
    doc.DocumentElement!.AppendChild(
        doc.ImportNode(signedXml.GetXml(), true)
    );
}

mTLS: why it exists and how to configure it in .NET

AEAT requires mutual TLS for the VeriFactu web service endpoint. This means both the server (AEAT) and the client (your software) present certificates during the TLS handshake. The client certificate is the same certificate used to sign the XML — the client's own tax identity certificate.

This is deliberate. mTLS authenticates the connection at the transport layer, before any application-level processing. AEAT can verify who is sending data without inspecting the XML payload. It also makes spoofing a submission much harder than with a simple API key.

// C# — configuring an HttpClient for mTLS
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

static HttpClient BuildVerifactuClient(X509Certificate2 clientCert)
{
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(clientCert);

    // If AEAT's server cert is from the FNMT root CA (not trusted by default on some servers)
    handler.ServerCertificateCustomValidationCallback = (_, cert, chain, errors) =>
    {
        // In production: validate against the FNMT root CA explicitly
        // For development: return true to bypass (never in production)
        return errors == SslPolicyErrors.None;
    };

    return new HttpClient(handler)
    {
        BaseAddress = new Uri("https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/")
    };
}

AEAT error codes you'll actually encounter

AEAT's response is synchronous and per-record. Each RegistroFacturacion in the request gets a corresponding result in the response. The main status codes:

Code Meaning What to do
Correcto Record accepted Persist the hash. Chain into next record.
AceptadoConErrores Accepted with warnings (non-blocking) Log warnings. Record is registered. Still persist the hash.
Incorrecto Record rejected Do not persist the hash. Fix the error, resubmit.
1105 Registro ya existente (duplicate) Check if the previously sent record was accepted. If yes, no action needed; treat it as success.
1108 Huella incorrecta (hash mismatch) Your HuellaAnterior doesn't match AEAT's stored hash. Indicates a chain break; investigate.
1200 NIF del emisor no reconocido The issuer NIF is not registered with AEAT. Verify the NIF and its fiscal status.
3001 Certificado inválido o caducado The signing certificate has expired or been revoked. Renew the certificate.

Error 1108 deserves special attention. It means your local chain state and AEAT's chain state have diverged. This can happen if: a previous submission was accepted by AEAT but you didn't receive the acknowledgement (timeout, network drop); you're running multiple instances that share invoice numbering without coordinating chain state; or you reset your chain state accidentally.

To recover from a chain break, you need to query AEAT for the last accepted hash for your NIF and resume the chain from that point.


Idempotency and retries

AEAT's web service endpoint can return a network timeout without having processed the request. If you retry naively, you risk two outcomes:

Error 1105 (duplicate) is therefore not an error; it's a confirmation that the original request was accepted. Your retry logic should treat 1105 as a success. Query the hash of the previously submitted record and use it to continue the chain.

The safe retry pattern:

  1. Before sending, persist the submission locally with status Pending.
  2. Send to AEAT.
  3. On success: update status to Accepted, persist the hash.
  4. On timeout or network error: retry. If AEAT returns 1105, treat as success.
  5. On Incorrecto: update status to Rejected. Fix and resubmit as a new record (same invoice number, corrected data, which AEAT treats as a substitution).

What we learned building this

Three things the official documentation doesn't warn you about:

The timestamp must include timezone offset. FechaHoraHusoGenRegistro must be in ISO 8601 format with explicit UTC offset: 2026-05-09T10:30:00+02:00. Sending it as UTC (Z suffix) causes validation errors in some AEAT environments even though it's technically valid ISO 8601.

The FNMT root CA is not in the default trust stores of major cloud providers. AWS, Azure, and GCP don't include FNMT (Spain's Fábrica Nacional de Moneda y Timbre) in their default certificate stores. Your mTLS client needs to explicitly trust the FNMT root or you'll get handshake failures that look like "untrusted certificate" errors.

The preproduction endpoint behaves differently from production. Some error codes that preproduction returns do not appear in production and vice versa. Always test in preproduction with realistic data volumes: single-record tests pass when bulk submissions with chain state errors do not.