DTE Normativa v1.2: Technical Guide for Developers
What the Normativa v1.2 Covers
On November 17, 2025, El Salvador's Ministerio de Hacienda (Ministry of Finance) published version 1.2 of the normativa governing Documentos Tributarios Electronicos, or DTEs (Electronic Tax Documents). This is the official technical specification for anyone building an electronic invoicing system or integrating DTE support into an ERP, accounting platform, or e-commerce system operating in El Salvador.
The normativa defines 11 types of tax documents, their JSON structures, digital signing requirements using JWS (JSON Web Signature), the transmission flow to the Ministry's servers, and the validation rules your system must satisfy for documents to be accepted. The full specification is available at the official electronic invoicing portal.
If you're building a DTE integration, this guide gives you a practical overview of the key technical components and TypeScript sample code to get you started faster.
Context for those abroad
If you're a developer based in the US (or anywhere outside El Salvador) building systems for Salvadoran clients, this normativa applies to any software that generates tax documents for businesses operating in El Salvador. Whether your client is a small business or a large enterprise, DTE compliance is mandatory. Understanding these specs is the first step to building a compliant integration.
The 11 DTE Types
The normativa defines 11 types of electronic tax documents, each with its own code, JSON schema version, and specific use case.
| Code | Name | JSON Version | Use Case |
|---|---|---|---|
| 01 | Factura Electronica (FE) | v1 | Sales to end consumers |
| 03 | Comprobante de Credito Fiscal (CCFE) | v3 | Transactions between VAT-registered taxpayers |
| 04 | Nota de Remision (NRE) | v3 | Merchandise transfers |
| 05 | Nota de Credito (NCE) | v3 | Cancellations and discounts |
| 06 | Nota de Debito (NDE) | v3 | Additional charges |
| 07 | Comprobante de Retencion (CRE) | v1 | VAT withholding |
| 08 | Comprobante de Liquidacion (CLE) | v1 | Commission-based operations |
| 09 | Doc. Contable de Liquidacion (DCLE) | v1 | Settlement accounting |
| 11 | Factura de Exportacion (FEXE) | v2 | Export sales |
| 14 | Factura de Sujeto Excluido (FSEE) | v1 | Purchases from non-VAT-registered individuals |
| 15 | Comprobante de Donacion (CDE) | v1 | Donations |
Which document type you'll work with most depends on your client's business. For most companies transacting with other registered taxpayers, the CCFE (code 03) is the primary document. For retail sales to end consumers, it's the FE (code 01). The rest cover more specific scenarios.
JSON Structure Deep Dive
All DTE types share a common base structure with five main sections. Here's a TypeScript interface representing the skeleton:
interface DocumentoTributarioElectronico {
identificacion: {
version: number; // JSON schema version
ambiente: "00" | "01"; // "00" = testing, "01" = production
tipoDte: string; // Document type code (01, 03, 04, etc.)
numeroControl: string; // Format: DTE-XX-MXXXPXXX-XXXXXXXXXXXXXXX
codigoGeneracion: string; // Unique UUID v4 per document
tipoModelo: 1 | 2; // 1 = normal, 2 = contingency
tipoOperacion: 1 | 2; // 1 = normal transmission, 2 = contingency transmission
tipoContingencia: null | 1 | 2 | 3 | 4 | 5;
motivoContin: string | null;
fecEmi: string; // Issue date: "YYYY-MM-DD"
horEmi: string; // Issue time: "HH:mm:ss"
tipoMoneda: "USD"; // Always USD for El Salvador
};
emisor: {
nit: string; // Tax ID number (no dashes)
nrc: string; // Taxpayer registration number
nombre: string; // Legal name
codActividad: string; // Economic activity code
descActividad: string; // Activity description
nombreComercial: string; // Trade name
tipoEstablecimiento: string;
direccion: {
departamento: string; // Department code (01-14)
municipio: string; // Municipality code
complemento: string; // Full address
};
telefono: string;
correo: string;
codEstableMH: string | null;
codEstable: string | null;
codPuntoVentaMH: string | null;
codPuntoVenta: string | null;
};
receptor: {
// Varies by DTE type
// CCFE requires the receiver's NIT/NRC
// FE can use a DUI (national ID) or be anonymous
};
cuerpoDocumento: Array<{
numItem: number; // Line number (1, 2, 3...)
tipoItem: 1 | 2 | 3 | 4; // 1=good, 2=service, 3=both, 4=other
codigo: string | null;
descripcion: string;
cantidad: number;
uniMedida: number; // Unit of measure code
precioUni: number; // Unit price (8 decimal places)
montoDescu: number; // Per-item discount
ventaNoSuj: number; // Non-taxable amount
ventaExenta: number; // Tax-exempt amount
ventaGravada: number; // Taxable amount
}>;
resumen: {
totalNoSuj: number; // Total non-taxable
totalExenta: number; // Total exempt
totalGravada: number; // Total taxable
subTotalVentas: number; // Subtotal
descuNoSuj: number;
descuExenta: number;
descuGravada: number;
totalDescu: number; // Total discounts
subTotal: number;
montoTotalOperacion: number; // Total operation amount
ivaRete1: number; // VAT withheld
reteRenta: number; // Income tax withheld
totalPagar: number; // Total payable (2 decimal places)
totalLetras: string; // Amount in words (Spanish)
condicionOperacion: 1 | 2 | 3; // 1=cash, 2=credit, 3=other
};
}The identificacion section is where you define the document type, its unique generation code, and the Numero de Control (control number) assigned by your system. The emisor section contains the issuer's tax data. The receptor varies by DTE type. The cuerpoDocumento holds the line items. And the resumen consolidates the totals.
Generating UUIDs and Control Numbers
Every DTE requires two unique identifiers: the codigoGeneracion (a UUID v4) and the numeroControl (a formatted control code).
Generation code
The generation code is simply an uppercase UUID v4:
function generateCodigoGeneracion(): string {
return crypto.randomUUID().toUpperCase();
}
// Result: "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"Control number
The Numero de Control follows the format DTE-XX-MXXXPXXX-XXXXXXXXXXXXXXX where:
DTEis a fixed prefixXXis the DTE type code (01, 03, 05, etc.)MXXXPXXXidentifies the business establishment and point of saleXXXXXXXXXXXXXXXis a 15-digit sequential correlative
function generateNumeroControl(
tipoDte: string,
codigoEstablecimiento: string,
codigoPuntoVenta: string,
correlativo: number
): string {
const corr = correlativo.toString().padStart(15, "0");
return `DTE-${tipoDte}-${codigoEstablecimiento}${codigoPuntoVenta}-${corr}`;
}
// Example for a CCFE:
// generateNumeroControl("03", "M001", "P001", 1)
// Result: "DTE-03-M001P001-000000000000001"The correlative is your system's responsibility. It must be sequential and non-repeating for each combination of document type + establishment + point of sale. Additionally, the normativa requires that the control number must restart from 1 on January 1st each year — it cannot repeat within a calendar year. This means your system needs an annual reset mechanism for the correlative per each combination of type + establishment + point of sale.
Building a CCFE (Type 03)
The Comprobante de Credito Fiscal (Tax Credit Receipt) is the most common document for transactions between VAT-registered taxpayers. Here's a TypeScript function that builds a valid skeleton:
interface CcfeParams {
emisor: {
nit: string;
nrc: string;
nombre: string;
codActividad: string;
descActividad: string;
nombreComercial: string;
direccion: { departamento: string; municipio: string; complemento: string };
telefono: string;
correo: string;
codEstableMH: string;
codPuntoVentaMH: string;
};
receptor: {
nit: string;
nrc: string;
nombre: string;
codActividad: string;
descActividad: string;
nombreComercial: string;
direccion: { departamento: string; municipio: string; complemento: string };
telefono: string;
correo: string;
};
items: Array<{
descripcion: string;
cantidad: number;
precioUni: number; // Price without VAT
descuento?: number;
}>;
correlativo: number;
codEstablecimiento: string;
codPuntoVenta: string;
}
function buildCcfe(params: CcfeParams) {
const { emisor, receptor, items, correlativo } = params;
const now = new Date();
const fecEmi = now.toISOString().split("T")[0];
const horEmi = now.toTimeString().split(" ")[0];
// Build document body with VAT calculations
const cuerpoDocumento = items.map((item, index) => {
const montoDescu = item.descuento ?? 0;
const ventaGravada = parseFloat(
(item.cantidad * item.precioUni - montoDescu).toFixed(8)
);
return {
numItem: index + 1,
tipoItem: 1 as const, // 1 = goods
codigo: null,
descripcion: item.descripcion,
cantidad: item.cantidad,
uniMedida: 59, // 59 = unit
precioUni: item.precioUni,
montoDescu,
ventaNoSuj: 0,
ventaExenta: 0,
ventaGravada,
tributos: ["20"], // "20" = IVA (VAT)
};
});
// Calculate summary
const totalGravada = cuerpoDocumento.reduce(
(sum, item) => sum + item.ventaGravada, 0
);
const iva = parseFloat((totalGravada * 0.13).toFixed(2));
const totalPagar = parseFloat((totalGravada + iva).toFixed(2));
return {
identificacion: {
version: 3, // CCFE uses version 3
ambiente: "00" as const, // "00" = testing
tipoDte: "03",
numeroControl: generateNumeroControl(
"03",
params.codEstablecimiento,
params.codPuntoVenta,
correlativo
),
codigoGeneracion: generateCodigoGeneracion(),
tipoModelo: 1 as const, // Normal model
tipoOperacion: 1 as const, // Normal transmission
tipoContingencia: null,
motivoContin: null,
fecEmi,
horEmi,
tipoMoneda: "USD" as const,
},
emisor: {
...emisor,
tipoEstablecimiento: "01", // Main office
codEstable: params.codEstablecimiento,
codPuntoVenta: params.codPuntoVenta,
},
receptor: {
...receptor,
tipoDocumento: "36", // NIT
numDocumento: receptor.nit,
},
cuerpoDocumento,
resumen: {
totalNoSuj: 0,
totalExenta: 0,
totalGravada: parseFloat(totalGravada.toFixed(2)),
subTotalVentas: parseFloat(totalGravada.toFixed(2)),
descuNoSuj: 0,
descuExenta: 0,
descuGravada: 0,
totalDescu: 0,
subTotal: parseFloat(totalGravada.toFixed(2)),
ivaRete1: 0,
reteRenta: 0,
montoTotalOperacion: totalPagar,
totalPagar,
totalLetras: "", // Generate this separately
condicionOperacion: 1,
tributos: [{
codigo: "20",
descripcion: "Impuesto al Valor Agregado 13%",
valor: iva,
}],
},
};
}This skeleton covers the simplest case: a cash sale with items taxed at El Salvador's 13% VAT rate. In production, you'll need to handle exempt sales, discounts, withholdings, and different operation conditions.
JWS Signing: Sign Before You Transmit
Every DTE must be digitally signed before transmission to the Ministry of Finance. The normativa requires the JWS (JSON Web Signature) standard with the RSA-SHA512 algorithm.
The flow works like this:
- You build the DTE JSON
- You sign it with your RSA private key using the RS512 algorithm
- The result is a JWS token (three dot-separated segments: header.payload.signature)
- That JWS token is what you transmit to the Ministry
The Ministry of Finance provides an open-source tool called SVFE-API-Firmador that you can run as a standalone signing service. It's a REST API that takes the JSON and your certificate, and returns the signed JWS.
If you prefer to sign directly in your application, you need a digital certificate (.crt) issued by the Ministry of Finance — an XML file with a CertificadoMH structure containing your base64-encoded private key — and a JWS library that supports RS512.
If you extract the private key from the XML certificate to PEM format, you can sign directly with the jose library:
import { SignJWT, importPKCS8 } from "jose";
async function signDte(
dte: Record<string, unknown>,
privateKeyPem: string
): Promise<string> {
const privateKey = await importPKCS8(privateKeyPem, "RS512");
const jws = await new SignJWT(dte as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: "RS512" })
.sign(privateKey);
return jws;
}If you prefer a ready-made solution that already handles the Ministry of Finance certificate format, the dte-signer-sv package abstracts the private key extraction from the XML and signing into a single step:
import { signDTE } from "dte-signer-sv";
const jws = await signDTE({
dte: myDteDocument,
privatePassword: "certificate-password",
certificate: "./certificates/88888888888888.crt",
loadCertificateFromPath: true,
});The key point: the signature must cover the complete DTE JSON. If you modify any field after signing, validation will fail on the Ministry's side.
Transmission: Test and Production Environments
The Ministry of Finance operates two environments: testing (dte-test) and production. The transmission flow is identical in both -- only the endpoints change.
Authentication and submission flow
-
Get an authentication token. You send your credentials (NIT + password) to the authentication endpoint. You receive a JWT with a limited validity period.
-
Transmit the signed DTE. You send the JWS to the reception endpoint along with your authentication token.
-
Receive the sello de recepcion (reception stamp). If the document passes validation, the Ministry returns a reception stamp confirming the DTE was accepted. This stamp is your proof that the document has fiscal validity.
If the document has structural, calculation, or data errors, you receive a rejection with detailed observations. Your system must handle both acceptance and rejection, and implement retry logic for connection errors.
Development tip
Always develop and test against the testing environment first. Documents issued with ambiente: "00" have no fiscal validity. Only switch to ambiente: "01" for production once everything works correctly.
Document states after transmission
Once you transmit a DTE, the Ministry of Finance classifies it into one of these states:
- Transmitido satisfactoriamente (successfully transmitted): the document was accepted and receives a Sello de Recepcion (reception stamp).
- Ajustado (adjusted): the document was modified by a subsequent document (for example, a Nota de Credito affecting it).
- Observado (observed): the Ministry added observations to the document, but it remains fiscally valid.
- Rechazado (rejected): the document was rejected due to structural, calculation, or data errors. It must be corrected and retransmitted within 24 hours of the rejection notification.
That 24-hour window is strict. Your system needs to detect rejections quickly and make it easy to correct and resubmit before the deadline expires.
Contingency transmission
Contingency mode (tipoModelo: 2) exists for when you can't connect to the Ministry's servers. It only applies to these DTE types: CCFE, FE, FEXE, NRE, NCE, NDE, and FSEE. The types CLE, CRE, DCLE, and CDE do not support contingency mode.
When operating in contingency, your system must retry the connection at least every 15 minutes. Once communication is restored, you have 72 hours from the Evento de Contingencia (contingency event) to transmit all accumulated documents.
The tipoContingencia field (values 1 through 5) indicates the reason — ranging from internet outages to natural disasters. Your system must record the reason and the exact moment the contingency started, because both go into the DTE JSON.
Document Invalidation
There are two reasons you can invalidate a DTE that already has a Sello de Recepcion:
- Errores (errors): data entry mistakes — wrong date, misspelled name, incorrect item description, etc.
- Afectaciones (modifications): business-level changes — price adjustments, service substitutions, or full rescission of the operation.
You can only invalidate documents that were already accepted (meaning they have a Sello de Recepcion). For FE and FEXE, invalidation is allowed within 3 months of the emission date. For CLE documents, the related NCE (Nota de Credito) must also go through the invalidation process.
The Evento de Invalidacion (invalidation event) must be transmitted to the Ministry the day after the Sello de Recepcion of the invalidation is obtained. From a development perspective, this means your system needs to track document states and automatically enforce these time windows to prevent invalidations from missing their deadlines.
Rounding Rules: The Detail That Breaks Integrations
This is one of the areas where integrations fail most often. The normativa establishes precise rounding rules that your system must respect:
- At the item level (
cuerpoDocumento): calculations must use 8 decimal places. This applies toprecioUni,ventaGravada,ventaExenta, andventaNoSuj. - At the summary level (
resumen): totals are rounded to 2 decimal places. - Tolerance: a difference of +/- $0.01 is allowed between the sum of items and the summary totals. If your difference exceeds one cent, the document will be rejected.
// Correct: 8 decimal places for items
const precioUni = 10.12345678;
const ventaGravada = parseFloat((cantidad * precioUni).toFixed(8));
// Correct: 2 decimal places for summary
const totalGravada = parseFloat(
items.reduce((sum, item) => sum + item.ventaGravada, 0).toFixed(2)
);If you're migrating from a system that uses 2 decimal places everywhere, you'll need to adjust the precision of intermediate calculations. This change seems minor, but it's the cause of a significant number of rejections in new integrations.
What Changed in v1.2
Version 1.2 of the normativa introduces a specific change in section 7: it adds the "compra por cuenta de terceros" (third-party purchasing) functionality for Facturas de Exportacion (FEXE, code 11). This allows documenting operations where one entity makes export purchases on behalf of another. This change is also why the FEXE JSON schema version was bumped from v1 to v2.
Specifically, three fields were added under the "Compra por cuenta de terceros" section:
- Field 72:
compraCuentaTerceros— an object ornull. Required when the operation applies, and only used for FEXE documents. - Field 73: Third-party identification number — alphanumeric, max 20 characters.
- Field 74: Third-party name — alphanumeric, max 250 characters.
Here's what the structure looks like in TypeScript:
// Section 7: Third-party purchasing (FEXE only, v1.2)
interface CompraCuentaTerceros {
nit: string; // Field 73: identification document, max 20 chars
nombre: string; // Field 74: name or business name, max 250 chars
}In the DTE JSON, the compraCuentaTerceros field is this object when applicable, or null when the operation doesn't involve third-party purchasing.
The Ministry of Finance grants a 3-month adaptation period from the publication date (November 17, 2025) for systems to incorporate this change. The deadline to have your system updated was February 2026.
If your system doesn't handle export invoices, this change doesn't affect you directly. But if you're building a general-purpose electronic invoicing platform, you need to implement the additional FEXE fields that support this modality.
Try the DTE Template Builder
Try the DTE Template Builder
Select any of the 11 DTE types, configure options, and get a ready-to-use JSON template for your system.
Go to DTE Builder →References:
- Electronic Invoicing Portal -- factura.gob.sv
- Official JSON Schemas -- Ministry of Finance
- Electronic Invoicing Technical Manual -- Ministry of Finance
- Transmission System Catalogs -- Ministry of Finance (PDF)
- Transmission System Catalogs v1.2 -- Ministry of Finance (XLSX)
- DTE Compliance Normativa v1.2 -- Ministry of Finance (PDF)