
CFDI Hydrocarbons Complement: Stamp Fuel Invoices Without SAT Rejecting Them (April 2026)
From April 24, 2026, every CFDI 4.0 that invoices gasolina or diésel must carry the Complemento de Hidrocarburos y Petrolíferos at the concepto level, with five attributes: Version, TipoPermiso, NumeroPermiso, ClaveHYP, SubProductoHYP. The PAC cross-checks your NumeroPermiso against the CNE's L_CNE list, updated daily, and rejects the CFDI if the permit is missing or expired. Fines run $17,000 to $97,000 MXN per comprobante (personas morales). Legal base: Regla 2.7.1.48, RMF 2026.
What changed, and why April 24, 2026 is a hard deadline
If you build invoicing for anyone who sells fuel in Mexico, your calendar just got a hard line on it. On March 25, 2026 the SAT published the Complemento de Hidrocarburos y Petrolíferos (the fuel-invoicing complement for CFDI 4.0), and it is in force on April 24, 2026. There is no soft-launch grace window I’d bet a production deploy on — treat the 24th as the day your stamping pipeline either carries the complement or starts failing.
This one wasn’t the SAT acting alone. It’s a joint push from SAT + SENER (energy ministry) + CNE (the Comisión Nacional de Energía, the energy regulator) + ATDT, and the stated goal is traceability — closing the gap that fuel theft and huachicol fiscal (fuel-tax fraud) drive a truck through. The way they close it is by forcing every fuel invoice to carry a verifiable permit number tied back to the regulator’s own records.
Here’s the scope, and it’s broad: the complement is mandatory on every CFDI 4.0 that invoices gasolina or diésel — hidrocarburos y petrolíferos. Gas stations, distributors, marketers — if a concepto (line item) on your CFDI is fuel, that line needs the complement.
The stakes are the part that makes this an engineering priority and not a “we’ll get to it” ticket:
- The PAC (the Proveedor Autorizado de Certificación, the SAT-authorized stamping provider) auto-rejects the CFDI at timbrado if the complement is missing or wrong. No stamp, no invoice.
- Fines run $17,000 to $97,000 MXN per comprobante for personas morales (companies) — per invoice issued without the complement after April 24.
- A CFDI the PAC rejects never gets stamped, so there’s nothing to deduct — and contadores warn that an improperly issued fuel CFDI can be challenged on deductibility and IVA credit downstream. That’s a fiscal call: confirm the treatment with a contador.
Legal base is Regla 2.7.1.48 of the Resolución Miscelánea Fiscal 2026 (the technical spec — XSD, catalogs, and validation rules — lives in Anexo 29 of the RMF 2026). Engineer’s caveat, as always: I’m not a contador. The figures and rules here are accurate as of 2026, but confirm the current SAT text and defer fiscal edge cases to a compliance person — your job is to make the pipeline fail fast and correct, not to adjudicate tax law.
This builds directly on the CFDI 4.0 + PAC timbrado foundation I covered in auto-invoicing CFDI from Stripe / Mercado Pago. If that pipeline is already running, this is a new block you bolt onto it — not a rewrite.
Where the complement lives: concepto level, not the comprobante
This is the single thing I see engineers get wrong first, so I’m putting it up front: the Complemento de Hidrocarburos operates at the Complemento Concepto level — per line item — not at the general comprobante (document) root.
If you’ve shipped other complements, your instinct is the wrong one here. Carta Porte and Pagos both attach at the comprobante root, under cfdi:Complemento at the top of the document. (I walked through one of those in the Carta Porte developer guide.) Hidrocarburos is nested a level deeper: it goes inside the concepto, under cfdi:Concepto/cfdi:ComplementoConcepto.
The practical consequences:
- Each fuel concepto carries its own complement block. The block is scoped to that line item, not the invoice.
- A CFDI with multiple fuel lines — say one line for gasolina and one for diésel — needs the block repeated per concepto. One block per combustible line, not one for the whole document.
- Your CFDI builder has to inject the block at the right depth. If your builder has a generic “append complement to document root” helper, do not reuse it here — you’ll produce a structurally invalid CFDI that the PAC bounces.
Get the placement right before you worry about anything else, because a perfectly populated block in the wrong node is still a rejection.
The five mandatory attributes you must populate
Inside that per-concepto block — the HidroYPetro element — five attributes are mandatory. Miss one, or malform one, and it’s a rejection cause:
- Version — the version of the complement itself (fixed at
1.0as of this release). - TipoPermiso — the CNE permit/activity type held by the emisor (issuer), a catalog code (the published values run PER01–PER11).
- NumeroPermiso — the unique CNE permit number, a 15–35 character string. This is the field that gets cross-checked against the regulator’s list (more on that next — it’s the whole ballgame).
- ClaveHYP — the hydrocarbon/petrolífero key from the SAT product catalog (gasolina regular, gasolina premium, diésel).
- SubProductoHYP — the subproduct classification, a catalog value in
SP##form.
Two engineering notes. First, all five are required — there’s no “optional if blank” here; a missing or malformed attribute fails validation. Second, TipoPermiso, ClaveHYP, and SubProductoHYP come from SAT/CNE catalogs, not free text. Wire them to the published catalog values, validate against that enum in your own builder, and don’t let a typo’d or invented key reach the PAC. Catalog-driven fields are the cheapest rejections to prevent locally. And note that TipoPermiso (a type code like PER07) and NumeroPermiso (the long permit string) are different fields — do not populate them with the same value.
The L_CNE catalog: the daily-updated gotcha that gets you rejected
Here’s the operational core of the whole post — the thing that will page your on-call at 3 a.m. if you ignore it.
At timbrado, the PAC does two checks against your NumeroPermiso:
- It verifies the emisor holds a vigente (currently valid) CNE permit.
- It cross-checks your
NumeroPermisoagainst the L_CNE list — a catalog of valid permits maintained by the energy regulator (CNE) and consumed by the PACs at timbrado, which (as of 2026, confirm current) is updated daily.
The L_CNE binds four fields per permit: the RFC del titular, the número de permiso, the estatus (vigente, suspendido, or revocado), and the vigencia dates. So if the permit isn’t in L_CNE as vigente — expired, revoked, or simply a typo in your data — the CFDI is rejected. And it’s not enough for the number to merely exist: the emisor’s RFC must match the permit holder on record. A valid permit number under the wrong RFC still fails.
The word daily is the trap. A permit that’s valid in your cache today can drop off the L_CNE list tomorrow — permits get revoked or lapse, and that propagates into the list on its own schedule, not yours. If you hardcode the list, or cache it once at deploy and forget it, you will eventually validate a fuel CFDI against a stale snapshot, pass it locally, and then eat a PAC rejection in production with a customer waiting on an invoice.
The engineer move is straightforward:
- Cache the L_CNE list, and refresh it on a schedule — daily is a safe default given the update cadence.
- Validate the permit BEFORE you call timbrar — check presence in L_CNE as vigente and RFC match — so you fail fast, locally, with a clear message instead of round-tripping a rejection through the PAC.
- Never stale-cache it. Treat a failed refresh as an incident, not a warning (I’ll come back to monitoring).
Building it: a sample CFDI line with the complement attached
Here’s the shape of it. The XML below is illustrative — element and attribute names follow the SAT structure, but always validate against the current official XSD and catalogs, and keep this vendor-neutral (the pattern stamps against any PAC).
<!-- Illustrative: fuel concepto with the HidroYPetro complement nested at concepto level.
Validate every value against the current official XSD and SAT/CNE catalogs. -->
<cfdi:Concepto
ClaveProdServ="15101514"
Cantidad="1000.00"
ClaveUnidad="LTR"
Descripcion="Gasolina regular"
ValorUnitario="22.50"
Importe="22500.00">
<cfdi:ComplementoConcepto>
<hyp:HidroYPetro
xmlns:hyp="http://www.sat.gob.mx/hidrocarburospetroliferos"
Version="1.0"
TipoPermiso="PER07"
NumeroPermiso="PL/1234/EXP/ES/2025"
ClaveHYP="15101514"
SubProductoHYP="SP16"/>
</cfdi:ComplementoConcepto>
</cfdi:Concepto>
Note the nesting: HidroYPetro (in the http://www.sat.gob.mx/hidrocarburospetroliferos namespace) sits inside cfdi:ComplementoConcepto, which sits inside the cfdi:Concepto. That’s the placement from the section above, made concrete. And see how TipoPermiso (PER07) and NumeroPermiso (PL/1234/EXP/ES/2025) are clearly different fields — the type code versus the actual permit string. The catalog values shown are placeholders; confirm TipoPermiso, ClaveHYP, and SubProductoHYP against the published SAT/CNE catalogs before you ship.
Now the part that actually saves you — the pre-flight validation against the cached L_CNE, run before you ever call the PAC. This is illustrative pseudocode:
# Illustrative: validate the permit locally BEFORE timbrar — fail fast, vendor-neutral.
def validate_hidrocarburos(concepto, emisor_rfc, l_cne_cache):
permiso = concepto.complemento.numero_permiso
# 1) Is the permit in the current L_CNE list, and vigente?
record = l_cne_cache.get(permiso)
if record is None or record.estatus != "VIGENTE":
raise CFDIValidationError(
f"NumeroPermiso {permiso} not vigente in L_CNE "
f"(expired, revoked, or typo). Refusing to timbrar."
)
# 2) Does the emisor RFC match the permit holder on record?
if record.rfc_titular != emisor_rfc:
raise CFDIValidationError(
f"Emisor RFC {emisor_rfc} does not match permit holder "
f"{record.rfc_titular} for {permiso}. Refusing to timbrar."
)
# 3) Guard against a stale cache — never validate against an old list.
if l_cne_cache.age_hours > 24:
raise StaleCacheError(
"L_CNE cache older than 24h; refresh before stamping."
)
return True # safe to call the PAC
def stamp_fuel_cfdi(cfdi, emisor_rfc, l_cne_cache, pac):
for concepto in cfdi.fuel_conceptos():
validate_hidrocarburos(concepto, emisor_rfc, l_cne_cache)
return pac.timbrar(cfdi) # only after every fuel line passes locally
The point of the StaleCacheError is that “the permit looks fine” is meaningless if the list you checked it against is a day old. Fail closed: if you can’t prove the cache is fresh, don’t stamp.
Ship it: get permit data, cache L_CNE, validate, handle rejections
Operationally, here’s the checklist I’d run a fuel-invoicing tenant through:
Step 1 — Get the CNE permit data. For each emisor, capture TipoPermiso and NumeroPermiso from their CNE permit and map it into your tenant config. This is per-emisor master data; treat it like you’d treat a CSD or RFC — verified once, owned, and re-checked when it changes.
Step 2 — Cache L_CNE and refresh on a schedule. Pull the CNE’s L_CNE list into a local store and refresh it on a scheduled job — daily, before your business day, given the update cadence. This is the cache your pre-flight validation reads.
Step 3 — Validate before timbrar. Run the NumeroPermiso presence + vigente + RFC-match check (plus the freshness guard) on every fuel concepto before the PAC call. Most rejections die here, locally, where they’re cheap.
Step 4 — Handle rejections. When the PAC does reject — surface the PAC error code to the caller, log it with the offending NumeroPermiso and RFC, and alert ops. Build retry logic only after the underlying permit data is fixed; blindly retrying a bad permit just burns API calls and time.
Monitoring: alert when the scheduled L_CNE refresh fails. A silent refresh failure is the worst case — you keep validating against a stale cache, pass invoices locally, and discover the problem only when the PAC starts bouncing them. Make the refresh job loud when it breaks.
- Get CNE permit dataTipoPermiso plus NumeroPermiso per emisor into tenant config
- Cache L_CNE, refresh on scheduleScheduled job pulls the CNE list before the business day
- Validate before timbrarPermit in L_CNE, vigente, RFC match, cache fresh
- Handle rejectionsSurface PAC error code, log, alert ops, fix data then retry
FAQ: rejections, multi-line invoices, and who this hits
Q: Why was my fuel CFDI rejected?
One of three things: the complement is missing entirely; the NumeroPermiso isn’t vigente in L_CNE (expired, revoked, or a typo); or the emisor RFC doesn’t match the permit holder on record. Check those in that order.
Q: Does the complement go on the comprobante or the concepto?
The concepto — the HidroYPetro element inside cfdi:ComplementoConcepto, per fuel line. Not the document root. This is the most common structural mistake.
Q: I sell gasolina AND diésel on one invoice — one block or many? Many — one complement block per combustible concepto. Two fuel lines means the block appears twice, once nested in each line.
Q: Who has to comply? Anyone issuing CFDI 4.0 for gasolina or diésel under a CNE permit — gas stations, distributors, marketers. If you stamp fuel and you hold a permit, this is you.
Q: What’s the fine? $17,000 to $97,000 MXN per comprobante for personas morales. On top of the fine, a rejected CFDI never stamps, and contadores warn an improperly issued fuel CFDI can be challenged on deductibility and IVA credit. These figures are accurate as of 2026 — confirm the current amounts and defer fiscal questions to a contador.
For the broader picture of where Mexico’s fuel rules sit among the region’s e-invoicing mandates, see the LATAM electronic invoicing 2026 developer map, and the rest of the fiscal/payments build guides at /mexico-cfdi-invoicing/.
Bottom line
From April 24, 2026 the Complemento de Hidrocarburos y Petrolíferos is non-optional on every CFDI 4.0 that invoices gasolina or diésel. The structural trap is placement — the HidroYPetro element lives at the concepto level, per fuel line, not the comprobante root. The operational trap is the L_CNE list updating daily (as of 2026, confirm current): cache it, refresh it on a schedule, and treat a failed refresh as an incident.
The principle that keeps you out of trouble is the same one I lean on for every regulated pipeline: validate before you timbrar. Check the permit is vigente in the current L_CNE list and the RFC matches — locally, fail fast, with a clear message — so the PAC rejection becomes the rare exception, not your error-handling path.
And the standing caveat: I’m an engineer, not a contador. Confirm the current SAT and CNE rules, and lean on a compliance person for the fiscal edge cases.
Sources: SAT — Complemento Hidrocarburos y Petrolíferos (Factura Electrónica > Complementos) · Facturama — Complemento de Hidrocarburos y Petrolíferos · BDO Mexico — Flash Fiscal · SAT