Validate a CFDI You Received Before You Deduct It: XSD, Sello, Vigencia and the 69-B Blacklist, in Python — Cesar Ayala
← All posts

Validate a CFDI You Received Before You Deduct It: XSD, Sello, Vigencia and the 69-B Blacklist, in Python

Before you deduct a received CFDI, run four checks: the XML is well-formed against the CFDI 4.0 XSD; the sello verifies as RSA-SHA256 over the cadena original (rebuilt with the SAT XSLT) against the emisor certificate; the SAT returns Estado Vigente (not Cancelado) from ConsultaCFDIService; and the emisor RFC is not Definitivo on the 69-B blacklist.

The short answer: four checks before you trust a received CFDI

To trust a received CFDI before you deduct it, run four checks in order: the XML validates against the CFDI 4.0 XSD, the Sello verifies as RSA-SHA256, the SAT returns Estado = Vigente, and the emisor RFC is not Definitivo on the 69-B blacklist. Pass all four and it is deductible; fail one and you hold or reject it.

  1. 1. XSD + well-formedParse the XML, validate against the CFDI 4.0 schema
  2. 2. SelloRebuild cadena original via SAT XSLT, verify RSA-SHA256 vs emisor cert
  3. 3. VigenciaConsultaCFDIService returns Estado=Vigente, not Cancelado
  4. 4. 69-BEmisor RFC not Definitivo on the Listado Completo

Why you validate what you receive: deductibility and apocrifos risk

Every CFDI post I have shipped so far is about emission — you generating an invoice: the timbrado outbox, auto-invoicing from Stripe or Mercado Pago, CFDI de Egreso for refunds. This one is the opposite direction: a supplier hands you an XML and you want to deduct it.

The stakes are real. A CFDI that is apócrifo (forged sello), already cancelled, or issued by a company the SAT has flagged as selling fake invoices is not deductible. The crypto is identical to the outbound side — same CSD, same cadena original, same RSA-SHA256 — you are just verifying instead of signing. In accounts payable, this validation is the gate that stands between “we paid it” and “we can legally deduct it.”

The order of the four checks is deliberate, cheapest-to-priciest. XSD validation is pure local CPU, so it runs first and rejects garbage before you spend anything. The sello check is local crypto but needs the SAT XSLT loaded. Only then do you touch the network: vigencia is a SOAP round-trip to the SAT, and the 69-B screen is a lookup against a list you refresh on a schedule. Short-circuit on the first failure and you never pay for a check you did not need to run.

Check 1 — the XML is well-formed against the CFDI 4.0 XSD

The cheapest check first: if the file will not parse or does not conform to the schema, nothing downstream matters. Use lxml so you get real XSD validation, not just well-formedness.

from lxml import etree

def validate_xsd(xml_bytes: bytes, xsd_path: str):
    # Parse strictly: malformed XML raises here.
    doc = etree.fromstring(xml_bytes)
    schema = etree.XMLSchema(etree.parse(xsd_path))
    if not schema.validate(doc):
        errors = "; ".join(str(e) for e in schema.error_log)
        raise ValueError(f"CFDI 4.0 XSD validation failed: {errors}")
    return doc

Keep a local copy of the CFDI 4.0 schema plus every complemento XSD your suppliers use (the schema imports them). Do not fetch schemas over the network at validation time — pin them on disk and update deliberately. A file that fails here is not a real CFDI; reject it immediately.

Check 2 — the sello: rebuild the cadena original and verify RSA-SHA256

The Sello attribute is the emisor’s digital signature: base64 RSA over the SHA-256 hash of the cadena original. The cadena original is a specific pipe-delimited concatenation of the CFDI fields, produced by applying the official SAT XSLT to the XML. You cannot hand-assemble it correctly — the XSLT encodes the exact field order, spacing, and inclusion rules, and it changes across complementos. Do not hand-roll it. Use a library that ships the SAT transforms, then verify explicitly.

import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_der_x509_certificate
from satcfdi.cfdi import CFDI

