Referencia HTTP
Lo que vale para todas las rutas: cómo autentica, cómo se hace idempotente una operación que gasta un correlativo, qué límites tiene y qué significa cada uno de los 49 códigos de error.
URL base y ambiente
https://hcnvknpsbadplnfcflxx.supabase.co/functions/v1/api-v1
Es la única URL pública de Facta. La llave selecciona el ambiente del Ministerio de Hacienda: facta_test_ transmite a apitest.dtes.mh.gob.sv y facta_live_ al servicio de producción. Las pruebas se hacen en esta misma URL con el certificado y la llave de pruebas; no se publica una segunda URL.
Todo cuerpo es application/json; charset=utf-8, y toda respuesta también, incluidos los errores. El cuerpo no puede pasar de 1 MB.
Autenticación
X-Facta-Key — la llave
facta_test_<id>.<secreto> en pruebas, facta_live_<id>.<secreto> en producción. La parte antes del punto es pública e identifica la fila; la de después se muestra UNA vez al acuñarla y no se guarda (solo su SHA-256 con pimienta).
El prefijo y el ambiente de la fila tienen que coincidir: una llave facta_test_ que apuntara a una fila de producción es 403, jamás una emisión.
Nunca en Authorization. El gateway lee esa cabecera por su cuenta.
Esta llave sola no firma. Abre nada: identifica y autentica. Para firmar hace falta además X-Facta-Sign-Key (ver los dos vaults del plan, §6).
Lista de IP autorizadas (opcional, se define al acuñar la llave): cada entrada es una dirección —190.53.1.2, 2001:db8::1— o un rango en notación CIDR —190.53.1.0/24, 2001:db8::/32—, IPv4 o IPv6. Un integrador detrás de un NAT con varias salidas pone su prefijo, no cada dirección. La lista vacía significa «desde cualquier parte». Si la llave tiene lista y tu IP no está en ninguna de sus entradas, la respuesta es ip_not_allowed (403) antes de cualquier trabajo. La IP que se compara es la del extremo que nos conecta, y una cabecera X-Forwarded-For puesta por el cliente no la cambia.
X-Facta-Sign-Key — el segundo factor
La contraseña que abre el vault de firma, donde vive el certificado del emisor. 256 bits generados por el navegador en la ceremonia de acuñado y mostrados UNA vez.
Por qué existe: en la web hay dos secretos independientes (la sesión y la llave del vault, que nunca viaja) y robar uno no basta. En una API hay un solo token, y si ese token bastara para firmar, un .env filtrado sería poder de emitir DTE reales con el certificado del cliente. Con esta separación, el token robado no firma.
Qué hacemos con ella: se convierte a bytes al entrar, se deriva la KEK por HKDF-SHA-256, se abre la envoltura y el arreglo se pone a cero. No se guarda, no se registra en ninguna bitácora y no hay hash de ella en nuestra base — el verificador es la propia etiqueta GCM de la envoltura. Lo que sí guardamos es el CONTEO de intentos fallidos: cinco seguidos en una hora suspenden la llave.
No es FACTA_UNLOCK_KEY. Esa otra (prefijo factauk_) abre sus credenciales de almacenamiento en SU servidor y no viaja nunca a Facta. Si la manda aquí, la respuesta se lo dice en vez de intentarlo.
Alcances
Cada ruta exige un alcance, y la llave los lleva escritos desde que se acuñó. Un alcance que falta es 403 forbidden_scope, y details dice cuál hacía falta y cuáles tiene.
| Alcance | Para qué | Rutas |
|---|---|---|
issue | Emitir, anular y registrar eventos de retorno | /v1/dte, /v1/dte/prepare, /v1/dte/sign, …/invalidate, …/return |
query | Leer lo emitido | GET /v1/dte, GET /v1/dte/{codigoGeneracion} |
download | Área de retención | …/file, /v1/dte/holding |
| ninguno | Salud y contrato | /v1/status, /v1/openapi.json |
Idempotencia
Identificador que el CLIENTE elige para esta operación. Obligatorio en toda ruta que no se pueda deshacer: las tres que gastan correlativo y la anulación. Sin él, un reintento por timeout quemaría un segundo número — o mandaría un segundo evento contra un documento que el primero ya anuló.
Reglas, todas verificables:
- Alcance
(llave, Idempotency-Key). Dos integradores pueden usar la misma cadena sin chocar, y ninguno puede leer la respuesta del otro. - Repetir la misma llave con el MISMO cuerpo devuelve la respuesta guardada, con
Idempotency-Replayed: true. Eso incluye un rechazo del MH: el reintento devuelve el mismo 422, sin quemar otro número. - Repetir con OTRO cuerpo es 422
idempotency_key_reuse. Devolver la factura de ayer para la venta de hoy es peor que fallar. Si la primera petición ya terminó,detailsnombra sucodigoGeneracion. Excepción: las altas de cliente y producto (POST /v1/customers,POST /v1/products). Ahí el otro cuerpo se aplica al registro que la llave creó (semántica dePATCH) y la respuesta es200conactualizado: true; solo si esa llave no guarda un registro vuelve el 422. - Mientras la primera está en vuelo, la segunda es 409
idempotency_in_flight— no se encola. El cliente reintenta. - TTL 24 h. Pasado eso la cadena queda libre: un reintento un día después es una venta nueva, no un reintento.
Límites de uso
Toda respuesta dice cuánto cupo queda. Los números describen la ventana que está más cerca de morder: con 5 llamadas libres en la hora y 1 en el día, lo que se anuncia es 1.
RateLimit-Limit: 57
RateLimit-Remaining: 56
RateLimit-Reset: 3600
RateLimit-Policy: hour;q=60;w=3600, day;q=300;w=86400
| Cabecera | Qué dice |
|---|---|
RateLimit-Limit | El techo de la ventana más cercana a morder. |
RateLimit-Remaining | Lo que queda de esa ventana, con esta petición ya descontada. |
RateLimit-Reset | Segundos hasta que esa ventana se libera del todo. |
RateLimit-Policy | Las dos ventanas, hour;q=60;w=3600, day;q=300;w=86400. |
Retry-After | Segundos que conviene esperar. Solo en los errores donde esperar arregla algo. |
Consultar /v1/status no gasta el cupo que reporta: se cuenta en una ventana propia y generosa, porque un monitor que pregunta cada minuto se comía el cupo de emitir.
Errores
Un error es siempre la misma forma: { "error": { "code", "message", "details" } }. El code es el contrato y no cambia; el message está escrito en español para la persona que lee un registro a las dos de la mañana, y puede reescribirse.
{
"error": {
"code": "mh_rejected",
"message": "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE",
"details": {
"estado": "RECHAZADO",
"descripcionMsg": "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE",
"observaciones": [],
"codigoGeneracion": "E4311553-DADF-4168-829E-B6B7E50F1D41",
"numeroControl": "DTE-03-M001P001-000000000000177"
}
}
}
| code | HTTP | Cuándo pasa | Qué hacer |
|---|---|---|---|
unauthorized |
401 | No llegó la cabecera |
Mande la llave. Y nunca en |
invalid_api_key |
401 | La llave no existe, está mal escrita o el secreto no coincide. |
Los tres casos tardan lo mismo y dicen lo mismo, a propósito. Revise que copió la llave entera, con el punto. |
key_revoked |
403 | Alguien la revocó desde la app. |
Una llave revocada no vuelve. Acuñe otra. |
key_expired |
403 | Pasó la fecha de vencimiento que se le puso al acuñarla. |
Acuñe otra desde Configuración → Llaves de la API. |
key_inactive |
403 | La llave está desactivada. |
Vuelva a activarla desde la app. |
forbidden_scope |
403 | A la llave le falta el alcance que la ruta exige. |
|
dte_type_not_allowed |
403 | La llave no tiene permitido ese |
|
ip_not_allowed |
403 | La llave tiene lista de direcciones y la suya no está en ninguna entrada. |
Añada su dirección o su rango CIDR al acuñar. La dirección que se compara es la del extremo que conecta; una cabecera puesta por el cliente no la cambia. |
environment_not_allowed |
403 | El prefijo de la llave y su fila no coinciden, o el ambiente de la llave no es el de su empresa. |
Si su empresa ya pasó a producción, acuñe una llave de producción; una de pruebas no puede emitir documentos reales. |
sign_key_required |
401 | Falta |
Mande la contraseña del vault de firma, la que empieza con |
sign_key_invalid |
401 | Esa contraseña no abre el vault de firma. |
|
sign_vault_locked |
403 | Cinco intentos fallidos en una hora. La llave queda suspendida. |
Esperar no lo arregla. Hay que reactivarla desde la app. |
sign_vault_missing |
409 | La llave no está provisionada para firmar, o el certificado que guarda es de otro NIT que el de la empresa. |
Reintentar no la arregla. Acuñe la llave de nuevo, que es lo que provisiona su vault de firma. |
invalid_request |
400 | Cuerpo ausente, que no es JSON, de más de 1 MB, un campo con la forma equivocada, o un código de generación que no tiene forma de UUID. |
|
validation_failed |
422 | El documento no cumple el esquema oficial del Ministerio de Hacienda. |
|
retention_mixed_class_unsupported |
422 | Se pide |
No se firmó nada ni se gastó correlativo. Emita las líneas exentas o no sujetas en otro documento, o no aplique la retención: la base de la retención de renta para esa mezcla todavía no está confirmada. |
not_found |
404 | No existe el documento, o el |
Un documento de otra empresa contesta exactamente lo mismo que uno que no existe. Es a propósito. |
drive_not_configured |
503 | Este servicio no tiene configurado el cliente OAuth de Google Drive. |
No lo arregla el cliente. Escriba a soporte; mientras tanto, las copias a Drive esperan a que alguien abra Facta. |
drive_reauthorization_required |
409 | Google retiró el permiso de la llave larga de ese Drive (revocado, vencido o cambio de contraseña). |
Una persona debe abrir Facta y pulsar «Confirmar en Google» en ese destino de Google Drive. Reintentar no lo arregla. |
drive_token_failed |
502 | Google no contestó o rechazó la renovación por una razón distinta a un permiso retirado. |
Reintente en unos segundos. No se reenvía el texto de Google. |
drive_not_available_for_api |
403 | Se llamó a la ruta de renovación de Google Drive. Drive está desactivado para la API (decisión de seguridad del 17-oct-2026) y no cuenta como almacenamiento de la API. |
No reintente. Conecte Almacenamiento Facta u otro destino compatible (S3 y compatibles, Supabase Storage, FTP/SFTP). Una empresa cuyo único destino es Drive se trata como una empresa sin destino. |
method_not_allowed |
405 | La ruta existe, pero no con ese método. |
El mensaje dice cuál es el bueno. |
idempotency_key_required |
400 | Falta |
Elija una cadena por operación. Sin ella, un reintento por timeout quemaría un segundo número. |
idempotency_key_reuse |
422 | Ya usó esa |
Use una nueva. Para cambiar un registro de catálogo, use |
idempotency_in_flight |
409 | La primera petición con esa llave todavía se está procesando. |
Reintente en unos segundos. No se encola a propósito: la primera puede estar gastando un correlativo ahora mismo. |
prepare_token_invalid |
422 | El |
Vuelva a llamar a |
return_exceeds_available |
422 | Se pide devolver más de lo que queda de una línea del documento, contando los eventos de retorno anteriores. |
|
return_window_closed |
422 |
|
Registre el evento de retorno dentro del plazo. Pasado, el documento ya no admite el evento y la salida es otra operación fiscal. |
return_type_not_allowed |
422 | El documento no es una factura (01), una factura de exportación (11) ni una de sujeto excluido (14). |
Los demás tipos se corrigen con una nota de crédito, no con un evento de retorno. |
return_pdf_unavailable |
404 | Se pidió el PDF de un evento de retorno y ya no tenemos su JSON firmado para dibujarlo, o se pidió su ticket (no existe). |
Use el PDF de la respuesta original ( |
has_return_events |
409 | Se intenta anular un documento que ya tiene eventos de retorno registrados. |
Hacienda no permite anularlo (Anexo V 44.4). No se mandó nada; corrija con los eventos de retorno y, si hace falta, una nota de crédito. |
include_limit_exceeded |
400 | Se pidió |
Pida |
not_sealed |
409 | Se descarga el JSON ( |
Espere a que la contingencia se retransmita y el documento se selle, o descargue lo guardado con |
rate_limited |
429 | La llave alcanzó su techo por hora o por día. |
|
amount_limit |
429 | El documento pasa del monto máximo por documento de la llave. |
Se comprueba antes de reservar, así que no gasta correlativo. Suba el techo de la llave o divida la operación. |
mh_rejected |
422 | Hacienda leyó el documento y lo negó. |
Sí gastó correlativo, y |
mh_unreachable |
502 | Reservado: la versión actual nunca lo devuelve. |
Todo fallo de transporte sale como 202 contingencia, porque para entonces el documento ya está firmado y se le debe a Hacienda. |
correlative_unavailable |
503 | No se pudo reservar el correlativo. |
Reintente con la MISMA |
no_storage_destination |
422 | Un destino propio (BYOS) reportado no está entre los destinos publicados de la llave. La emisión ya NO devuelve este error por falta de almacenamiento (desde el 6-oct-2026). |
Sincronice los destinos desde la app. Para emitir sin almacenamiento disponible no hace falta nada: el documento se emite y la respuesta trae |
storage_unsupported |
501 | El servidor todavía no publica la capacidad o ruta de almacenamiento administrado. |
El SDK conserva compatibilidad y marca la capacidad como desconocida; actualice el servidor antes de depender de ella. |
storage_unavailable |
503 | La copia administrada no se pudo consultar o reparar temporalmente. También lo devuelve la emisión, con |
Reintente solo la operación de almacenamiento. No vuelva a emitir el DTE. Si fue una emisión rechazada por el interruptor, espere |
entrega_token_invalido |
401 | El |
Use el token exacto que devolvió la emisión ( |
entrega_vencida |
410 | Pasaron más de 5 minutos desde la emisión y el canal todavía no se había entregado. |
No se reintenta con ese token. El documento ya está emitido; entréguelo por sus propios medios o desde la app. |
canal_no_marcado |
409 | El documento no marcó ese canal en |
Los canales se marcan al emitir. Revise con |
catalog_write_disabled |
403 | Una ruta de escritura del catálogo ( |
El dueño lo activa en Cuenta → API → «Permitir administrar clientes y productos desde el API» (eso guarda el catálogo sin cifrar en Facta DTE) y concede |
catalog_encrypted |
409 | Una ruta del catálogo y los clientes y productos de la empresa están cifrados (o una ficha quedó cifrada a medias de una conversión). |
El servidor no puede leer un catálogo cifrado. El dueño lo guarda sin cifrar en Cuenta → API; hasta entonces, |
catalog_duplicate |
409 | Ya existe un cliente con ese documento, o un producto con ese código. |
|
service_unavailable |
503 | Algo nuestro no contestó — la base, el registro de peticiones, el de idempotencia. |
Reintente con la misma |
internal_error |
500 | Un fallo que no estaba previsto. |
El mensaje nunca describe nuestras entrañas. Si se repite, escriba a soporte con la hora y el |
El SDK de TypeScript añade un código más que el servidor nunca manda, network_error: la petición no llegó a recibir respuesta.
Las diez rutas
| Ruta | Qué hace | Alcance | Firma |
|---|---|---|---|
GET /v1/status |
Salud, ambiente y techos restantes | ninguno | no |
POST /v1/dte |
Emitir un DTE en una sola llamada | issue |
sí |
GET /v1/dte |
Listar lo emitido, por rango de fechas | query |
no |
POST /v1/dte/prepare |
Reservar el correlativo y obtener el documento canónico | issue |
no |
POST /v1/dte/sign |
Firmar y transmitir un documento preparado | issue |
sí |
GET /v1/dte/{codigoGeneracion} |
Consultar un documento por su código de generación | query |
no |
POST /v1/dte/{codigoGeneracion}/invalidate |
Anular un documento que Hacienda ya selló | issue |
sí |
POST /v1/dte/{codigoGeneracion}/return |
Registrar el evento de retorno de una factura que Hacienda ya selló | issue |
sí |
GET /v1/dte/{codigoGeneracion}/file |
Descargar JSON/PDF o regenerar un ticket de un DTE sellado | download |
no |
GET /v1/dte/holding |
Qué hay en tu área de retención ahora mismo | download |
no |
GET /v1/storage/status |
Consultar capacidad administrada y destino BYOS verificado | download |
no |
GET /v1/storage/copies |
Consultar recibos de copias JSON/PDF/evento de invalidación | download |
no |
POST /v1/storage/copies/{codigoGeneracion}/repair |
Reintentar JSON/PDF de un DTE ya sellado | download |
no |
GET /v1/openapi.json |
El contrato de esta API, servido por ella misma | ninguno | no |
La ruta decide, y el método se comprueba después. Una ruta que existe con el método equivocado responde 405 method_not_allowed; una que no existe, 404 not_found.
Esquemas
Los 52 objetos del contrato. Cada campo, con su tipo y si es obligatorio, está en la página de la operación que lo usa y en el contrato descargable.
| Esquema | Qué describe | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Error | |||||||||||||||||||||||||
Entrega | Qué canales entregan el documento una vez emitido. Solo marca: la entrega se pide después, con el token de la respuesta, y emitir nunca espera a ella. Sin este objeto no se entrega nada (las preferencias de correo de la empresa en la app no aplican a la API). Los dos canales vienen incluidos en toda llave que emite ( | ||||||||||||||||||||||||
EstadoDeCanal |
| ||||||||||||||||||||||||
CanalDeEntrega | |||||||||||||||||||||||||
EntregaMarcada | Lo que la emisión devuelve cuando marcó canales. Sin | ||||||||||||||||||||||||
SolicitudEntrega | |||||||||||||||||||||||||
EntregaDeCanal | |||||||||||||||||||||||||
EstadoDeEntrega | |||||||||||||||||||||||||
Direccion | Códigos del catálogo oficial del MH (CAT-012/013), no nombres. | ||||||||||||||||||||||||
Receptor | Un cliente guardado ( Un CCF (03) exige | ||||||||||||||||||||||||
DocumentoRelacionado | El documento sellado que una nota ajusta. Dos formas:
Un código que no está en su índice es | ||||||||||||||||||||||||
ReceptorExportacion | Quien compra desde fuera del país. No tiene NRC ni dirección con códigos del catálogo: tiene país, una dirección en texto y si es persona o empresa. Su | ||||||||||||||||||||||||
Exportacion | Lo que hace de una venta una exportación. La tasa es cero, y tanto la línea como el resumen llevan el tributo C3 con valor 0 — un | ||||||||||||||||||||||||
ReceptorSujetoExcluido | El VENDEDOR, no el comprador — el 14 es el único documento del catálogo que corre al revés: lo emite quien compra, para documentar una compra a alguien que no está en el registro de IVA. Por eso no tiene NRC (si lo manda, se ignora: el esquema no tiene dónde ponerlo) y por eso no hay IVA en ninguna parte del documento. | ||||||||||||||||||||||||
Item | Una línea se describe entera (
| ||||||||||||||||||||||||
SolicitudDte | Un solo cuerpo para los seis tipos, y cada tipo exige lo suyo. La tabla de quién pide qué:
Mandar el campo propio de un tipo en otro es | ||||||||||||||||||||||||
FilaApendice | |||||||||||||||||||||||||
Representacion | Datos solo de la hoja (la representación gráfica). Nunca entran al JSON firmado ni viajan a Hacienda: existen únicamente para que una plantilla los dibuje (mesa y mesero de un restaurante, la placa de un taller, los puntos de un programa de lealtad…). Un documento emitido con y sin Se guardan con el índice del documento para que la hoja que se vuelve a descargar o a dibujar salga igual. Una plantilla imprime solo los bloques cuyos datos llegaron; las plantillas que no declaran una clave la ignoran, así que mandarla es seguro y no cambia la hoja. Solo la API los acepta: desde la aplicación esos bloques no se imprimen. Todo texto va en una sola línea, sin caracteres de control. Hasta 20 valores en total entre las claves conocidas y las | ||||||||||||||||||||||||
SolicitudFirma | |||||||||||||||||||||||||
PersonaDeLaAnulacion | |||||||||||||||||||||||||
SolicitudAnulacion | |||||||||||||||||||||||||
DteAnulado | |||||||||||||||||||||||||
SolicitudRetorno | |||||||||||||||||||||||||
ItemRetorno | Lleva | ||||||||||||||||||||||||
Disponible | Lo que queda por devolver de una línea del documento. | ||||||||||||||||||||||||
RetornoSellado | |||||||||||||||||||||||||
RetornoPendiente | El evento está firmado y registrado, pero Hacienda no contestó. Es la misma estructura que | ||||||||||||||||||||||||
RetornoResumen | Un evento de retorno registrado sobre un documento, en | ||||||||||||||||||||||||
Totales | Los calcula
| ||||||||||||||||||||||||
DteSellado | |||||||||||||||||||||||||
DteEnContingencia | Firmado y sin respuesta del MH. Lleva los mismos campos que el sellado salvo los que solo existen con sello: no hay | ||||||||||||||||||||||||
DtePreparado | |||||||||||||||||||||||||
DteConsultado | |||||||||||||||||||||||||
PaginaDeDocumentos | |||||||||||||||||||||||||
DteEnLista | Lo que hace falta para reconciliar: qué número, qué estado, cuánto y a quién. El documento entero se pide por su código con | ||||||||||||||||||||||||
DocumentoRetenido | Una fila del área de retención — evidencia de estado, nunca una ruta de bucket ni contenido. | ||||||||||||||||||||||||
ManagedStorageArtifactReceipt | |||||||||||||||||||||||||
ManagedInvalidationReceipt | El recibo de un archivo único: el evento de invalidación sellado, guardado bajo el código del documento ORIGINAL y junto a su | ||||||||||||||||||||||||
ManagedStorageReceipt | |||||||||||||||||||||||||
PlantillasDisponibles | |||||||||||||||||||||||||
ManagedStorageStatus | |||||||||||||||||||||||||
ManagedDocumentCopy | |||||||||||||||||||||||||
ManagedDocumentCopies | |||||||||||||||||||||||||
Status | |||||||||||||||||||||||||
Ventana | |||||||||||||||||||||||||
SyncRevision | |||||||||||||||||||||||||
Cliente | |||||||||||||||||||||||||
ClienteEntrada | |||||||||||||||||||||||||
ClienteCambio | Los mismos campos que | ||||||||||||||||||||||||
Producto | |||||||||||||||||||||||||
ProductoEntrada | |||||||||||||||||||||||||
ProductoCambio | Los mismos campos que |