Blog
Desarrolladores·17 de octubre de 2026 · 9 min

Factura electrónica con Node.js y JavaScript: guía de la API

En Node.js emitís una factura electrónica con fetch: POST con tres cabeceras, 202 con la clave y el veredicto por webhook firmado con HMAC. Código listo.

Factura electrónica con Node.js y JavaScript: guía de la API

Respuesta corta: desde Node.js 18 o superior podés emitir una factura electrónica con el fetch nativo: un POST a /documents/factura con X-API-Key, X-API-Secret e Idempotency-Key. La respuesta 202 trae la clave y el consecutivo; el resultado de Hacienda llega después, por un webhook que verificás con HMAC-SHA256.

Esta guía arma una integración completa en JavaScript del lado del servidor: un cliente mínimo, la emisión, el manejo de errores, la espera del veredicto y el webhook con Express. Todo con la base https://api.facturaencr.com/v2/efactura.

Antes de empezar

  • Una llave de API creada en el portal. El X-API-Secret se muestra una sola vez: guardalo en tu gestor de secretos.
  • Un emisor: tu certificado .p12 subido, o el emisor de pruebas EMISORPRUEBA, ya habilitado en tu cuenta para el sandbox.
  • Node.js 18+, que trae fetch sin dependencias.

⚠️ Esto va siempre en el servidor. El secret firma comprobantes fiscales a nombre del comercio: nunca lo pongás en código que corre en el navegador.

Un cliente mínimo

const BASE = 'https://api.facturaencr.com/v2/efactura';

