factadesarrolladores API 0.1.0-esqueleto

@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

sh
# 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/api

Configuración

typescript
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ónTipoQué hace
apiKeytexto, requeridafacta_test_… o facta_live_…. Léala del gestor de secretos, no de un archivo del repositorio.
signKeyfactask_…, condicionalHace 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.
baseUrltexto, opcionalPor defecto la URL pública. Una llave facta_test_ sigue emitiendo en pruebas con esa misma URL.
timeoutMsnúmero, opcional, defecto 60000Plazo de la petición entera. El Ministerio puede tardar unos cuarenta segundos en contestar.
maxRetriesnúmero, opcional, defecto 3Cuántas veces reintentar las condiciones donde reintentar es seguro.
fetchfunción, opcionalPara inyectarlo en pruebas.

CallOptions — el segundo argumento de los métodos que escriben:

OpciónQué hace
idempotencyKeyLa 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.
signalUn 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.

typescript
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.

typescript
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.

typescript
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á reservado

firmar(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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

PropiedadTipoQué es
codeFactaErrorCodeEl contrato, y no cambia. Los del servidor más network_error, que el servidor nunca manda: la petición no llegó a recibir respuesta.
statusnúmeroEl HTTP. 0 cuando el fallo ocurrió antes de salir.
messagetextoEspañ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.
detailsdesconocidoDepende del código. validation_failed trae issues; rate_limited, la ventana y los segundos.
isRejectionsí/no«Hacienda leyó el documento y lo negó», frente a «no llegamos a Hacienda». Es la diferencia que decide qué hacer después.
spentobjeto o nullEl correlativo que este fallo ya gastó, cuando lo hay. El §167 permite corregir con ese mismo número.
mhObservationslista de textoLo que dijo Hacienda, palabra por palabra, cuando rechazó.
typescript
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 el estado antes de leer totales.
  • DtePreparado, DteConsultado, DteAnulado, DtePage, HoldingPage.
  • Status, Ventana — lo que devuelve status().
  • FactaError, FactaErrorCode, SpentCorrelative.

Lo que este cliente no hace

  1. 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.
  2. 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.
  3. No guarda archivos. No hay adaptador de almacenamiento local todavía; downloadDocument le 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.

Escriba para buscar Por ejemplo: idempotencia, contingencia, 422, emitir.

moverse Enter abrir Esc cerrar