Fatturazione elettronica SDI in Python: guida pratica all'integrazione via API
Chi sviluppa gestionali in Italia prima o poi ci passa: il cliente chiede «ma le fatture partono da sole verso lo SDI?». Sembra un'integrazione come un'altra, una chiamata HTTP e via. In pratica entrano in gioco certificati, XML con schemi rigidi, notifiche asincrone e scarti che arrivano giorni dopo.
In questo articolo raccolgo quello che abbiamo imparato integrando la fatturazione elettronica in software su misura: le scelte di architettura, la struttura minima di una FatturaPA, gli errori che si vedono più spesso e come testare senza mandare fatture vere al Sistema di Interscambio.
Le due strade: canale diretto SDI o intermediario REST
Lo SDI (Sistema di Interscambio dell'Agenzia delle Entrate) accetta le fatture su diversi canali. Per uno sviluppatore le opzioni realistiche sono due.
1. Canale diretto SDICoop (web service SOAP). Si accredita il proprio sistema presso lo SDI, si gestiscono certificati client, endpoint SOAP per la trasmissione (SdIRiceviFile) e un proprio endpoint esposto per ricevere le notifiche (TrasmissioneFatture). Pieno controllo, ma anche:
- procedura di accreditamento e test di interoperabilità;
- mutual TLS con certificati da rinnovare;
- un servizio sempre raggiungibile per ricevere le ricevute;
- gestione in proprio di firma, conservazione e archiviazione.
2. Intermediario con API REST. Un provider accreditato espone un'API JSON/REST: carichi l'XML (o a volte un JSON che lui converte), ricevi un ID e le notifiche via webhook o polling. Rinunci a un po' di controllo e accetti una dipendenza esterna, ma l'integrazione scende da settimane a giorni.
La nostra regola pratica: canale diretto solo se i volumi e i requisiti lo giustificano davvero; per la maggior parte dei gestionali un intermediario è la scelta più sensata. In ogni caso conviene isolare il canale dietro un'interfaccia, così cambiarlo non tocca la logica di business.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class EsitoInvio:
id_trasmissione: str
stato: str # "inviata", "consegnata", "scartata", ...
messaggio: str | None = None
class CanaleSDI(ABC):
@abstractmethod
def invia(self, nome_file: str, xml: bytes) -> EsitoInvio: ...
@abstractmethod
def stato(self, id_trasmissione: str) -> EsitoInvio: ...
La struttura minima di una FatturaPA
Il tracciato FatturaPA (versione 1.2.x) ha due blocchi principali:
-
FatturaElettronicaHeader: dati di trasmissione, cedente/prestatore, cessionario/committente; -
FatturaElettronicaBody: dati generali del documento, righe, riepiloghi IVA, pagamento.
Uno scheletro ridotto:
<p:FatturaElettronica versione="FPR12"
xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2">
<FatturaElettronicaHeader>
<DatiTrasmissione>
<IdTrasmittente><IdPaese>IT</IdPaese><IdCodice>01234567890</IdCodice></IdTrasmittente>
<ProgressivoInvio>00001</ProgressivoInvio>
<FormatoTrasmissione>FPR12</FormatoTrasmissione>
<CodiceDestinatario>0000000</CodiceDestinatario>
</DatiTrasmissione>
<CedentePrestatore>...</CedentePrestatore>
<CessionarioCommittente>...</CessionarioCommittente>
</FatturaElettronicaHeader>
<FatturaElettronicaBody>
<DatiGenerali>
<DatiGeneraliDocumento>
<TipoDocumento>TD01</TipoDocumento>
<Divisa>EUR</Divisa>
<Data>2026-11-05</Data>
<Numero>2026/001</Numero>
</DatiGeneraliDocumento>
</DatiGenerali>
<DatiBeniServizi>...</DatiBeniServizi>
</FatturaElettronicaBody>
</p:FatturaElettronica>
Alcune cose da sapere subito:
-
FPR12per i privati,FPA12per la Pubblica Amministrazione (con codice ufficio a 6 caratteri invece del codice destinatario a 7). - Il nome file segue lo schema
IT<partitaIVA>_<progressivo>.xml, con progressivo alfanumerico univoco. - Gli importi vanno con il punto decimale e almeno due decimali; le date in formato ISO.
Per generare l'XML in Python consigliamo lxml per la costruzione e soprattutto per la validazione con l'XSD ufficiale:
from decimal import Decimal, ROUND_HALF_UP
from lxml import etree
NS = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2"
def importo(valore: Decimal) -> str:
return str(valore.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def sub(parent, tag, text=None):
el = etree.SubElement(parent, tag)
if text is not None:
el.text = text
return el
def riga(parent, numero, descrizione, quantita, prezzo, aliquota):
dl = sub(parent, "DettaglioLinee")
sub(dl, "NumeroLinea", str(numero))
sub(dl, "Descrizione", descrizione[:1000])
sub(dl, "Quantita", f"{quantita:.2f}")
sub(dl, "PrezzoUnitario", importo(prezzo))
sub(dl, "PrezzoTotale", importo(prezzo * quantita))
sub(dl, "AliquotaIVA", importo(aliquota))
return dl
Usate sempre Decimal, mai float: un centesimo di differenza tra righe e riepilogo basta per uno scarto.
Validare prima di inviare
Lo SDI risponde con una Notifica di Scarto (NS) se qualcosa non va, con codici errore numerici (00xxx). Ricevere uno scarto dopo ore è costoso: meglio intercettare il più possibile in locale, a due livelli.
Livello 1, schema XSD:
def valida_xsd(xml_bytes: bytes, xsd_path: str) -> list[str]:
schema = etree.XMLSchema(etree.parse(xsd_path))
doc = etree.fromstring(xml_bytes)
if schema.validate(doc):
return []
return [f"riga {e.line}: {e.message}" for e in schema.error_log]
Livello 2, controlli semantici che l'XSD non vede:
def controlli_semantici(fattura) -> list[str]:
errori = []
for r in fattura.riepiloghi:
attesa = (r.imponibile * r.aliquota / 100).quantize(Decimal("0.01"), ROUND_HALF_UP)
if abs(attesa - r.imposta) > Decimal("0.01"):
errori.append(f"Imposta incoerente per aliquota {r.aliquota}")
if r.aliquota == 0 and not r.natura:
errori.append("Aliquota 0 senza codice Natura")
if fattura.cliente.paese == "IT" and not (fattura.cliente.piva or fattura.cliente.cf):
errori.append("Cliente italiano senza P.IVA né codice fiscale")
return errori
Gli errori che vediamo più spesso nei progetti reali:
-
Partita IVA o codice fiscale non validi (anche solo per uno spazio o un prefisso
ITduplicato). - Natura mancante o non coerente con aliquota zero: dopo l'introduzione dei codici N2.x, N3.x, N6.x molti gestionali vecchi usano ancora codici generici.
- Arrotondamenti: somma delle righe diversa dall'imponibile del riepilogo.
- File duplicato: stesso nome file o stessa fattura (numero, data, cedente) già trasmessa.
-
Codice destinatario e PEC entrambi vuoti quando servirebbe almeno
0000000. - Caratteri non ammessi nel testo: copia-incolla da Word con caratteri speciali che rompono l'encoding.
Un piccolo consiglio: normalizzate i testi in ingresso (Unicode NFC, rimozione caratteri di controllo) nel punto in cui i dati entrano nel sistema, non al momento dell'invio.
Autenticazione: cosa aspettarsi
Con il canale diretto l'autenticazione è a livello trasporto: certificato client rilasciato in fase di accreditamento, mutual TLS. Con requests o zeep:
import requests
from zeep import Client
from zeep.transports import Transport
session = requests.Session()
session.cert = ("/secrets/client.crt", "/secrets/client.key")
session.verify = "/secrets/ca-sdi.pem"
client = Client("SdIRiceviFile.wsdl", transport=Transport(session=session))
Con gli intermediari REST il pattern più comune è OAuth2 client credentials o API key, a volte con firma HMAC dei webhook. In entrambi i casi:
- certificati e chiavi fuori dal repository (secret manager o variabili d'ambiente);
- token con scadenza gestita, rinnovati prima della scadenza e non a errore avvenuto;
- verifica della firma su ogni webhook in ingresso, altrimenti chiunque può marcare una fattura come «consegnata».
import hmac, hashlib
def webhook_valido(body: bytes, firma: str, segreto: bytes) -> bool:
atteso = hmac.new(segreto, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(atteso, firma)
Notifiche asincrone: la parte che si sottovaluta
L'invio non è la fine. Dopo la trasmissione arrivano notifiche: ricevuta di consegna (RC), mancata consegna (MC), scarto (NS) e, per la PA, esito committente (NE) o decorrenza termini (DT). Il modello dati deve prevederlo:
from enum import Enum
class StatoFattura(str, Enum):
BOZZA = "bozza"
INVIATA = "inviata"
CONSEGNATA = "consegnata" # RC
NON_CONSEGNATA = "non_consegnata" # MC: valida, ma da comunicare al cliente
SCARTATA = "scartata" # NS: va corretta e reinviata
Due regole che ci hanno evitato parecchi guai: gestire le notifiche in modo idempotente (la stessa notifica può arrivare due volte) e non permettere modifiche a una fattura uscita dallo stato BOZZA, se non tramite nota di credito o reinvio dopo scarto.
Testare senza mandare fatture vere allo SDI
È il punto su cui conviene investire di più.
- Ambiente di test dell'intermediario: quasi tutti offrono una sandbox che simula consegne e scarti. Usatela in CI.
-
Fake del canale: grazie all'interfaccia
CanaleSDIpotete iniettare un'implementazione finta nei test.
class CanaleFinto(CanaleSDI):
def __init__(self, esito="consegnata"):
self.esito = esito
self.inviati = []
def invia(self, nome_file, xml):
self.inviati.append((nome_file, xml))
return EsitoInvio(id_trasmissione=f"TEST-{len(self.inviati)}", stato=self.esito)
def stato(self, id_trasmissione):
return EsitoInvio(id_trasmissione, self.esito)
def test_scarto_riporta_in_correzione():
canale = CanaleFinto(esito="scartata")
servizio = ServizioFatture(canale)
f = servizio.emetti(fattura_di_esempio())
assert f.stato == StatoFattura.SCARTATA
assert servizio.puo_modificare(f)
- Golden file: salvate alcuni XML «buoni» validati e confrontate l'output del generatore in ogni build; qualsiasi cambio inatteso salta fuori subito.
- Validazione XSD in CI su un set di fatture di esempio che coprono i casi limite: esenzioni, split payment, note di credito (TD04), clienti esteri.
- Strumenti di controllo pubblici: l'Agenzia delle Entrate mette a disposizione un servizio di controllo dei file XML, utile per verificare a mano casi dubbi.
Un altro scenario frequente è quello opposto: aziende che hanno fatture o documenti solo in PDF e devono produrre l'XML. Lì il problema diventa estrarre dati affidabili da un formato pensato per essere letto da persone; ne abbiamo parlato a proposito di un convertitore da PDF a XML per fatture elettroniche, e valgono le stesse regole di validazione viste sopra.
Cosa abbiamo imparato
- Isolate il canale di trasmissione dietro un'interfaccia: passare da intermediario a canale diretto (o cambiare provider) diventa un problema di configurazione.
-
Decimalovunque e controlli di coerenza sui riepiloghi IVA: la maggior parte degli scarti nasce da arrotondamenti e codici Natura. - Validate in locale due volte, XSD più regole semantiche, prima di ogni invio.
- Le notifiche sono parte del dominio, non un dettaglio: stati espliciti, gestione idempotente, niente modifiche dopo l'invio.
- Testate senza SDI: fake del canale, golden file e sandbox in CI vi fanno scoprire i problemi prima del cliente.
Se lavorate a integrazioni simili e avete trovato soluzioni diverse, soprattutto sulla gestione delle notifiche, ditelo nei commenti: è un tema su cui c'è sempre qualcosa da imparare.
Scritto dal team di S-Data, software house che sviluppa gestionali e integrazioni su misura.
Top comments (0)