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.

Respuesta corta: desde Node.js 18 o superior podés emitir una factura electrónica con el
fetchnativo: unPOSTa/documents/facturaconX-API-Key,X-API-SecreteIdempotency-Key. La respuesta202trae 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-Secretse muestra una sola vez: guardalo en tu gestor de secretos. - Un emisor: tu certificado
.p12subido, o el emisor de pruebasEMISORPRUEBA, ya habilitado en tu cuenta para el sandbox. - Node.js 18+, que trae
fetchsin 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 tumappuede generar unimpuestovacío, cortalo antes: Hacienda rechaza con-1y el consecutivo se quema. - Los totales no se mandan. Se calculan desde las líneas.
montoTotalysubtotalde línea sí se pueden enviar para contrastar: si no cuadran,400con los dos números. - Un campo vacío es un campo no enviado.
""ynullse tratan como ausentes, así que no hace falta limpiar el objeto. El0sí 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.
