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.
The full flow
A VeriFactu submission goes through six stages:
- Build the
RegistroFacturacionXML block with the invoice data. - Compute the SHA-256 hash of the canonical string (covered in the previous post) and embed it as
HuellaAnteriorin theEncadenandoRegistroelement of the next record. - Wrap the record in a
SuministroLRFacturasEmitidasenvelope and sign the whole document with XMLDSig using the client's certificate. - Send via HTTPS with mTLS (the client certificate authenticates the connection at the transport layer).
- Parse the synchronous AEAT response: either an acceptance, a partial acceptance, or an error per record.
- 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:
- The first request was processed → retry produces a duplicate → AEAT returns error
1105. - The first request was not processed → retry succeeds → all good.
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:
- Before sending, persist the submission locally with status
Pending. - Send to AEAT.
- On success: update status to
Accepted, persist the hash. - On timeout or network error: retry. If AEAT returns
1105, treat as success. - On
Incorrecto: update status toRejected. 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.