Spain — Verifactu, TicketBAI, SII
Research doc for the Spain fiscalization adapter. Internal reference for
engineers maintaining or extending rust/adapters/spain/.
Status: Implemented (legacy Phase-0 wedge). Adapter at
rust/adapters/spain/ (crate spain-adapters).
Strategic context: Spain is the most complex country we support. A
Spanish merchant can be subject to any of three different tax regimes
depending on (a) location (Basque country vs. territorio común) and
(b) size (large enterprise vs. SME). The adapter handles all three —
Verifactu (national, RD 1007/2023), TicketBAI (Basque, three provincial
variants), and SII (large-business near-real-time VAT books) — through a
single Adapter struct that routes on country_config.system. Each
sub-system has its own authority interface, schema, and submission
window. See apps/docs-internal/docs/ops/country-prioritization.md and
the per-system sandbox docs at
apps/docs-internal/docs/countries/spain-sandbox.md.
1. Regulatory scope
Three coexisting regimes
| Regime | Operator | Scope | Mandatory date |
|---|---|---|---|
| Verifactu | AEAT (Agencia Estatal de Administración Tributaria) | National per-receipt fiscalization for all software-issued invoices in territorio común | 2026-01-01 large enterprises, 2026-07-01 all others (per RD 1007/2023 + Orden HFP/1177/2024) |
| TicketBAI | Three provincial Haciendas Forales (Bizkaia, Gipuzkoa, Araba) | Per-receipt fiscalization for taxpayers domiciled in the Basque country | 2022-01-01 Gipuzkoa (phased), 2024-01-01 Bizkaia (LROE), 2022-04-01 Araba (phased) — full scope today |
| SII | AEAT | Near-real-time VAT books for large enterprises (Suministro Inmediato de Información) | 2017-07-01 mandatory for VAT-monthly taxpayers (turnover > €6M, VAT groups, REDEME registrants) |
The three regimes have non-overlapping target populations by design (a Basque merchant uses TicketBAI, a non-Basque large-enterprise uses SII, a non-Basque SME uses Verifactu) but the wire formats and authentication surfaces have nothing in common. Our adapter implements each as an independent module with shared scaffolding.
Verifactu — who must report
Per Real Decreto 1007/2023:
- Any taxpayer issuing invoices through software in territorio común must use a Sistema Informático de Facturación (SIF) that complies with the Verifactu specification.
- Two operating modes:
- Sistema Verificable (Verifactu) — invoices submitted to AEAT; a Verifactu-mode SIF that submits real-time gets a tax-ledger benefit (no further AEAT inspection of the chain).
- Sistema Verificable No-Verifactu — invoices retained locally in a tamper-evident chain; AEAT can demand the chain on inspection. Lower wire-protocol cost but greater audit risk.
- Our adapter targets Verifactu mode (the default) — invoices are submitted at issuance.
The QR code on every invoice (mandatory from 2026-01-01) is a validation URL pointing at AEAT's QR-validator endpoint.
TicketBAI — who must report
Per Norma Foral of each Basque province (NF 4/2018 Bizkaia, NF 1/2022 Gipuzkoa, NF 18/2020 Araba) and their implementing regulations:
- Any taxpayer with tax domicile in the Basque country issuing invoices through software must use a TicketBAI-certified SIF.
- The SIF signs each invoice with an XAdES-EPES enveloped signature using the merchant's Basque-government-issued signing certificate.
- The signed XML is submitted real-time to the provincial Hacienda Foral, except in Bizkaia where it goes through LROE (Libro Registro de Operaciones Económicas) batch — see §6.
SII — who must report
Per Real Decreto 596/2016 and AEAT Orden HAP/1626/2017:
- Mandatory for taxpayers in the VAT-monthly regime: turnover above €6,010,121.04, VAT-grouped entities, REDEME (Registro de Devolución Mensual) registrants.
- Reports VAT books (libros registro) — not individual receipts. Categories: issued invoices, received invoices, capital goods investment, intra-EU operations.
- Submission window: 4 calendar days from invoice issuance (issued book) or registration (received book). Excludes Saturdays, Sundays, and national holidays for the calculation.
Thresholds
- Verifactu: no transaction-amount threshold. All software-issued invoices in scope.
- TicketBAI: no transaction-amount threshold. All invoices in scope.
- SII: turnover threshold of €6,010,121.04 to be VAT-monthly; below that the merchant is on quarterly returns and SII is voluntary.
2. API surface
2.1 Verifactu — SOAP over HTTPS, mTLS
Endpoints (rust/adapters/spain/src/verifactu/client.rs:1-11):
| Variant | Sandbox | Production |
|---|---|---|
| Standard | https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP | https://www1.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP |
| Seal (sello electrónico) | https://prewww10.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP | https://www10.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP |
The "seal" variant (www10 / prewww10) is for sello electrónico
auth (organisation seal cert) rather than a personal cert.
SubmitOptions { sandbox, seal } selects via select_endpoint
(client.rs:168-176).
Operations: SOAP body wraps a RegFactuSistemaFacturacion
operation with one or more RegistroFactura records (Alta or
Anulacion). One submission can carry up to ~1,000 records.
Wire format: SOAP 1.1, Content-Type: text/xml; charset=utf-8,
SOAPAction: "" empty (client.rs:85-87). The HTTP client uses
mTLS; the cert is the merchant's certificado digital issued by FNMT
(Fábrica Nacional de Moneda y Timbre) or another AEAT-approved CA.
2.2 QR validator — public GET
QR base URL (verifactu/qr.rs:1-3):
| Environment | URL |
|---|---|
| Production | https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR |
| Sandbox | https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR |
The QR encoded on the receipt is a GET URL with query parameters:
{base}?nif={NIF}&numserie={InvoiceNumber}&fecha={DD-MM-YYYY}&importe={Total}
numserie is URL-encoded (slashes → %2F, spaces → %20,
qr.rs:27-37). End-customers scan the QR; AEAT's web validator
returns a yes/no plus the invoice metadata.
2.3 TicketBAI — provincial endpoints
Each Basque province operates its own endpoint
(ticketbai/lroe_client.rs:7-11):
| Territory | Sandbox | Production |
|---|---|---|
| Bizkaia (LROE) | https://pruebas.batuz.eus/e-service/LROE | https://www.batuz.eus/e-service/LROE |
| Gipuzkoa | https://pruebas-egoitza.gipuzkoa.eus/ogasuna/lroe/cgi-bin/lroe | https://egoitza.gipuzkoa.eus/ogasuna/lroe/cgi-bin/lroe |
| Araba | https://pruebas.araba.eus/tax/lroe | https://www.araba.eus/tax/lroe |
Different provinces, different base URLs, different XML schema variants — but all three accept an XAdES-signed TicketBAI XML over mTLS-protected HTTP POST. Bizkaia is special: it routes through LROE (Libro Registro de Operaciones Económicas), a batch-mode register, whereas Gipuzkoa and Araba accept individual TicketBAI submissions in real time.
The Bizkaia split is reflected in the submission-window mapping
(lib.rs:80-100):
"ticketbai" => match territory {
Some(t) => {
let t_lower = t.to_lowercase();
if t_lower == "bizkaia" || t_lower == "48" {
SubmissionWindow::Days(1) // LROE batch, daily
} else {
SubmissionWindow::Immediate // Gipuzkoa / Araba
}
}
None => SubmissionWindow::Immediate,
}
2.4 QR validator — TicketBAI (per province)
Per ticketbai/qr.rs:4-12:
| Territory | Sandbox | Production |
|---|---|---|
| Araba | https://pruebas-ticketbai.araba.eus/tbai/qrtbai/ | https://ticketbai.araba.eus/tbai/qrtbai/ |
| Bizkaia | https://batuz.eus/QRTBAI/ (no separate sandbox) | https://batuz.eus/QRTBAI/ |
| Gipuzkoa | https://tbai.prep.gipuzkoa.eus/qr/ | https://tbai.egoitza.gipuzkoa.eus/qr/ |
Bizkaia uses the same URL for sandbox and production — sandbox disambiguation happens via the test cert.
2.5 SII — SOAP over HTTPS
Per sii/client.rs:1-5:
| Environment | Base URL |
|---|---|
| Production | https://www1.agenciatributaria.gob.es/wlpl/SSII-FACT/ws/ |
| Sandbox | https://www7.aeat.es/wlpl/SSII-FACT/ws/ |
Two book-paths:
| Book | Path | Constant (sii/client.rs:4-5) |
|---|---|---|
| Issued | fe/SiiFactFEV2SOAP | PATH_ISSUED |
| Received | fr/SiiFactFRV2SOAP | PATH_RECEIVED |
Plus a separate cancellation book (build_cancellation_xml in
sii/xml.rs). Same SOAP envelope as Verifactu, mTLS, FNMT or
equivalent cert.
3. Authentication
3.1 Verifactu auth
mTLS only. No bearer tokens, no signed XML at the application layer (the SOAP envelope is signed via the TLS handshake).
The cert is a merchant-held certificado digital de persona física or de representante de persona jurídica — issued by FNMT-RCM via in-person enrollment at any oficina de registro. Validity is typically 2 years for individuals, 4 years for representatives.
The adapter wraps the cert + key as a single PEM in
HttpClient::with_mtls (verifactu/client.rs:52-64):
pub fn with_mtls(endpoint: &str, pem: &[u8]) -> Result<Self, ClientError> {
let identity = reqwest::Identity::from_pem(pem)...
}
The RoutingClient (client.rs:118-166) caches per-endpoint
HttpClients so TLS sessions are reused across requests.
3.2 Verifactu chain integrity
The Verifactu wire protocol does not require XML signing — the
chain is enforced via the Huella (hash) field on each RegistroAlta
record. The hash is SHA-256 over a canonical key=value string
(verifactu/hash.rs:8-23):
IDEmisorFactura={NIF}
&NumSerieFactura={number}
&FechaExpedicionFactura={DD-MM-YYYY}
&TipoFactura={F1|F2|R1|R2|R3|R4|R5}
&CuotaTotal={tax_total}
&ImporteTotal={invoice_total}
&Huella={previous_record_hash_or_empty_for_first}
&FechaHoraHusoGenRegistro={ISO 8601 with offset}
The output is uppercase hex SHA-256 (note the {:02X} formatter
at hash.rs:43-47), 64 characters. The first record's Huella field
is empty.
Cancellation records (compute_anulacion_hash, hash.rs:29-41) use
a different key set:
IDEmisorFacturaAnulada={NIF}&NumSerieFacturaAnulada={number}
&FechaExpedicionFacturaAnulada={date}&Huella={prev}
&FechaHoraHusoGenRegistro={ts}
Test vectors at testdata/conformance/spain/verifactu/golden/ hash-test-vectors.json exercise both forms. The case 1 vector
(empty prev hash) produces
3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60
(hash.rs:91-107).
3.3 TicketBAI auth — XAdES-EPES + mTLS
Two layers:
- XAdES-EPES enveloped signature on the submission XML, using the merchant's Basque-government-issued signing certificate. The signature is a standards-compliant XML-DSig with XAdES extensions for signature-policy-id and signing-time.
- mTLS to the provincial endpoint.
The signing implementation is in
rust/adapters/spain/src/ticketbai/signature.rs
(Signer::sign, signature.rs:53-80). It:
- Computes SHA-256 of the canonical XML body (C14N-2001-03-15).
- Builds the
<xades:SignedProperties>block with cert digest, signing time, and the per-province signature policy (URL + base64-encoded SHA-256 of the published policy PDF). - Builds
<ds:SignedInfo>referencing both the document and the SignedProperties. - Signs
SignedInfovia RSA-SHA256 (PKCS#1 v1.5) using the merchant's RSA private key (4096-bit typical). - Emits the full
<ds:Signature>block enveloped in the XML root.
Per-province signature policy (signature.rs:23-42):
| Territory | Policy identifier | Digest base64 |
|---|---|---|
| Araba | https://ticketbai.araba.eus/tbai/sinadura/ | 4Vk3uExj7tGn9DyUCPDsV9HRmK6KZfYdRiW3StOjcQA= |
| Bizkaia | https://www.batuz.eus/.../sinadura_elektronikoaren_zehaztapenak_v1_1.pdf | K2baIY0fk8jbkPHkffk5F5C46O5VuzDwH21dAovjVRs= |
| Gipuzkoa | https://www.gipuzkoa.eus/ticketbai/sinadura | vSe1CH7eAFVkGN0X2Y7Nl9XGUoBnziDA5BGUSsyt8mg= |
Important: the signature-policy-identifier and digest are
baked-in constants for each province. If the provincial
authority publishes a new revision of the policy PDF, the digest
must be updated in signature.rs:23-42 and a new release shipped.
3.4 SII auth
Same surface as Verifactu — mTLS only, FNMT or equivalent cert.
No XML signing required at the application layer; SII responds with
a per-record acceptance status (Correcto / AceptadoConErrores /
Rechazado).
4. Payload shape
4.1 Verifactu — RegistroAlta and RegistroAnulacion
Built via verifactu::build_alta_xml (verifactu/xml.rs). Top-level:
soapenv:Envelope
└── soapenv:Body
└── sum:RegFactuSistemaFacturacion
├── sum:Cabecera (issuer NIF + name)
└── sum:RegistroFactura
└── sum1:RegistroAlta | sum1:RegistroAnulacion
├── IDVersion=1.0
├── IDFactura (IDEmisorFactura, NumSerieFactura, FechaExpedicionFactura)
├── NombreRazonEmisor
├── TipoFactura (F1, F2, R1..R5)
├── DescripcionOperacion
├── Desglose (per-rate breakdown: ClaveRegimen, CalificacionOperacion, TipoImpositivo, BaseImponible, CuotaRepercutida)
├── ImporteTotal, CuotaTotal
├── Encadenamiento (PrimerRegistro="S" or RegistroAnterior with prev hash)
├── SistemaInformatico (NombreRazon, NIF, NombreSistemaInformatico, IdSistemaInformatico, Version, NumeroInstalacion, TipoUsoPosibleSoloVerifactu, ...)
├── FechaHoraHusoGenRegistro (ISO 8601 with offset)
├── TipoHuella=01 (SHA-256)
└── Huella (uppercase hex SHA-256, computed per §3.2)
The adapter populates the registro from a Transaction at
lib.rs:102-280, defaulting TipoFactura=F1 (standard invoice) and
the regime_key="01" / qualification="S1" (subject and not exempt,
ordinary regime). For richer cases — corrective invoices (R1..R5),
foreign recipients, simplified-invoice flag — the
RegistroAlta struct (verifactu/types.rs:4-38) carries the
optional fields but the basic prepare path doesn't fill them.
Software block requires:
Nombre— software product name.NIF— software vendor's NIF.NombreSistemaInformatico— system display name.IdSistemaInformatico— 2-char alphanumeric system ID (we useZYNTEM01by default inlib.rs:206-217).Version— semantic version of the SIF.NumeroInstalacion— per-install counter.TipoUsoPosibleSoloVerifactu—Sif SIF only operates in Verifactu mode,Notherwise. We sendS(lib.rs:215).
4.2 TicketBAI — T:TicketBai
Built via ticketbai::XmlBuilder::build_invoice_xml
(ticketbai/xml.rs). Top-level:
T:TicketBai (xmlns:T)
├── Cabecera (IDVersionTBAI=1.2)
├── Sujetos
│ ├── Emisor (NIF, ApellidosNombreRazonSocial)
│ └── Destinatarios (one per buyer; absent for FacturaSimplificada)
├── Factura
│ ├── CabeceraFactura (SerieFactura, NumFactura, FechaExpedicionFactura, HoraExpedicionFactura, FacturaSimplificada=S|N, ...)
│ ├── DatosFactura (DescripcionFactura, DetallesFactura with line items, ImporteTotalFactura)
│ └── TipoDesglose
│ └── DesgloseFactura | DesgloseTipoOperacion
│ └── Sujeta / NoSujeta
│ └── NoExenta (TipoNoExenta=S1) → DetalleNoExenta (TipoImpositivo, BaseImponible, CuotaImpuesto)
├── HuellaTBAI
│ ├── EncadenamientoFacturaAnterior (only if not first)
│ │ ├── SerieFacturaAnterior
│ │ ├── NumFacturaAnterior
│ │ ├── FechaExpedicionFacturaAnterior
│ │ └── SignatureValueFirmaFacturaAnterior (first 100 chars of prev signature value)
│ └── Software (LicenciaTBAI, EntidadDesarrolladora.NIF, Nombre, Version)
└── ds:Signature (XAdES-EPES enveloped)
The chain is anchored by SignatureValueFirmaFacturaAnterior —
the first 100 characters of the previous invoice's
<ds:SignatureValue>. A new signature signs over the new XML body
including this back-reference, so tampering with any prior invoice
breaks the chain.
4.3 TBAI ID and CRC-8
Each TBAI invoice carries a TBAI- ID computed locally
(ticketbai/tbai_id.rs:6-11):
TBAI-{NIF(9)}-{DDMMYY(6)}-{SignatureValue[0:13]}-{CRC8(prefix, 3 digits)}
E.g., TBAI-00000006Y-251019-btFpwP8dcLGAF-237.
The CRC-8 is the SMBus polynomial (0x07, init 0x00, no xor)
implemented as a 256-byte lookup table (ticketbai/crc8.rs). It
hashes the prefix (everything before the trailing 3-digit CRC,
including the trailing -).
4.4 TicketBAI QR
Built via generate_qr_url (ticketbai/qr.rs:14-30):
{territorial_base}?id={tbai_id}&s={series}&nf={invoice_number}&i={total}&cr={CRC8(url_without_cr):03}
The cr query param is another CRC-8, this time over the entire
URL up to (but not including) the &cr= suffix. Two CRC-8s in a
single TBAI flow — one in the TBAI ID, one in the QR — and they are
not the same input. Don't reuse computations.
4.5 SII — LRFacturasEmitidas / LRFacturasRecibidas
Built via sii::build_issued_invoice_xml and build_received_invoice_xml
(sii/xml.rs). Top-level:
soapenv:Envelope
└── soapenv:Body
└── siiLR:SuministroLRFacturasEmitidas
├── sii:Cabecera (IDVersionSii=1.1, Titular, TipoComunicacion=A0|A1|A2|A4)
└── siiLR:RegistroLRFacturasEmitidas
├── sii:PeriodoLiquidacion (Ejercicio, Periodo)
├── siiLR:IDFactura (IDEmisorFactura, NumSerieFacturaEmisor, FechaExpedicionFacturaEmisor)
└── siiLR:FacturaExpedida
├── TipoFactura=F1|F2|F3|F4|R1|R2|R3|R4|R5
├── ClaveRegimenEspecialOTrascendencia=01..16
├── ImporteTotal
├── DescripcionOperacion
├── Contraparte (counterparty NIF / ID)
└── TipoDesglose → DesgloseFactura → Sujeta/NoSujeta...
TipoComunicacion (sii/types.rs:18):
| Code | Meaning |
|---|---|
A0 | initial submission (alta) |
A1 | substitution / correction of a previously-submitted record |
A2, A4 | other variants documented in AEAT SII spec |
5. Ordering and sequencing
5.1 Verifactu chain
Per-NIF, in-memory chain store (verifactu/chain.rs,
InMemoryChainStore). The Adapter initializes one at startup
(lib.rs:60-64):
pub fn new() -> Self {
Self { chain_store: verifactu::InMemoryChainStore::new() }
}
Each prepare_verifactu call:
- Fetches the previous
Huellafor this NIF (lib.rs:118-123). - Computes the new
Huellaviacompute_alta_hash(lib.rs:125-134). - Persists the new entry via
chain_store.put(nif, ChainEntry { ... })(lib.rs:136-147).
For production, the in-memory store needs to be replaced with a
durable backing (Postgres in Cloud, SQLite in Embedded). The trait
boundary ChainStore in verifactu/chain.rs is the seam.
Critical: the chain is keyed by NIF (taxpayer), not by series or by document type. A taxpayer who issues multiple invoice series under one NIF gets one chain across all series — invoices are chained in submission order, not in per-series order.
5.2 TicketBAI chain
Per-NIF, anchored on <SignatureValue>[0:100] of the previous
invoice. This is not stored in our adapter today: the prepare
path emits is_first_invoice: true and sets
previous_invoice: None (lib.rs:361-363):
let invoice_req = ticketbai::InvoiceRequest {
...
is_first_invoice: true,
previous_invoice: None,
...
};
This is a known incomplete area: a real production deployment
must persist signature values across invoices and pass
previous_invoice for every non-first record. Tracked separately;
the goldens currently exercise the chain encoding but the adapter's
prepare path doesn't yet wire it up.
5.3 SII period semantics
SII's "ordering" is by (Ejercicio, Periodo). The adapter computes
period from the transaction's created_at
(lib.rs:451-453):
let exercise = tx.created_at.format("%Y").to_string();
let period = format!("{:02}", tx.created_at.month());
Within a period, AEAT keys records by (IDEmisorFactura, NumSerieFacturaEmisor, FechaExpedicionFacturaEmisor). Resubmission
with the same key updates the prior record (TipoComunicacion=A1);
under A0 it's a duplicate-key error.
5.4 Idempotency
| Regime | Dedup key | Behavior on retry |
|---|---|---|
| Verifactu | (NIF, NumSerie, Fecha, TipoFactura) | First submit wins; second returns the original CSV |
| TicketBAI | (NIF, Serie, NumFactura, FechaExpedicion) | Same; provincial responses are idempotent |
| SII | (IDEmisor, NumSerie, FechaExpedicion) per book | A1 substitutes A0 |
For network errors / 5xx, retry with the same payload bytes is safe.
For 4xx validation errors, fix the data and resubmit with a new
NumSerie (the rejected one is not "burned" the way Italian SDI
treats ProgressivoInvio, but reusing it after rejection is bad
practice).
6. Submission window and deployment mode
6.1 Submission windows
Per lib.rs:80-100:
| System | Territory / variant | Window |
|---|---|---|
| Verifactu | — | Days(4) (4 calendar days from issuance) |
| SII | — | Days(4) |
| TicketBAI | Gipuzkoa, Araba | Immediate |
| TicketBAI | Bizkaia (LROE) | Days(1) (LROE is daily-batched) |
Note that the Adapter::submission_window() impl returns
Days(4) as the default (lib.rs:542-547); the actual window is
dispatched in the routing logic. The orchestrator uses
submission_window_for(system, territory) (the test helper at
lib.rs:80-100) — production code paths should mirror this logic.
6.2 Deployment modes
lib.rs:549-551:
fn supported_deployment_modes(&self) -> &[DeploymentMode] {
&[DeploymentMode::Cloud, DeploymentMode::EmbeddedLocal]
}
All three sub-systems are Cloud-OK in principle:
- Verifactu, SII: mTLS-only, no per-receipt local crypto. Cert lives in our KMS.
- TicketBAI: requires XAdES signing per receipt — but the RSA key + cert are uploaded by the merchant and live in our KMS, so Cloud-side signing is fine. The signature policy is per-province and known statically.
In practice, Cloud is the default for Spanish deployments. The embedded path exists for merchants who insist on local-only signing for security reasons (typically hospitality multi-site), but it's the minority.
7. Sandbox access
Three different onboarding flows. See
apps/docs-internal/docs/countries/spain-sandbox.md for the full
per-system steps.
7.1 Verifactu sandbox
- Get a test FNMT certificate from the
pre-(preproduccion) FNMT instance. Free; self-service after digital identity check via Cl@ve PIN or in-person registration. - Wire the PEM cert + key into the adapter via
RoutingClient::with_mtls(pem). - Sandbox endpoint:
https://prewww1.aeat.es/...VerifactuSOAP. - Test NIFs are sandbox-specific; production NIFs do not work in sandbox.
7.2 TicketBAI sandbox
Per province:
- Bizkaia: Batuz pre-production at
https://pruebas.batuz.eus/. Test certs issued by IZENPE (the Basque PKI). - Gipuzkoa: Egoitza pre at
https://pruebas-egoitza.gipuzkoa.eus/. Same IZENPE test certs work. - Araba:
https://pruebas.araba.eus/. Same.
Each province publishes test fixtures and a public test NIF
(00000006Y is the canonical Bizkaia QR test NIF, used in our
goldens at qr.rs:48-54).
7.3 SII sandbox
- Sandbox endpoint:
https://www7.aeat.es/wlpl/SSII-FACT/ws/. - Same FNMT pre-production cert as Verifactu; SII shares the AEAT PKI surface.
8. Error model
8.1 Verifactu
Response shape (verifactu/client.rs:181-194, parsed by
parse_response):
<sfLR:EstadoEnvio>Correcto | AceptadoConErrores | Incorrecto</sfLR:EstadoEnvio>
<sfLR:CSV>CSV-2024-001234567</sfLR:CSV>
<sfLR:TimestampPresentacion>2026-04-26T13:14:15+02:00</sfLR:TimestampPresentacion>
<sfLR:NIFPresentador>89890001K</sfLR:NIFPresentador>
EstadoEnvio | Meaning |
|---|---|
Correcto | All records accepted |
AceptadoConErrores | Mixed: some accepted, some rejected with per-record ResultadoCorreccion |
Incorrecto | Envelope-level failure; no records persisted |
The ClientError enum (client.rs:233-241):
pub enum ClientError {
Http(String), // 4xx, network
Server(String), // 5xx
Tls(String), // mTLS handshake / cert load
}
The parse_response function does not raise on
Incorrecto — it returns the response with status="Incorrecto"
and the caller decides. This avoids losing the AEAT diagnostic XML.
8.2 TicketBAI / LROE
Response shape (lroe_client.rs:14-26):
pub struct LroeSubmissionResponse {
pub status: String, // "Aceptado" | "AceptadoConErrores" | "Rechazado"
pub csv_code: String,
pub received_at: String,
pub warnings: Vec<String>,
pub raw_xml: String,
}
Per-province CSV / receipt code formats differ slightly but the status semantics are uniform.
8.3 SII
Same pattern — SII returns per-record status under the
SiiResponse/RespuestaLinea element. The parser at sii/xml.rs
extracts (EstadoRegistro, CodigoErrorRegistro, DescripcionErrorRegistro) per record.
8.4 Common error themes
- Cert mismatch: cert NIF must match the issuer NIF on the
invoice. AEAT rejects with code
4102("NIF del certificado no coincide con el del titular del libro"). - Out-of-window submission: > 4 days for Verifactu/SII triggers
1102("Fuera del plazo de presentación"). - Chain break (Verifactu): a
Huellamismatch on submission is detected server-side and returned as4501. The chain is then marked broken and AEAT requires a subsanación flow. - TicketBAI signature policy mismatch: if the policy URL or
digest in the signed XML doesn't match the province's published
policy, the submission is rejected with
B4_2000003(Bizkaia) or equivalent in Gipuzkoa / Araba.
9. Implementation notes
Crate layout
rust/adapters/spain/
├── Cargo.toml # crate "spain-adapters"
└── src/
├── lib.rs # Adapter, SpainConfig, route to verifactu | ticketbai | sii
├── xml_utils.rs # shared XML escape helpers
├── verifactu/
│ ├── mod.rs # public re-exports
│ ├── chain.rs # ChainStore trait, InMemoryChainStore
│ ├── client.rs # HttpClient, RoutingClient (mTLS + endpoint selection + parse_response)
│ ├── hash.rs # compute_alta_hash, compute_anulacion_hash (uppercase hex SHA-256)
│ ├── qr.rs # build_qr_url (AEAT validator URL)
│ ├── types.rs # RegistroAlta, RegistroAnulacion, Software, etc.
│ └── xml.rs # build_alta_xml, build_anulacion_xml
├── ticketbai/
│ ├── mod.rs # public re-exports
│ ├── cancellation.rs # cancellation XML builder
│ ├── crc8.rs # CRC-8/SMBus lookup (256-byte table)
│ ├── lroe.rs # LROE batch builder (Bizkaia)
│ ├── lroe_client.rs # provincial endpoints, mTLS client
│ ├── qr.rs # generate_qr_url with CRC-8 suffix
│ ├── signature.rs # XAdES-EPES Signer with per-province policy
│ ├── tbai_id.rs # generate_tbai_id (NIF-DDMMYY-sigfrag-CRC8)
│ ├── types.rs # InvoiceRequest, Software, Territory, ...
│ └── xml.rs # XmlBuilder::build_invoice_xml
└── sii/
├── mod.rs # re-exports
├── client.rs # SiiClient trait, SiiHttpClient (issued / received books)
├── types.rs # IssuedInvoice, Breakdown, BreakdownSubject, TaxDetail, ExemptDetail
└── xml.rs # build_issued_invoice_xml, build_received_invoice_xml, build_cancellation_xml
Trait fit
CountryAdapter::prepare() parses SpainConfig from the location
config and dispatches:
match cfg.system.as_str() {
"verifactu" => self.prepare_verifactu(tx, loc, &cfg),
"ticketbai" => self.prepare_ticketbai(tx, loc, &cfg),
"sii" => self.prepare_sii(tx, loc, &cfg),
_ => unreachable!(...), // ValidatedConfig invariant
}
Each branch builds its system's XML, computes the appropriate
fiscal_id format, and emits a PreparedRecord with
submission_payload set to the XML bytes.
fiscal_id formats:
| System | Format |
|---|---|
| Verifactu | ES-VRF-{NIF}-{first 8 chars of Huella} |
| TicketBAI | TBAI-{NIF}-{DDMMYY}-{sigfrag}-{CRC8} |
| SII | ES-SII-{NIF}-{exercise}-{tx.id} |
CountryAdapter::submit() is a no-op (lib.rs:570-579); the actual
HTTP submission is performed by a separate worker that consumes the
submission_payload, applies signing where needed, and posts to the
appropriate authority client.
Render hints
Each system surfaces different print-mandatory fields:
- Verifactu:
verifactu_hash(Huella) footer,qr(QrUrl, pointing at AEAT validator) footer-right, mandatory from 2026-01-01 (lib.rs:248-269). - TicketBAI:
tbai_idfooter,zki(signature anchor) footer,qr(QrUrl, per-province validator) footer-right — all mandatory (lib.rs:396-429). - SII: empty render hints — SII is a back-office reporting
flow; the printed receipt is the regular commercial invoice
(
lib.rs:519-526).
Idiosyncrasies and gotchas
- Verifactu hash uses uppercase hex, not lowercase
(
hash.rs:43-47). Fixture vectors are uppercase. A lowercase implementation produces a "valid SHA-256" that AEAT silently reads as a different value and the chain breaks on next record. - Hash field order is positional, not alphabetical
(
hash.rs:18-22). Don't sort or normalize field order in any automated way. IDEmisorFacturain the hash input is the raw NIF — withoutESprefix. Even though the spec sometimes calls it "Tax Identification Number", in this context it's just the NIF.- Verifactu
Huellais empty for the first record (hash.rs:101) — the formatter emits&Huella=&Fecha...with nothing between the equals and ampersand. Don't try to normalize this. - TicketBAI
previous_invoice.signature_valueis the first 100 characters of the prior invoice's<ds:SignatureValue>, typically a base64 of an RSA-2048 signature, but the field stores it as-is (no decoding). It's a string slice of an XML text node. Don't trim whitespace. - TicketBAI signature policies are constants
(
signature.rs:23-42) — if the province updates its policy PDF, the digest must be rebuilt from the new PDF. There is no remote fetch or auto-update. - CRC-8 computations are not interchangeable — the TBAI ID's
CRC-8 input is
TBAI-{NIF}-{DDMMYY}-{sigfrag}-(with trailing dash); the QR's CRC-8 input is the full URL up to&cr=(no trailing separator). - Bizkaia TBAI flow uses LROE batching, not real-time TicketBAI
submit. The XML schema is the LROE wrapper around N TicketBAI
blocks — see
lroe.rs. Don't try to submit a singleT:TicketBaito Bizkaia'sLROEendpoint; it will be rejected. - The
chain_storeis shared across the entire adapter — oneInMemoryChainStoreperAdapterinstance. In Cloud mode we typically run one Adapter per worker, which means each worker needs to reload the chain from durable storage on startup. The in-memory store as shipped is only suitable for tests. - Legacy SOAP namespace prefixes vary: AEAT responses use
sfLR:for Verifactu,siiLR:for SII. The parser (client.rs:201-230) handles both prefixed and bare element names — don't assume a particular prefix when adding new field extractions. - TicketBAI sig placeholder: the prepare path currently
base64-encodes a placeholder string for the signature value
(
lib.rs:372-376); the real ZKI is computed at submission time when signing happens. The render hint flags ZKI as "mandatory" because the regulator demands it on the printed receipt; the runtime overwrites the value once the real signing produces the canonical ZKI.
10. References
Authority documentation
- Verifactu:
- Real Decreto 1007/2023 — https://www.boe.es/diario_boe/txt.php?id=BOE-A-2023-24840.
- Orden HFP/1177/2024 — implementing order, technical specs.
- AEAT Verifactu portal — https://sede.agenciatributaria.gob.es/Sede/iva/sistemas-informaticos-facturacion-verifactu.html.
- XSDs distributed via the AEAT Diseños XML page; mirrored in
testdata/conformance/spain/verifactu/schemas/.
- TicketBAI:
- Bizkaia (Batuz) — https://www.batuz.eus/.
- Gipuzkoa (Egoitza) — https://www.gipuzkoa.eus/es/web/ogasuna/ticketbai.
- Araba — https://web.araba.eus/es/hacienda/ticketbai.
- Signature policy PDFs cited verbatim in
rust/adapters/spain/src/ticketbai/signature.rs:23-42.
- SII:
- Real Decreto 596/2016 — https://www.boe.es/diario_boe/txt.php?id=BOE-A-2016-11475.
- AEAT SII portal — https://sede.agenciatributaria.gob.es/Sede/iva/suministro-inmediato-informacion.html.
Adapter source
rust/adapters/spain/Cargo.toml— crate manifest.rust/adapters/spain/src/lib.rs—Adapter,SpainConfig, routing,CountryAdapterimpl.- Verifactu:
verifactu/hash.rs—compute_alta_hash,compute_anulacion_hash.verifactu/qr.rs—build_qr_url(AEAT validator).verifactu/client.rs—RoutingClient,select_endpoint,parse_response.verifactu/types.rs—RegistroAlta,Software, etc.verifactu/xml.rs— XML emitters.
- TicketBAI:
ticketbai/signature.rs—Signer, per-provinceSignaturePolicy.ticketbai/tbai_id.rs—generate_tbai_id.ticketbai/qr.rs—generate_qr_url.ticketbai/crc8.rs—crc8(SMBus polynomial).ticketbai/lroe_client.rs— provincial endpoints,LroeHttpClient.ticketbai/types.rs—Territory,InvoiceRequest, ...
- SII:
sii/client.rs—SiiHttpClient, book paths.sii/types.rs—IssuedInvoice,Breakdown,TaxDetail.sii/xml.rs—build_issued_invoice_xml,build_received_invoice_xml,build_cancellation_xml.
Internal docs
apps/docs-internal/docs/guides/spain.md— integration guide (legacy; should migrate to external).apps/docs-internal/docs/countries/spain-sandbox.md— sandbox onboarding steps for all three systems.apps/docs/docs/guides/spain.md— current customer-facing presentation.