def verify_sello(cfdi: CFDI) -> None:
    # 1. Library rebuilds the cadena original via the SAT XSLT (do NOT hand-roll it).
    cadena = cfdi.cadena_original().encode("utf-8")
    # 2. The emisor Certificado (base64 DER) is embedded in the XML, keyed by NoCertificado.
    cert = load_der_x509_certificate(base64.b64decode(cfdi["Certificado"]))
    sello = base64.b64decode(cfdi["Sello"])
    # 3. Verify RSA-SHA256 of the sello against the emisor public key; raises on mismatch.
    cert.public_key().verify(sello, cadena, padding.PKCS1v15(), hashes.SHA256())

The library’s cadena_original() applies the SAT XSLT for you; you then hash it under the hood via SHA256() and run the RSA verification against the Certificado embedded in the XML (matched by its NoCertificado). If verify raises, the invoice was altered after signing or the sello is forged — either way it is apócrifo and you reject it before it reaches the ledger. The reference implementations are SAT-CFDI/python-satcfdi and, in PHP, phpcfdi/cfdiutils; read their cadena-original code before you trust any port of it, since a single character wrong in the cadena original breaks the hash.

Inputcadena original (SAT XSLT output)
HashSHA-256
SignatureRSA over the hash (base64 = Sello)
Verify againstemisor Certificado / NoCertificado

Check 3 — vigencia at the SAT with ConsultaCFDIService

A perfectly-signed CFDI can still be cancelled. Only the SAT knows, and you ask it through the SOAP endpoint https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc. It takes exactly four values pulled from the CFDI — RFC emisor (re), RFC receptor (rr), total (tt), and UUID (id) — usually expressed as the expresión impresa query string.

import requests

WSDL_ENV = """<soapenv:Envelope
  xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:tem="http://tempuri.org/">
  <soapenv:Body>
    <tem:Consulta>
      <tem:expresionImpresa>?re={re}&amp;rr={rr}&amp;tt={tt}&amp;id={id}</tem:expresionImpresa>
    </tem:Consulta>
  </soapenv:Body>
</soapenv:Envelope>"""

