@facta/api
El único cliente oficial. Node 22 y 24, y también Deno y Bun: fetch y crypto.randomUUID son los dos únicos globales que toca, y los dos son estándar en todas partes.
Instalación
# Hoy: desde el código fuente del repositorio.
# sdks/typescript/ → mod.ts, src/, dist/index.js
#
# El paquete todavía NO está publicado en npm. Cuando lo esté, la línea
# será esta, y nada más de esta página cambia:
pnpm add @facta/apiConfiguración
import { Facta } from "@facta/api";
const facta = new Facta({
apiKey: process.env.FACTA_API_KEY!,
// Solo hace falta para emitir, firmar y anular. Consultar no la pide.
signKey: process.env.FACTA_SIGN_KEY!,
});FactaOptions:
| Opción | Tipo | Qué hace |
|---|---|---|
apiKey | texto, requerida | facta_test_… o facta_live_…. Léala del gestor de secretos, no de un archivo del repositorio. |
signKey | factask_…, condicional | Hace falta para emitir, firmar y anular; no para consultar. Guárdela fuera del mismo .env que apiKey. Si le pasa una factauk_ por error, el constructor la rechaza por el prefijo. |
baseUrl | texto, opcional | Por defecto la URL pública. Una llave facta_test_ sigue emitiendo en pruebas con esa misma URL. |
timeoutMs | número, opcional, defecto 60000 | Plazo de la petición entera. El Ministerio puede tardar unos cuarenta segundos en contestar. |
maxRetries | número, opcional, defecto 3 | Cuántas veces reintentar las condiciones donde reintentar es seguro. |
fetch | función, opcional | Para inyectarlo en pruebas. |
CallOptions — el segundo argumento de los métodos que escriben:
| Opción | Qué hace |
|---|---|
idempotencyKey | La suya. Si su sistema ya tiene un identificador para esta venta, páselo: así la operación es idempotente entre reinicios del proceso, no solo entre reintentos de una llamada. Sin ella, el cliente genera una y la reusa en todos los reintentos. |
signal | Un AbortSignal para cancelar. Los reintentos se detienen con la misma señal. |
Los diez métodos
status()
No manda la llave de firma. Devuelve Status.
Lo primero que conviene llamar. Confirma que la llave sirve, dice para qué empresa emite, en qué ambiente, si puede firmar y cuánto cupo le queda. No exige ningún alcance.
const estado = await facta.status();
console.log(estado.ambiente); // "00" en pruebas, "01" en producción
console.log(estado.llave.alcances); // ["issue", "query", "download"]
console.log(estado.limites.hora?.remaining);emitir(request, options?)
Manda la llave de firma. Devuelve DteSellado | DteEnContingencia.
Prepara, firma y transmite en una sola operación. Es el camino normal. Devuelve una unión: estado === "sellado" trae selloRecibido y totales; estado === "contingencia" no, porque todavía no hay veredicto — y el tipo lo obliga. Errores propios: mh_rejected, validation_failed, no_storage_destination, amount_limit.
import { Facta, FactaError } from "@facta/api";
const facta = new Facta({
apiKey: process.env.FACTA_API_KEY!,
signKey: process.env.FACTA_SIGN_KEY!,
});
try {
const dte = await facta.emitir(
{
tipoDte: "03",
receptor: { customerId: "374114b6-e957-4c7a-8911-dd6381b1e0ea" },
items: [{ descripcion: "Integración de la API", cantidad: 1, precioUni: 25 }],
},
{ idempotencyKey: "venta-2026-09-02-00417" },
);
console.log(dte.estado, dte.numeroControl);
if (dte.estado === "sellado") console.log(dte.selloRecibido, dte.totales.totalPagar);
} catch (error) {
if (error instanceof FactaError && error.isRejection) {
console.error("rechazado:", error.message, "· gastó:", error.spent?.numeroControl);
} else throw error;
}preparar(request, options?)
No manda la llave de firma. Devuelve DtePreparado.
Reserva el correlativo y devuelve el documento canónico con sus totales, sin firma. Para quien quiere ver el documento antes de firmarlo. Reserva el número, así que un preparar sin su firmar deja un correlativo entregado, y eso es un hueco que alguien tiene que explicar. Por eso el prepareToken vence a los 15 minutos.
const preparado = await facta.preparar({
tipoDte: "01",
items: [{ descripcion: "Café", cantidad: 2, precioUni: 1.5 }],
});
console.log(preparado.totales.totalPagar); // ya calculado por el servidor
console.log(preparado.numeroControl); // el número ya está reservadofirmar(prepared, options?)
Manda la llave de firma. Devuelve DteSellado | DteEnContingencia.
Firma y transmite exactamente lo que devolvió preparar. Pase el documento sin tocar un centavo: el servidor comprueba un MAC sobre su hash canónico, y un cambio se rechaza con prepare_token_invalid en vez de firmarse.
const dte = await facta.firmar(preparado);consultar(codigoGeneracion)
No manda la llave de firma. Devuelve DteConsultado.
Un documento por su código de generación, en el estado en que esté. Contesta también por los rechazados, que no tienen fila de índice pero sí reserva: devolver 404 para un número que Hacienda negó mandaría a buscar un error que no existe.
const uno = await facta.consultar("7875BC7A-9580-441D-94E4-FA455E9D8BD0");
console.log(uno.estado); // "rechazado", "sellado", "contingencia"…listDocuments(filters?)
No manda la llave de firma. Devuelve DtePage.
El libro de lo sellado, del más nuevo al más viejo, con filtros por fecha, estado y tipo. La paginación es por cursor, no por página: ?pagina=2 sobre una tabla que crece repite un documento y se salta otro. Un documento rechazado no está aquí. Si al reconciliar aparece un hueco en su numeración, pregunte por ese código con consultar.
let cursor: string | null = null;
do {
const pagina = await facta.listDocuments({
desde: "2026-09-01",
hasta: "2026-09-30",
estado: "sellado",
limit: 100,
...(cursor ? { cursor } : {}),
});
for (const fila of pagina.documentos) {
console.log(fila.numeroControl, fila.totales?.totalPagar);
}
cursor = pagina.siguiente;
} while (cursor !== null);invalidate(codigoGeneracion, request, options?)
Manda la llave de firma. Devuelve DteAnulado.
Anula un documento que Hacienda ya selló. No es un borrado: es un evento, con su propio documento firmado, su propio código y su propio sello. No hay forma de deshacerla. Los tres tipos no son intercambiables: el 1 (error en el documento) y el 3 (otro) exigen motivo y codigoGeneracionReemplazo —emita primero el documento correcto—; el 2 (rescisión) prohíbe nombrar reemplazo. Pedir la anulación de algo ya anulado contesta 200 con yaEstabaInvalidado: true.
const anulado = await facta.invalidate(codigoGeneracion, {
tipoAnulacion: 1,
motivo: "El precio unitario iba sin el descuento pactado",
codigoGeneracionReemplazo: "7875BC7A-9580-441D-94E4-FA455E9D8BD0",
responsable: { nombre: "Ana Rivas", tipoDocumento: "36", numDocumento: "06142803901121" },
solicita: { nombre: "Beto Cruz", tipoDocumento: "13", numDocumento: "012345678" },
});
if (anulado.yaEstabaInvalidado) console.log("ya estaba anulado; no se mandó nada");downloadDocument(codigoGeneracion, kind?, options?)
No manda la llave de firma. Devuelve DownloadedDocument.
Los bytes exactos del JSON firmado o del PDF, sin parsear ni volver a serializar. Exige el alcance download. El área de retención dura una hora desde la firma, pero pasada esa hora la ruta sigue contestando: el documento se rearma desde la reserva, que guarda el JWS sellado.
const archivo = await facta.downloadDocument(dte.codigoGeneracion, "json");
await writeFile(archivo.filename ?? `${dte.codigoGeneracion}.json`, archivo.bytes);
console.log(archivo.contentType); // "application/json"listHolding(limit?)
No manda la llave de firma. Devuelve HoldingPage.
Qué hay en su área de retención ahora mismo. Evidencia de estado —dónde aterrizó, intentos, cuándo vence, cuántas veces se descargó—, nunca rutas de bucket ni contenido.
const pagina = await facta.listHolding(50);
for (const fila of pagina.documentos) {
console.log(fila.codigoGeneracion, fila.whereLanded, fila.expiresAt);
}getContract()
No manda la llave de firma. Devuelve el contrato OpenAPI.
El contrato que publica la propia API. Es la única ruta que no autentica: lo primero que necesita quien va a integrar es saber qué hay, y eso ocurre antes de tener una llave.
const contrato = await facta.getContract();
console.log(contrato.info?.version);FactaError
| FactaErrorCode |
|---|
unauthorized |
invalid_api_key |
key_revoked |
key_expired |
key_inactive |
forbidden_scope |
dte_type_not_allowed |
ip_not_allowed |
environment_not_allowed |
sign_key_required |
sign_key_invalid |
sign_vault_locked |
sign_vault_missing |
invalid_request |
validation_failed |
not_found |
method_not_allowed |
idempotency_key_required |
idempotency_key_reuse |
idempotency_in_flight |
prepare_token_invalid |
rate_limited |
amount_limit |
mh_rejected |
mh_unreachable |
correlative_unavailable |
service_unavailable |
no_storage_destination |
internal_error |
network_error |
Todo fallo llega como un FactaError. Es lo único sobre lo que conviene decidir.
| Propiedad | Tipo | Qué es |
|---|---|---|
code | FactaErrorCode | El contrato, y no cambia. Los del servidor más network_error, que el servidor nunca manda: la petición no llegó a recibir respuesta. |
status | número | El HTTP. 0 cuando el fallo ocurrió antes de salir. |
message | texto | Español, para el humano que lee el registro. Puede reescribirse: un cliente que haga switch sobre el mensaje se rompe con una corrección de ortografía. |
details | desconocido | Depende del código. validation_failed trae issues; rate_limited, la ventana y los segundos. |
isRejection | sí/no | «Hacienda leyó el documento y lo negó», frente a «no llegamos a Hacienda». Es la diferencia que decide qué hacer después. |
spent | objeto o null | El correlativo que este fallo ya gastó, cuando lo hay. El §167 permite corregir con ese mismo número. |
mhObservations | lista de texto | Lo que dijo Hacienda, palabra por palabra, cuando rechazó. |
import { FactaError } from "@facta/api";
try {
await facta.emitir(venta);
} catch (error) {
if (!(error instanceof FactaError)) throw error;
switch (error.code) {
case "mh_rejected":
console.error(error.spent?.numeroControl, error.mhObservations);
break;
case "no_storage_destination":
break;
case "validation_failed":
console.error(error.details);
break;
default:
throw error;
}
}Reintentos y contingencia
El cliente reintenta solo donde reintentar es seguro, con espera creciente: idempotency_in_flight (la primera petición sigue trabajando), service_unavailable, correlative_unavailable, mh_unreachable y network_error. Nunca en un 4xx y nunca en un rechazo.
La Idempotency-Key se acuña una vez, fuera del bucle. Generar una nueva por intento es exactamente el error que esa cabecera existe para evitar: quemaría un segundo correlativo.
Y un 202 no es un error, así que no pasa por aquí: llega como un resultado con estado: "contingencia". Lo cuenta entera la guía Contingencia y resultados inciertos.
Tipos
Los tipos se leen del contrato TypeScript (mod.ts), no de un .d.ts generado. Los que se usan a diario:
SolicitudDte,Item,Receptor— lo que se manda.ResultadoEmision = DteSellado | DteEnContingencia— la unión que obliga a mirar elestadoantes de leertotales.DtePreparado,DteConsultado,DteAnulado,DtePage,HoldingPage.Status,Ventana— lo que devuelvestatus().FactaError,FactaErrorCode,SpentCorrelative.
Lo que este cliente no hace
- No calcula dinero. Ni IVA, ni retenciones, ni totales, ni el número de control, ni fechas fiscales. Todo eso lo produce el servidor y el cliente lo transporta. Un SDK que calcule dinero es un segundo motor fiscal, y dos motores se desincronizan el primer martes.
- No firma. El certificado no pasa por aquí en ningún momento. Lo que sí pasa, en las rutas que firman, es
X-Facta-Sign-Key: la contraseña que abre el vault en el servidor. El cliente la reenvía y no hace nada con ella. - No guarda archivos. No hay adaptador de almacenamiento local todavía;
downloadDocumentle entrega los bytes y usted decide dónde ponerlos.
Una prueba de arquitectura del propio paquete falla si alguna de las dos primeras deja de ser cierta.