facta-dte/api — SDK de PHP
Un cliente de PHP para la API de Facta. Emite, consulta, anula y descarga sin que usted arme cabeceras, repita peticiones a mano ni escriba el código de reintentos. No firma y no calcula impuestos: el correlativo, los totales, el IVA, la firma y la transmisión a Hacienda siguen siendo del servidor, como en el cliente de TypeScript.
Esta primera versión cubre todo lo que necesita solo HTTP: emitir, preparar y firmar por separado, consultar, listar, descargar el Archivo DTE y el PDF, anular, eventos de retorno, entrega por correo y WhatsApp, el almacenamiento de Facta y la lectura y escritura del catálogo. Lo que necesita la llave de desbloqueo y almacenamiento local está en Lo que esta versión todavía no incluye.
Requisitos
- PHP 7.4 o superior, y sin avisos de obsolescencia hasta PHP 8.5. Sirve el hosting compartido de siempre.
- Ninguna extensión obligatoria más allá del núcleo de PHP (
jsonyhash), ni dependencias de Composer. - HTTP: usa
ext-curlcuando existe. Si su PHP no la trae, usa los flujos nativos de PHP (allow_url_fopen, yext-opensslpara HTTPS). Si ya tiene un cliente PSR-18, puede entregárselo con la opcióntransport. ext-openssltambién servirá para abrir el vault local en versiones futuras; hoy no hace falta para nada más que HTTPS sin cURL.- TLS nunca se desactiva. No hay una opción para saltarse la verificación. Si el servidor tiene un almacén de certificados viejo, la salida es
caBundle: la ruta de un paquete de certificados actual. - Una llave de API (
facta_test_…ofacta_live_…) y, para emitir y anular, la llave de firma (factask_…). Se acuñan en la app, en Cuenta → API.
Instalación
Hay dos caminos, y los dos dejan el mismo cliente.
Con Composer (Packagist)
Es el camino principal. El paquete facta-dte/api está en Packagist:
composer require facta-dte/apirequire __DIR__ . '/vendor/autoload.php';Sin Composer: el ZIP por FTP
Para hosting compartido, cPanel o cualquier servidor donde no tiene consola. Descargue facta-api-php-vX.Y.Z.zip de la página de versiones del repositorio, descomprímalo y suba por FTP la carpeta facta-api-php/ junto a su script (o a cualquier sitio que PHP pueda leer). Después:
require_once __DIR__ . '/facta-api-php/autoload.php';No hay nada más que instalar. Si solo tiene Composer en otra máquina, ejecute allí composer require facta-dte/api y suba la carpeta vendor/ que genera.
Credenciales y opciones
Cree el cliente una vez por petición con las llaves que le dio la app. Léalas del gestor de secretos o del entorno del servidor, no de un archivo dentro del repositorio.
use Facta\Api\Facta;
$facta = new Facta([
'apiKey' => getenv('FACTA_API_KEY'), // facta_test_… (ambiente 00) o facta_live_… (01)
'signKey' => getenv('FACTA_SIGN_KEY'), // factask_…, solo viaja a las rutas que firman
]);| Opción | Qué hace |
|---|---|
apiKey | Obligatoria. facta_test_… emite en pruebas contra el ambiente 00; facta_live_…, en producción. Con otro prefijo el constructor lanza invalid_api_key. |
signKey | factask_…. Se manda solo a las rutas que firman: emitir, firmar, anular y registrar un retorno. Si le pasa por error una factauk_, el constructor la rechaza antes de que viaje. |
unlockKey | factauk_…. Reservada para la siguiente etapa (catálogo local y destinos propios). Nunca se envía al servidor; hoy solo cambia el mensaje cuando un catálogo cifrado no se puede leer. |
baseUrl | La raíz de la API. Por defecto, la URL pública. Una llave facta_test_ sigue emitiendo en pruebas con esa misma URL. |
timeoutMs | Plazo de un intento, por defecto 60000. El Ministerio puede tardar unos cuarenta segundos en contestar. |
maxRetries | Intentos extra para los errores donde reintentar es seguro, por defecto 3. |
region | La región de las funciones, enviada como x-region; false no manda ninguna. Por defecto, la variable de entorno FACTA_API_REGION y, si no existe, la que trae el cliente. |
transport | Un objeto Facta\Api\Http\Transport, por ejemplo new Psr18Transport($cliente, $fabricaDePeticiones, $fabricaDeFlujos). |
caBundle | Ruta de un paquete de certificados, para servidores con el almacén desactualizado. La verificación TLS no se desactiva nunca. |
Todos los métodos aceptan además, como último argumento, opciones por llamada: timeoutMs y headers; los que escriben, también idempotencyKey. Una opción que el método no conoce lanza InvalidArgumentException: un error de escritura no se ignora en silencio. Lo mismo vale para una opción desconocida del constructor.
Las respuestas llegan como arreglos asociativos con los nombres de campo de la propia API (estado, codigoGeneracion, numeroControl…). Las peticiones también son arreglos: una lista se convierte en un arreglo JSON, un arreglo asociativo en un objeto, y new \stdClass() es el objeto vacío {}.
Su primera factura
Una factura (01) de pruebas. Cambiar de ambiente es cambiar de llave, no de código.
require_once __DIR__ . '/facta-api-php/autoload.php';
use Facta\Api\Facta;
use Facta\Api\FactaError;
$facta = new Facta([
'apiKey' => getenv('FACTA_API_KEY'),
'signKey' => getenv('FACTA_SIGN_KEY'),
]);
try {
$resultado = $facta->issue(
[
'tipoDte' => '01',
'items' => [['descripcion' => 'Servicio', 'cantidad' => 1, 'precioUni' => 11.30]],
],
['idempotencyKey' => 'pedido-00417'] // suya: la misma venta, la misma llave
);
if ($resultado['estado'] === 'sellado') {
echo $resultado['numeroControl'], ' ', $resultado['selloRecibido'], "\n";
} else {
echo "Contingencia: aceptado, falta el sello de Hacienda.\n";
}
} catch (FactaError $e) {
echo $e->getErrorCode(), ' (HTTP ', $e->getStatus(), '): ', $e->getMessage(), "\n";
if ($e->isRejection()) {
print_r($e->mhObservations());
}
}Tres cosas que ese ejemplo ya hace bien. La idempotencyKey es de su sistema (el número de pedido, el ticket de caja): si el proceso se cae a mitad de la llamada y usted la repite, el servidor reconoce la misma operación y no emite dos veces. El estado se mira antes de leer el sello, porque un documento en contingencia todavía no lo tiene. Y el fallo se trata por su código, no por su mensaje.
Métodos del cliente
Cada método dice qué alcance de la llave exige, qué devuelve y qué errores propios tiene. Los errores comunes a todos —llave, alcance, límites, red— están en Errores. Un método que escribe y gasta un correlativo lleva la Idempotency-Key del cliente; el apartado de idempotencia explica cómo funciona.
Configuración y estado
environment()
No hace ninguna petición. Devuelve "00" si la llave es facta_test_… y "01" si es facta_live_….
region()
No hace ninguna petición. Devuelve la región que se manda como x-region, o null si la desactivó con region => false. A diferencia del cliente de TypeScript, no consulta /v1/status para descubrirla: un proceso de PHP vive lo que dura una petición web, y una consulta por petición costaría más de lo que ahorra.
servedRegion()
Devuelve la región que sirvió la última respuesta (cabecera x-sb-edge-region), o null antes de la primera respuesta o cuando la cabecera no vino.
status($options = [])
GET /v1/status. No exige alcance ni manda la llave de firma. Confirma que la llave sirve y devuelve, como arreglo, ok, version, ambiente, emisor, llave (con alcances, tiposDte y catalogMode), firma, sincronizacion y limites. Es lo primero que conviene llamar; la ruta tiene su propia ventana de límite y no gasta el cupo que reporta.
$estado = $facta->status();
echo $estado['ambiente'], ' ', implode(',', $estado['llave']['alcances']), "\n";diagnose($options = [])
Lee /v1/status y /v1/storage/status y responde si esta llave está lista. No abre vaults, no reserva un correlativo y no emite. Opciones: expectedEnvironment ("00" o "01"), requiredScopes (lista) y dteType (por ejemplo "01"). Devuelve overall, canIssue, canQuery, canDownload, canIssueAndArchive, la lista checks (cada una con id, state y message), storageReady, catalogMode, region y servedRegion. Si el estado no se puede leer, no lanza: devuelve un informe bloqueado con el código seguro del error.
canIssueAndArchive es siempre false en esta versión, porque el archivo local no existe todavía; y las comprobaciones que dependerían de almacenamiento local salen como unknown con ese motivo.
$informe = $facta->diagnose(['dteType' => '03']);
if (!$informe['canIssue']) {
foreach ($informe['checks'] as $c) { echo $c['state'], ': ', $c['message'], "\n"; }
}Emitir
issue($request, $options = [])
POST /v1/dte. Exige el alcance issue y manda la llave de firma. Prepara, firma y transmite en una sola operación; es el camino normal. $request lleva tipoDte, el receptor y las items, con los nombres de campo de la API; lo calcula todo el servidor. Devuelve el documento sellado (estado "sellado", con selloRecibido, totales y archivoDte) o, si Hacienda no contestó, un documento en contingencia (estado "contingencia", sin sello todavía) —un 202 no es un error—. La respuesta trae además codigoGeneracion, numeroControl, representacionGrafica (el PDF en base64), entrega, storage y advertencias cuando existen.
Opciones por llamada:
idempotencyKey: la suya. Si no la pasa, el cliente acuña una por llamada y la reutiliza en todos los reintentos.deliver: marca canales de entrega; ver Entrega.timeoutMsyheaders.
Errores propios habituales: mh_rejected (Hacienda leyó y negó; gastó correlativo), validation_failed (no gastó), amount_limit, sign_key_required, sign_key_invalid, idempotency_key_reuse. Una emisión puede terminar con advertencias aunque haya sellado: por ejemplo, cuando ningún almacenamiento duradero guardó el documento.
prepare($request, $options = [])
POST /v1/dte/prepare. Exige issue; no abre el vault de firma, así que no manda la llave de firma. Reserva el correlativo y devuelve el documento canónico sin firmar, con su prepareToken. Úselo cuando necesita mostrar o revisar el documento antes de firmarlo. Devuelve un arreglo con estado "preparado", codigoGeneracion, numeroControl, tipoDte, ambiente, totales, documento y prepareToken.
documento queda como el árbol \stdClass que decodificó el cliente, a propósito: un objeto vacío {} dentro de él no debe volverse [] al regresar. Trátelo como opaco y páselo a sign() sin tocarlo. Si lo guarda entre las dos llamadas, use json_encode y json_decode($texto) (objetos), nunca json_decode($texto, true).
sign($prepared, $options = [])
POST /v1/dte/sign. Exige issue y manda la llave de firma. Firma y transmite lo que devolvió prepare(). Pase el resultado sin cambios: el servidor comprueba un MAC sobre el documento canónico, así que un centavo alterado se rechaza (prepare_token_invalid) en lugar de firmarse. Si le falta el prepareToken o el documento, lanza InvalidArgumentException antes de mandar nada. Devuelve lo mismo que issue().
Entrega por correo y WhatsApp
Emitir nunca espera a la entrega; son peticiones aparte. issue() solo marca canales con la opción deliver:
$resultado = $facta->issue($venta, [
'idempotencyKey' => 'pedido-00417',
'deliver' => [
'email' => true, // o una dirección; true usa receptor.correo
'whatsapp' => ['number' => '+50370001234', 'consent' => true],
],
]);
$token = $resultado['entrega']['token']; // vale cinco minutos: es un secreto, guárdelo en su servidorconsent => true es la constancia de que el receptor aceptó recibir el documento por WhatsApp; el cliente exige que usted lo escriba. Una respuesta sellada trae entrega con un token válido cinco minutos y el estado inicial de cada canal. Un documento en contingencia no trae token.
deliverEmail($generationCode, $token, $options = [])
POST /v1/dte/{codigoGeneracion}/entrega/correo. Exige issue (o entrega:correo) y el token que devolvió la emisión. Responde el estado final del canal o en_proceso; una segunda llamada devuelve el estado vigente sin mandar un segundo mensaje. Un canal que no se puede entregar es un estado (fallido, sin_consentimiento…), no una excepción. Un token vencido es entrega_vencida, y uno equivocado, entrega_token_invalido; el cliente lo quita del mensaje y de details si el servidor lo repitió.
deliverWhatsApp($generationCode, $token, $options = [])
POST /v1/dte/{codigoGeneracion}/entrega/whatsapp. Exige issue (o entrega:whatsapp). Igual que el correo; se cobra del monedero prepagado de la empresa (sin_credito si no hay saldo).
getDelivery($generationCode, $options = [])
GET /v1/dte/{codigoGeneracion}/entrega. Exige query (o cualquiera de los dos alcances de entrega). Lee el estado de cada canal en canales; sigue funcionando cuando el token ya venció.
waitForDelivery($generationCode, $options = [])
Sondea getDelivery() hasta que cada canal esperado esté en un estado final (cualquiera menos pendiente o en_proceso). Opciones: channels (por defecto, los que reporta el servidor), timeoutMs (60000) e intervalMs (2000). Si se agota el plazo no lanza: devuelve la última lectura con settled => false, porque la entrega nunca cambia el resultado fiscal.
Consultar y listar
getDocumentStatus($generationCode, $options = [])
GET /v1/dte/{codigoGeneracion}. Exige query. Contesta también para documentos rechazados. Devuelve el estado (sellado, firmado, rechazado, contingencia, invalidado, reservado, liberado, descartado), numeroControl, tipoDte, ambiente, fecEmi, selloRecibido, totales y, si los hay, retornos y disponible. Un documento de otra empresa contesta not_found, igual que uno que no existe.
listDocuments($filters = [], $options = [])
GET /v1/dte. Exige query (y download si pide include). Una página a la vez. Filtros: desde, hasta, estado, tipoDte, limit, cursor e include => ['dte'], que agrega el Archivo DTE a cada fila. Devuelve documentos y siguiente; para la página que sigue pase siguiente como cursor, hasta que sea null. Un filtro desconocido lanza InvalidArgumentException. Con include, la página es de 25 como máximo; si se pasa, el servidor contesta include_limit_exceeded.
$cursor = null;
do {
$pagina = $facta->listDocuments(['desde' => '2026-10-01', 'limit' => 100, 'cursor' => $cursor]);
foreach ($pagina['documentos'] as $fila) { echo $fila['numeroControl'], "\n"; }
$cursor = $pagina['siguiente'];
} while ($cursor !== null);downloadDocument($generationCode, $kind = 'json', $options = [])
GET /v1/dte/{codigoGeneracion}/file. Exige download. Devuelve los bytes exactos que mandó el servidor, sin volver a serializarlos. kind es "json" (el Archivo DTE que recibe el receptor, por defecto), "pdf" o "ticket" (un tiquete de rollo dibujado en el servidor). El resultado es un arreglo con codigoGeneracion, kind, bytes, contentType y filename, y, cuando el servidor lo dice, storageSource (managed, holding o archive), jsonFormat (archivo-dte o raw) y paperWidthMm.
Opciones: raw => true (solo json: lo guardado tal cual, necesario para un documento en contingencia, que todavía no tiene Archivo DTE y de otro modo contesta not_sealed); source => 'managed' (solo json y pdf: exige la copia de Almacenamiento Facta, sin caer a la retención temporal); paperWidthMm (solo ticket, de 40 a 120, por defecto 80). Una combinación inválida lanza InvalidArgumentException antes de mandar nada.
$pdf = $facta->downloadDocument($codigo, 'pdf');
file_put_contents($codigo . '.pdf', $pdf['bytes']); // escriba $pdf['bytes'] tal cualEl PDF lo dibuja el servidor con la plantilla de la empresa; este cliente no genera ninguno.
Anular y devolver
invalidate($generationCode, $request, $options = [])
POST /v1/dte/{codigoGeneracion}/invalidate. Exige issue y manda la llave de firma. Anula un documento sellado; es irreversible. $request lleva tipoAnulacion (1, 2 o 3), motivo, codigoGeneracionReemplazo cuando corresponde, y responsable y solicita, cada uno con nombre, tipoDocumento y numDocumento. Devuelve estado "invalidado" y el evento, o yaEstabaInvalidado => true. Si el documento tiene eventos de retorno, contesta has_return_events.
registerReturn($generationCode, $request, $options = [])
POST /v1/dte/{codigoGeneracion}/return. Exige issue y manda la llave de firma. Registra un evento de retorno sobre una factura, una exportación o un sujeto excluido que esta API emitió (guía). $request lleva items —cada una con su linea (contada desde 1) y exactamente una de cantidad o noGravado— y, opcional, fechaEvento (AAAA-MM-DD). El cliente comprueba esas reglas localmente y lanza InvalidArgumentException si el servidor las rechazaría de todas formas. Es irreversible una vez sellado.
Una respuesta 202 con estado "firmado" significa que Hacienda no contestó: repita con la misma idempotencyKey y el mismo cuerpo para reenviar el mismo evento firmado. Los rechazos son return_exceeds_available, return_window_closed y return_type_not_allowed.
Almacenamiento de Facta
Estos tres métodos hablan del almacenamiento administrado por Facta (JSON y PDF), no de sus destinos propios.
getStorageStatus($options = [])
GET /v1/storage/status. Exige download. Devuelve capabilityVersion, managed (cobertura, capacidad y si está listo), byos (si hay un destino propio verificado listo) y las listas supportedKinds y unsupportedKinds. No hace trabajo fiscal. Si el servidor no publica la ruta, lanza storage_unsupported; si la respuesta no cumple el contrato, storage_contract_invalid.
getDocumentCopies($options = [])
GET /v1/storage/copies. Exige download. Lista los recibos de las copias administradas del JSON y el PDF de esta empresa y este ambiente, incluidas las pendientes o fallidas, con state, bytes y sha256. Con la opción generationCode se limita a un documento. No devuelve el contenido del documento ni rutas de almacenamiento. Un recibo de otro ambiente o de otro documento se trata como storage_contract_invalid.
retryDocumentStorage($generationCode, $options = [])
POST /v1/storage/copies/{codigoGeneracion}/repair. Exige download e issue. Repara las copias de un documento que ya está sellado: no firma, no vuelve a emitir y no gasta un correlativo. Devuelve el recibo de almacenamiento (destination, environment, json, pdf, operationId). Si los bytes originales ya no existen, falla de forma explícita en lugar de inventarlos.
La emisión exitosa y la copia exitosa son cosas distintas. Si el recibo que viene dentro de una emisión no cumple el contrato, el cliente lo descarta y deja storageErrorCode: "storage_contract_invalid" en el resultado, conservando el documento sellado.
Retención y contrato
listHolding($limit = 50)
GET /v1/dte/holding. Exige download. Inspecciona el área de retención —los documentos que el servidor conserva un tiempo mientras no haya otra copia duradera—. No descarga bytes. Devuelve documentos.
getContract()
GET /v1/openapi.json. No exige alcance. El contrato OpenAPI que publica el servidor, como arreglo.
Catálogo de clientes y productos
El catálogo se lee y se escribe por la API, y depende de cómo lo guarda la empresa. Una llave con catalog:read lee un catálogo en claro o legible; escribir exige catalog:write y que el dueño haya permitido administrar el catálogo desde la API (lo que lo guarda sin cifrar en Facta). Los registros vuelven con los dos nombres de cada campo, el de la API y el almacenado (nombre y name, por ejemplo).
Con un catálogo cifrado, estas lecturas lanzan unauthorized (details.reason), porque leerlo necesitaría la instantánea local que esta versión no incluye.
| Método | Qué hace |
|---|---|
listCustomers($options) | Lista los clientes activos; con includeInactive => true, también los desactivados. catalog:read. |
getCustomer($customerId, $options) | Un cliente por su id estable, o null. Un cliente desactivado también es null salvo includeInactive. |
searchCustomers($query, $options) | Busca por nombre, número de documento, NRC y correo. Opciones: limit (1 a 500, por defecto 50) e includeInactive. |
listProducts($options) | Lista los productos activos. catalog:read. |
getProduct($productId, $options) | Un producto por su id estable, o null. |
searchProducts($query, $options) | Busca en descripciones, códigos y códigos de barras. |
createCustomer($input, $options) | Crea un cliente. nombre es obligatorio. catalog:write. Con idempotencyKey, una creación reintentada es segura. |
updateCustomer($customerId, $changes, $options) | Cambia solo los campos que usted pasa. Para reactivar, ['activo' => true]. |
deactivateCustomer($customerId, $options) | Desactiva (DELETE): deja de listarse y los documentos ya emitidos lo conservan. No existe el borrado definitivo. |
createProduct($input, $options) | Crea un producto. |
updateProduct($productId, $changes, $options) | Cambia solo los campos que pasa. |
deactivateProduct($productId, $options) | Desactiva un producto. |
Un documento duplicado al crear un cliente, o un código duplicado en un producto, contesta catalog_duplicate; details.id dice cuál es y details.activo si está desactivado.
Idempotencia y reintentos
Todo método que puede gastar un correlativo necesita una Idempotency-Key estable. Pase la suya (idempotencyKey) y guárdela con el pedido: así la operación es idempotente aunque el proceso se reinicie, no solo entre los reintentos de una misma llamada. Si no la pasa, el cliente acuña una por llamada, una sola vez, fuera del bucle de reintentos, y la reenvía con cada intento. Generar una nueva por intento sería exactamente el error que la cabecera existe para evitar.
Lo mismo con el cuerpo: se codifica una vez por llamada y cada reintento reenvía esos mismos bytes.
La huella de la petición
issue() y prepare() mandan además X-Facta-Request-Fingerprint: el SHA-256 de la forma canónica (RFC 8785) de su petición tal como la escribió, antes de que el servidor resuelva ningún id del catálogo. Lo usa el servidor para reconocer un reintento aunque el catálogo haya cambiado entre el intento y su repetición; sin ella, esa repetición contestaría idempotency_key_reuse. Solo viaja en POST /v1/dte y POST /v1/dte/prepare; usted no tiene que calcularla.
Qué se reintenta
El cliente reintenta solo donde reintentar es seguro, hasta maxRetries veces más, con espera creciente (250 ms, 500 ms… hasta 4 s) o con el Retry-After del servidor cuando lo manda, y estos seis códigos son los únicos:
idempotency_in_flight: la primera petición con esa llave sigue trabajando.mh_unreachableservice_unavailablecorrelative_unavailablestorage_unavailablenetwork_error: la petición no obtuvo respuesta (fallo de conexión o plazo agotado).
Es seguro porque cada intento reenvía los mismos bytes con la misma Idempotency-Key. Nunca se reintenta un 4xx ni un rechazo de Hacienda. Cuando se agotan los intentos se lanza el último error. Los códigos que no están en esa lista son finales. Un 202 de contingencia no es un error: llega como resultado con estado "contingencia", y lo cuenta la guía Contingencia y resultados inciertos.
FactaError
Todo fallo es una Facta\Api\FactaError. Decida por el código, nunca por el mensaje: el texto es para quien lee el registro y puede reescribirse. Los códigos son los mismos que los del cliente de TypeScript.
| Método | Qué devuelve |
|---|---|
getErrorCode() | Una de las constantes de FactaError (o un código que un servidor más nuevo añadió). |
getStatus() | El HTTP; 0 cuando la petición no obtuvo respuesta. |
getDetails() | Lo que el servidor mandó en details, ya sin secretos. validation_failed trae issues; rate_limited, la ventana y la espera. |
getRetryAfterMs() | Lo que pidió la cabecera Retry-After, en milisegundos, o null. |
isRejection() | true si Hacienda leyó el documento y lo negó (mh_rejected), frente a «no llegamos a Hacienda». Se arregla cambiando los datos, no esperando. |
spent() | ['codigoGeneracion' => …, 'numeroControl' => …] si el fallo ya gastó un correlativo, o null. La corrección puede reusar ese número. |
mhObservations() | Lo que dijo Hacienda, palabra por palabra, como lista de textos. |
El mensaje de un error ya no contiene ninguna de las llaves configuradas, y details omite los campos con aspecto de credencial.
try {
$facta->issue($venta, ['idempotencyKey' => $pedido]);
} catch (FactaError $e) {
switch ($e->getErrorCode()) {
case FactaError::MH_REJECTED:
error_log(json_encode(['gastado' => $e->spent(), 'observaciones' => $e->mhObservations()]));
break;
case FactaError::VALIDATION_FAILED:
error_log(json_encode($e->getDetails()));
break;
default:
throw $e;
}
}Además de FactaError, el cliente lanza InvalidArgumentException para un uso incorrecto que se detecta antes de mandar nada (una opción desconocida, un kind inválido, un retorno sin líneas) y Facta\Api\UnsupportedEnvironmentException si ese PHP no tiene ninguna forma de hacer peticiones HTTP.
Códigos de error
Los de abajo son los códigos que define el cliente; los del servidor tienen su explicación completa en la referencia HTTP. network_error, operation_outcome_unknown y archive_integrity_error son del propio cliente y nunca los manda el servidor; los dos últimos existen por compatibilidad con TypeScript y esta versión no los lanza.
| Código | Qué significa y qué hacer |
|---|---|
unauthorized | Falta la llave, o un catálogo cifrado no se puede leer en esta versión (mire details.reason). |
invalid_api_key | La llave no existe, está mal escrita o no empieza con facta_test_ o facta_live_. |
key_revoked | La revocaron desde la app; no vuelve. Acuñe otra. |
key_expired | Pasó su fecha de vencimiento. Acuñe otra. |
key_inactive | Está desactivada. Reactívela desde la app. |
forbidden_scope | Le falta el alcance que el método exige. details.required dice cuál. |
dte_type_not_allowed | La llave no permite ese tipoDte. details.allowed trae la lista. |
ip_not_allowed | La llave tiene lista de direcciones y la suya no está. |
environment_not_allowed | El ambiente de la llave no es el de la empresa. |
sign_key_required | Falta la llave de firma factask_… en una operación que firma. |
sign_key_invalid | Esa contraseña no abre el vault de firma. details dice cuántos intentos quedan. |
sign_vault_locked | Demasiados intentos fallidos: la llave se suspendió. Reactívela desde la app. |
sign_vault_missing | La llave no está provisionada para firmar. Acuñe la llave de nuevo. |
invalid_request | El cuerpo o un campo tiene la forma equivocada. details.field señala el campo. No gasta correlativo. |
include_limit_exceeded | include con más de 25 filas por página. Baje limit. |
validation_failed | El documento no cumple el esquema oficial. details.issues lista cada campo. No gasta correlativo. |
retention_mixed_class_unsupported | Retención de renta pedida sobre un documento con líneas exentas o no sujetas. Emítalas en otro documento. |
not_found | No existe el documento, el id o la ruta. |
method_not_allowed | La ruta existe, pero no con ese método. |
not_sealed | Se pidió el Archivo DTE de un documento que todavía no tiene sello. Espere, o descargue con raw => true. |
catalog_write_disabled | El dueño no ha permitido administrar el catálogo desde la API. |
catalog_encrypted | El servidor no puede leer un catálogo cifrado. |
catalog_duplicate | Ya existe ese cliente o producto. details.id dice cuál. |
idempotency_key_required | Falta la Idempotency-Key en una operación que no se puede deshacer. |
idempotency_key_reuse | Esa llave ya se usó con otra petición. Use una nueva, o repita exactamente la misma. |
idempotency_in_flight | La primera petición con esa llave sigue en proceso. Se reintenta sola. |
prepare_token_invalid | El prepareToken venció o el documento cambió. Vuelva a llamar a prepare(). |
return_exceeds_available | Se pidió devolver más de lo que queda. details.lineas dice cuánto. |
return_window_closed | El evento está fuera del plazo de retorno. |
return_type_not_allowed | Ese tipo de documento no admite un evento de retorno; se corrige con una nota de crédito. |
return_pdf_unavailable | Ya no hay JSON firmado para dibujar la hoja del evento de retorno. |
has_return_events | No se puede anular un documento que ya tiene eventos de retorno. |
rate_limited | La llave llegó a su techo por hora o por día. details dice cuánto esperar. |
amount_limit | El documento pasa del monto máximo por documento de la llave. No gasta correlativo. |
mh_rejected | Hacienda leyó el documento y lo negó. Gastó correlativo; mire spent(). |
mh_unreachable | Reservado; se reintenta. Un fallo de transporte con Hacienda sale como contingencia. |
correlative_unavailable | No se pudo reservar el correlativo. Se reintenta con la misma llave de idempotencia. |
service_unavailable | Algo de Facta no contestó. Se reintenta con la misma llave. |
no_storage_destination | Un destino propio reportado no está entre los publicados de la llave. |
storage_unsupported | El servidor todavía no publica esa ruta de almacenamiento administrado. |
storage_unavailable | El almacenamiento administrado no contestó. Se reintenta; no vuelva a emitir. |
storage_contract_invalid | La capacidad o el recibo contradice el contrato. Conserve el éxito fiscal y concilie el almacenamiento. |
entrega_vencida | Pasaron más de cinco minutos: el token ya no sirve. Lea el estado con getDelivery(). |
entrega_token_invalido | El token no corresponde a este documento y canal. |
canal_no_marcado | El documento no marcó ese canal al emitirse. |
internal_error | Un fallo imprevisto. Si se repite, escriba a soporte. |
operation_outcome_unknown | Código del cliente, definido para coincidir con el de TypeScript; esta versión no lo lanza todavía (llegará con la recuperación de operaciones). |
archive_integrity_error | Código del cliente, definido para coincidir con el de TypeScript; esta versión no lo lanza, porque no incluye el archivo local cifrado. |
network_error | La petición no obtuvo respuesta. Se reintenta sola con la misma llave. |
Lo que esta versión todavía no incluye
Esta es la primera etapa. Faltan las funciones que necesitan la llave de desbloqueo (factauk_…) y almacenamiento local, y no se han dejado a medias: simplemente no están.
- La instantánea local del catálogo cifrado. Con un catálogo cifrado, el cliente de TypeScript descifra clientes y productos en su equipo; este no lo hace todavía. Mientras tanto sirven los catálogos legibles por la API o en claro, que el servidor lee por usted (
customerIdyproductIdse resuelven en el servidor), y siempre se puede mandar el receptor y las líneas completos. - Los destinos propios (BYOS) con la llave de desbloqueo. Replicar el JSON y el PDF a sus buckets, y avisar a Facta de esa copia, no está en esta versión.
- El archivo local cifrado y la recuperación tras un reinicio. No hay equivalente a
issueAndArchive()ni arecoverOperation(). Para sobrevivir a un reinicio use unaidempotencyKeypropia y guárdela con el pedido: repetir la llamada devuelve el mismo documento. - La impresión.
El camino normal no los necesita. Con Almacenamiento Facta conectado, el servidor guarda el JSON y el PDF de cada documento y getStorageStatus(), getDocumentCopies() y retryDocumentStorage() le dejan comprobarlo y repararlo. Ese es el camino que cubre esta versión.
Lo que este cliente no hace
- No calcula dinero. Ni IVA, ni retenciones, ni totales, ni el número de control, ni las fechas fiscales. Un cliente que calcule dinero sería un segundo motor fiscal, y dos motores se desincronizan el primer martes. Una prueba de arquitectura del paquete falla si aparecen en el código primitivas de firma o la construcción de campos fiscales.
- No firma. El certificado nunca pasa por aquí. Lo que sí viaja, y solo en las rutas que firman, es
X-Facta-Sign-Key: la contraseña que abre el vault de firma en el servidor. - No dibuja el PDF. El PDF y el ticket los dibuja el servidor con la plantilla de la empresa;
downloadDocument()le entrega sus bytes exactos. - No desactiva la verificación TLS. Ni siquiera con un servidor viejo: para eso existe
caBundle.
Para el HTTP directo, sin esta librería, siga los ejemplos en PHP.