def consulta_vigencia(re, rr, tt, uuid):
    body = WSDL_ENV.format(re=re, rr=rr, tt=tt, id=uuid)
    r = requests.post(
        "https://consultaqr.facturaelectronica.sat.gob.mx/ConsultaCFDIService.svc",
        data=body.encode("utf-8"),
        headers={
            "Content-Type": "text/xml; charset=utf-8",
            "SOAPAction": "http://tempuri.org/IConsultaCFDIService/Consulta",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.text  # parse CodigoEstatus, Estado, EsCancelable, EstatusCancelacion

The response carries CodigoEstatus (S means the query was obtained satisfactorily), Estado (Vigente or Cancelado), EsCancelable, and EstatusCancelacion. You want Estado to read Vigente. If it reads Cancelado, the supplier voided the invoice after sending it to you — do not deduct it. For production I lean on phpcfdi/sat-estado-cfdi, which wraps this exact service and normalizes the response so you are not parsing raw SOAP by hand.

The #1 reason vigencia checks silently fail: total as a string, not a number

Here is the gotcha that will eat an afternoon. ConsultaCFDIService compares the total (tt) as a string, not as a number. If the CFDI total is 15230.00 and you send 15230 or 15230.0, the query does not error — it just fails to find the CFDI, and you wrongly conclude something is wrong with the invoice.

# WRONG: floats and int coercion drop or change the printed decimals.
tt = float(root.get("Total"))            # 15230.0  -> mismatch
tt = str(int(float(root.get("Total"))))  # "15230"  -> mismatch

# RIGHT: send the Total attribute exactly as it appears in the XML.
tt = root.get("Total")                   # "15230.00" -> match

Pull the Total attribute straight off the cfdi:Comprobante node as a string and pass it through untouched, decimals and all. Never round it, never reformat it, never round-trip it through a float. This single mistake is the number-one reason otherwise-correct vigencia checks silently return nothing.

Check 4 — screen the emisor against the 69-B blacklist

Article 69-B of the Código Fiscal is the SAT’s public list of companies presumed to sell simulated operations. EFOS (Empresa que Factura Operaciones Simuladas) are the issuers of fake invoices; EDOS (Empresa que Deduce Operaciones Simuladas) are whoever deducted them — you do not want to become an EDOS. The SAT publishes the Listado Completo 69-B as open data at Datos Abiertos SAT. Download it and match your emisor RFC against it.

import csv

def load_69b(csv_path: str) -> dict:
    situacion = {}
    with open(csv_path, encoding="latin-1") as f:
        # The list has header/preamble rows; skip to the data rows.
        for row in csv.reader(f):
            if len(row) < 3 or not row[1].strip():
                continue
            rfc = row[1].strip().upper()
            situacion[rfc] = row[2].strip()  # Situacion del contribuyente
    return situacion

def screen_emisor(rfc: str, situacion: dict) -> str:
    estado = situacion.get(rfc.upper())
    if estado == "Definitivo":
        raise ValueError(f"Emisor {rfc} is 69-B Definitivo -- not deductible")
    return estado or "Not listed"

Each row carries a Situación del contribuyente with one of four values: Presunto (presumed, still contesting), Desvirtuado (cleared the presumption), Definitivo (confirmed EFOS), or Sentencia Favorable (won in court), each with its oficio and DOF publication dates.

Blocks the deduction

  • Definitivo — confirmed EFOS, CFDI is not deductible
  • This is the row that hurts you in an audit

Does not block

  • Presunto — presumed, still contesting
  • Desvirtuado — cleared the presumption
  • Sentencia Favorable — won in court

Why a Definitivo emisor kills your deduction — and why you refresh the list

A CFDI from an emisor marked Definitivo is not deductible, full stop. That is the status the SAT uses to confirm the company was selling simulated operations, and any invoice you take from them turns you into an EDOS on paper. Presunto, Desvirtuado, and Sentencia Favorable do not carry that finality — but Definitivo does.

The list is updated roughly quarterly, sometimes more often. So you download-and-refresh on a schedule — never hardcode a snapshot. An emisor that was clean when you first paid them can appear as Definitivo months later, and your validation has to catch it. Pull the fresh CSV on a cron, re-screen open payables against it, and treat the list as live data, not a constant. This is the same discipline as validating an RFC without the Constancia: trust the SAT’s live source, not a value you cached once.

Wire it into an AP gate: reject or hold on any failed check

Compose the four checks into a single gate. Any failure holds or rejects the invoice before it reaches payment or the deduction ledger.

def validate_received_cfdi(xml_bytes, xsd_path, cfdi, situacion_69b):
    doc = validate_xsd(xml_bytes, xsd_path)          # 1
    verify_sello(cfdi)                                # 2 (raises if apocrifo)

    re = cfdi["Emisor"]["Rfc"]                        # emisor RFC
    rr = cfdi["Receptor"]["Rfc"]                      # receptor RFC
    tt = doc.get("Total")                             # exact string — see the gotcha
    uuid = cfdi.uuid                                  # UUID from tfd:TimbreFiscalDigital

    resp = consulta_vigencia(re, rr, tt, uuid)        # 3
    if "Cancelado" in resp:
        return "REJECT", "CFDI is Cancelado at the SAT"

    screen_emisor(re, situacion_69b)                  # 4 (raises on Definitivo)
    return "ACCEPT", "All four checks passed"

The failure modes map cleanly to outcomes: XSD or sello failure means the file is not a valid CFDI — reject. Cancelado means the supplier voided it — reject. Definitivo on 69-B means it is not deductible — reject (or hold pending your contador’s review). Everything else — a Presunto emisor, a network timeout to the SAT — is a hold: flag it for a human, do not auto-approve. This is the same reject-or-hold posture you want anywhere invoices meet money, like the AP-with-Claude pipeline, and it complements the SAT bulk-download flow for pulling the CFDIs in the first place with Descarga Masiva in four steps.

XSD ok?
Sello ok?
Vigente?
Emisor not Definitivo?
ACCEPT / else HOLD or REJECT

Engineer, not contador: what to verify against the SAT before production

I am an engineer, not a contador or abogado — this is how you wire the checks in code, not tax advice. Before you ship, verify the current rules against sat.gob.mx and confirm the cadena-original logic against the phpcfdi reference implementations rather than my prose. The endpoints, the four query fields, the Estado enum, and the 69-B statuses here match the SAT services as of this writing, but the SAT changes schemas, WSDLs, and list formats without much warning. Pin your XSDs, wrap ConsultaCFDIService and the 69-B download in retries, refresh the blacklist on a schedule, and keep a contador in the loop for the deductibility calls. The code gets you a trustworthy gate; the final “yes, deduct it” belongs to someone with a cédula. For the broader picture, the Mexican CFDI hub collects the outbound companions to this inbound flow.