@firmo-ecf/sdk
v0.1.2
Published
SDK oficial de Firmo para emitir e-CF (DGII, República Dominicana)
Maintainers
Readme
@firmo-ecf/sdk
SDK oficial de Firmo en TypeScript para emitir y consultar comprobantes fiscales electrónicos (e-CF) de la DGII (República Dominicana).
- Sin dependencias: usa el
fetchnativo. Funciona en Node ≥ 18, navegadores, Deno, Bun y entornos edge. - Tipado completo de la API v1.
Idempotency-Keyautomática en cadaPOST, reintentos con backoff en 429/5xx (respetaRetry-After).- Errores tipados en español.
- Verificación de firmas de webhooks con
crypto.subtle.
Instalación
npm install @firmo-ecf/sdk
# o
pnpm add @firmo-ecf/sdkInicio rápido
import { Firmo } from '@firmo-ecf/sdk';
const firmo = new Firmo({ apiKey: process.env.FIRMO_API_KEY! });
const ecf = await firmo.ecf.emitir(
{
tipo: 31, // Factura de Crédito Fiscal
comprador: { rnc: '131880681', razonSocial: 'Cliente SRL', correo: '[email protected]' },
items: [
{ descripcion: 'Consultoría', cantidad: 10, precioUnitario: 2500, indicadorBienServicio: 2 },
{ descripcion: 'Libro', cantidad: 1, precioUnitario: 800, indicadorFacturacion: 4 }, // exento
],
},
{ esperar: true }, // ?wait=true: espera hasta 20 s el resultado de la DGII
);
console.log(ecf.encf, ecf.estado, ecf.montoTotal); // E310000000001 aceptado 30300Las claves sk_test_… operan en sandbox (simulador de la DGII si la empresa no ha subido certificado). Las sk_live_… operan en producción.
Opciones del cliente
new Firmo({
apiKey: 'sk_test_…',
baseUrl: 'https://api.firmo.do', // por defecto
empresaId: '…', // header Firmo-Company (integradores con varias empresas)
timeoutMs: 60_000, // por intento
maxRetries: 2, // reintentos en 429/5xx/red
fetch: customFetch, // opcional
});El SDK envía en cada petición la cabecera Firmo-Channel: sdk, para que Firmo registre en el campo canal del e-CF que se emitió con el SDK. Es información declarada por el cliente (no se verifica). FiltrosEcf.canal filtra firmo.ecf.listar() por canal.
Para actuar sobre otra empresa sin crear un cliente desde cero: firmo.conEmpresa(empresaId).
Recursos
| Recurso | Métodos |
|---|---|
| firmo.ecf | emitir(input, { esperar?, idempotencyKey? }), obtener(idOEncf), listar(filtros), listarTodos(filtros), xml(idOEncf), qr(idOEncf) (ArrayBuffer PNG), pdf(idOEncf) (ArrayBuffer; HTML imprimible en v1), reenviar(idOEncf) (solo error o pendiente sin avanzar), consultarEstado(idOEncf) (consulta real a la DGII), corregir(idOEncf, input) (rechazados), intentos(idOEncf), reenviarPendientes(), enviarCorreo(idOEncf, correo) |
| firmo.recibidos | listar(filtros), listarTodos(filtros), obtener(id), aprobar(id, motivo?), rechazar(id, motivo) |
| firmo.anulaciones | crear({ rangos }) |
| firmo.empresa | obtener(), actualizar(cambios), subirCertificado(archivo, password) |
| firmo.secuencias | listar(), crear({ tipo, desde, hasta, venceEn }) |
| firmo.empresas | listar(), crear(input) (integradores) |
| firmo.webhooks | listar(), crear({ url, eventos }), eliminar(id) |
| firmo.consultas | estado({ encf, rncEmisor, rncComprador?, codigoSeguridad? }) |
| firmo.certificacion | progreso() |
| Firmo.cuentas | crear(input, { baseUrl? }) (estático, sin API key) |
| Firmo.webhooks | verificarFirma(body, header, secreto, toleranciaSeg = 300), construirEvento(...) (estáticos) |
Paginación
const pagina = await firmo.ecf.listar({ estado: 'rechazado', desde: '2026-10-01', limite: 50 });
// GET /v1/ecf?status=rechazado&from=2026-10-01&limit=50
// pagina.datos, pagina.siguiente (cursor)
for await (const ecf of firmo.ecf.listarTodos({ tipo: 32, desde: '2026-10-01' })) {
console.log(ecf.encf, ecf.montoTotal);
}Descargas
import { writeFile } from 'node:fs/promises';
const xml = await firmo.ecf.xml('E310000000001');
await writeFile('E310000000001.png', Buffer.from(await firmo.ecf.qr('E310000000001')));
const html = new TextDecoder().decode(await firmo.ecf.pdf('E310000000001'));Certificado digital
import { readFile } from 'node:fs/promises';
await firmo.empresa.subirCertificado(await readFile('certificado.p12'), process.env.P12_PASSWORD!);Registro de sandbox
const cuenta = await Firmo.cuentas.crear({ nombre: 'Ana Pérez', correo: '[email protected]', empresa: 'Ana SRL' });
// cuenta.apiKey → sk_test_… (solo se muestra una vez)Totales antes de emitir
calcularTotales replica la lógica de la DGII de forma local (sin red): agrupa por indicador de facturación (1 = 18%, 2 = 16%, 3 = 0%, 4 = exento, 0 = no facturable) y redondea el ITBIS a 2 decimales.
import { calcularTotales } from '@firmo-ecf/sdk';
const t = calcularTotales([
{ cantidad: 2, precioUnitario: 1500 },
{ cantidad: 1, precioUnitario: 300, indicadorFacturacion: 4 },
]);
// t.montoGravadoTotal 3000, t.totalItbis 540, t.montoExento 300, t.montoTotal 3840Errores
Todas las fallas lanzan FirmoError (status, codigo, mensaje, detalles) o una subclase:
| Clase | Cuándo |
|---|---|
| FirmoValidacionError | 400 validacion (ver detalles) |
| FirmoAuthError | 401 no_autorizado, 403 prohibido |
| FirmoLimiteError | 429 limite_excedido (retryAfterSeg) |
| FirmoDgiiNoDisponibleError | 502 dgii_no_disponible: el e-CF queda en cola y Firmo lo reintenta |
| FirmoError | resto (no_encontrado, secuencia_agotada, certificado_faltante, conexion, timeout…) |
import { FirmoError, FirmoValidacionError } from '@firmo-ecf/sdk';
try {
await firmo.ecf.emitir(input);
} catch (e) {
if (e instanceof FirmoValidacionError) console.error(e.mensaje, e.detalles);
else if (e instanceof FirmoError && e.codigo === 'secuencia_agotada') {
// registra un nuevo rango con firmo.secuencias.crear(...)
} else throw e;
}Idempotencia y reintentos
Cada POST lleva una Idempotency-Key (crypto.randomUUID()) que se reutiliza en los reintentos, así que un reintento nunca duplica un e-CF. Pasa tu propia clave para que sea estable entre procesos:
await firmo.ecf.emitir(input, { idempotencyKey: `pedido-${pedido.id}` });Se reintentan 429, 5xx y errores de red (por defecto 2 veces) con backoff exponencial y jitter, respetando Retry-After. Los PATCH no se reintentan.
Webhooks
Firmo envía POST con { id, evento, creadoEn, datos } y el header Firmo-Signature: t=<unix>,v1=<hex>, donde v1 = HMAC-SHA256(secreto, t + "." + body). Verifica siempre con el cuerpo crudo:
// Express
import express from 'express';
import { Firmo } from '@firmo-ecf/sdk';
app.post('/webhooks/firmo', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = await Firmo.webhooks.verificarFirma(req.body, req.get('Firmo-Signature'), process.env.FIRMO_WEBHOOK_SECRET!);
if (!ok) return res.status(400).send('firma inválida');
const evento = JSON.parse(req.body.toString('utf8'));
if (evento.evento === 'ecf.aceptado') {
/* … */
}
res.sendStatus(200);
});// Next.js / Hono / Cloudflare Workers (Request estándar)
export async function POST(req: Request) {
const body = await req.text();
try {
const evento = await Firmo.webhooks.construirEvento(body, req.headers.get('Firmo-Signature'), process.env.FIRMO_WEBHOOK_SECRET!);
// evento.evento, evento.datos
return new Response('ok');
} catch {
return new Response('firma inválida', { status: 400 });
}
}La tolerancia por defecto es de 300 s entre t y la hora actual (protege contra repetición); pasa otro valor como cuarto argumento (0 la desactiva).
Eventos: ecf.aceptado, ecf.aceptado_condicional, ecf.rechazado, ecf.error, ecf.reintentado, recibido.nuevo, certificado.por_vencer, secuencia.por_agotarse.
Licencia
MIT