async function llamar(metodo, ruta, { body, idempotencyKey } = {}) {
  const headers = {
    'X-API-Key': process.env.EFACTURA_KEY,
    'X-API-Secret': process.env.EFACTURA_SECRET,
  };
  if (body) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(BASE + ruta, {
    method: metodo,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  const datos = res.status === 204 ? null : await res.json();
  if (!res.ok) {
    const err = new Error(datos?.message || 'Error ' + res.status);
    err.status = res.status;
    err.codigo = datos?.error;          // estable: programá contra esto
    err.detalles = datos?.details || []; // solo en validation_error
    err.requestId = datos?.requestId;
    err.retryAfter = res.headers.get('Retry-After');
    throw err;
  }
  return datos;
}

Lo primero que conviene llamar es POST /auth/verify: devuelve apiKey.environment, que te dice si esa llave emite en sandbox o en producción. El ambiente lo decide la llave, no la URL.

Emitir la factura

async function emitirFactura(pedido) {
  return llamar('POST', '/documents/factura', {
    idempotencyKey: 'pedido-' + pedido.id,   // estable por venta, no por intento
    body: {
      emisorLegalId: '3101000000',            // la cédula del comercio
      condicionVenta: '01',
      medioPago: ['01'],
      receptor: {
        tipoIdentificacion: '02',
        numeroIdentificacion: '3101000003',
        nombre: 'Comercial Ejemplo S.A.',
        correoElectronico: 'compras@ejemplo.cr',
      },
      detalle: pedido.lineas.map((l) => ({
        codigoCabys: l.cabys,                 // 13 dígitos, del catálogo real
        cantidad: l.cantidad,
        unidadMedida: l.unidad,               // p. ej. "Unid" o "Sp"
        detalle: l.descripcion,
        precioUnitario: l.precioSinIva,
        impuesto: [{ codigo: '01', codigoTarifa: '08', tarifa: 13 }],
      })),
    },
  });
}

const doc = await emitirFactura(pedido);
// { documentId, clave, consecutivo, status: 'queued', environment, estimatedReadyAt }

Detalles que se pagan caro si se ignoran:

  • Cada línea declara IVA, incluso las exentas (codigoTarifa: '10', tarifa: 0). Si tu map puede generar un impuesto vacío, cortalo antes: Hacienda rechaza con -1 y el consecutivo se quema.
  • Los totales no se mandan. Se calculan desde las líneas. montoTotal y subtotal de línea sí se pueden enviar para contrastar: si no cuadran, 400 con los dos números.
  • Un campo vacío es un campo no enviado. "" y null se tratan como ausentes, así que no hace falta limpiar el objeto. El 0 sí es un dato.

Manejar los errores

try {
  await emitirFactura(pedido);
} catch (e) {
  if (e.status === 400) marcarParaRevision(pedido, e.detalles); // no gastó consecutivo
  else if (e.codigo === 'idempotency_in_progress') reintentarLuego(pedido, 5);
  else if (e.status === 429 || e.status >= 500) reintentarLuego(pedido, Number(e.retryAfter) || 30);
  else throw e;
}

Los 429 y 5xx son seguros de reintentar con la misma clave: si la petición no llegó a emitir, no consumió consecutivo; si sí emitió, recibís la respuesta original.

El webhook con Express

Registrá tu endpoint una vez (POST /webhooks con url y events: ['document.accepted', 'document.rejected']) y guardá el secret que devuelve. Para verificar la firma necesitás el cuerpo crudo, por eso se usa express.raw en esa ruta:

const crypto = require('crypto');
const express = require('express');
const app = express();

function firmaValida(rawBody, cabecera, secret, toleranciaSeg = 300) {
  const partes = Object.fromEntries(
    (cabecera || '').split(',').map((kv) => kv.split('=').map((s) => s.trim()))
  );
  const t = Number(partes.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;
  const esperado = crypto.createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex');
  const a = Buffer.from(esperado, 'hex');
  const b = Buffer.from(partes.v1 || '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/hooks/efactura', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!firmaValida(raw, req.get('X-Efactura-Signature'), process.env.EFACTURA_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);              // respondé ya
  encolar(JSON.parse(raw));         // { event, documentId, timestamp, data: { clave, consecutivo, ... } }
});

Tres reglas del handler: respondé 2xx enseguida, sé idempotente (usá X-Efactura-Delivery-Id o la clave) y no confiés en el orden de los eventos. Podés probar el endpoint sin emitir nada con POST /webhooks/{id}/test.

Si no podés recibir webhooks

Consultá GET /documents/{id} con backoff: +10 s → +20 s → +40 s → +60 s y después cada 5 minutos hasta 30. Si sigue sin veredicto, POST /documents/{id}/refresh fuerza la reconsulta a Hacienda. Las ventajas de cada camino están en webhooks vs polling.

TypeScript

La especificación completa está en GET /docs/openapi.yaml (y en JSON en /docs/openapi.json). Podés generar los tipos con cualquier generador de OpenAPI y tipar el cliente de arriba sin escribirlos a mano. También hay una colección de Postman en GET /docs/postman.json.

Preguntas frecuentes

¿Puedo llamar a la API desde React o desde el navegador?

No. El X-API-Secret tiene que quedar en tu servidor. El navegador le pide a tu backend y tu backend llama a la API.

¿Qué uso como Idempotency-Key?

Algo que identifique la venta: pedido-4471. Un UUID nuevo en cada reintento no protege de nada. Está explicado en idempotencia.

¿El 202 significa que Hacienda la aceptó?

No. Significa que la recibimos, la validamos y la firmamos. No entregués el comprobante hasta tener status: "accepted".

¿Cómo emito una nota de crédito desde Node?

Igual, con POST /documents/nota-credito y el bloque referencia. Lo detallamos en cómo emitir una nota de crédito por API.

La API de Facturaencr cubre los comprobantes de la v4.4 con el mismo patrón que viste acá. Probala en el sandbox gratis (100 comprobantes al mes) y revisá la documentación para los catálogos y los ejemplos de cada endpoint.

Empezá a emitir con la API

Sandbox gratis. Tus primeros 100 documentos sin costo. Pagás solo por lo que emitís.