Back to blog

DTE Normativa v1.2: Technical Guide for Developers

By Marvin Calero·15 min read

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:

  • DTE is a fixed prefix
  • XX is the DTE type code (01, 03, 05, etc.)
  • MXXXPXXX identifies the business establishment and point of sale
  • XXXXXXXXXXXXXXX is 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:

  1. You build the DTE JSON
  2. You sign it with your RSA private key using the RS512 algorithm
  3. The result is a JWS token (three dot-separated segments: header.payload.signature)
  4. 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

  1. Get an authentication token. You send your credentials (NIT + password) to the authentication endpoint. You receive a JWT with a limited validity period.

  2. Transmit the signed DTE. You send the JWS to the reception endpoint along with your authentication token.

  3. 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 to precioUni, ventaGravada, ventaExenta, and ventaNoSuj.
  • 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 or null. 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: