MuninCloud← Desarrolladores

API de auditoría de facturas de MuninCloud

Sube una factura, recibe su lectura auditada en JSON (v1).

Cómo funciona

Cómo se usa, en cuatro pasos

  1. Tras la compra recibes un enlace de recogida de un solo uso. Al abrirlo generas tu llave de administrar (mc_live_…), que se ve una vez. Nunca te la mandaremos por correo.
  2. Con la llave de administrar creas una llave de procesar por cada empresa cuyas facturas vayas a enviar (POST /v1/llaves), indicando su NIF y su nombre o razón social (los dos obligatorios: con ellos el motor reconoce qué parte de la factura es tu empresa). Puedes limitar cada llave a unas IP.
  3. Con una llave de procesar subes facturas (POST /v1/trabajos), diciendo en cada una si es una compra o una venta de esa empresa, y recibes un número de trabajo. El proceso es asíncrono: tarda de segundos a unos minutos.
  4. Consultas el trabajo (GET /v1/trabajos/{trabajo}) hasta que esté terminado (con su documento v1) o rechazado (con su error). Respeta la cabecera Retry-After entre consultas.

Qué entra. Solo facturas, recibidas o emitidas: un PDF, JPEG o PNG de hasta 15 MB. Se leen como mucho 5 páginas. Un documento con varias facturas se rechaza (MULTIFACTURA): un proceso es una factura. Lo que no es una factura (un ticket, una nómina, un extracto, un acta…) se rechaza (NO_ES_FACTURA). Al subirla dices si es una compra (la empresa de la llave la recibe) o una venta (la emite): la factura se lee así y la respuesta lo repite en documento.direccion (RECIBIDA o EMITIDA). Si en el papel la empresa aparece en el lado contrario, se lee igual como dijiste y el documento lleva el aviso TIPO_DISCREPANTE.

Qué se cobra. Cada factura que se entrega terminada descuenta un proceso de tu pool. Un trabajo rechazado o fallido no descuenta.

Qué guardamos. Nada de la factura. El resultado se conserva 24 horas desde que termina, para que lo recojas; después, el trabajo ya no existe (NO_ENCONTRADO).

El documento v1 está descrito en respuesta.schema.json. Regla de v1: se pueden añadir campos, nunca quitarlos ni renombrarlos. Tu programa debe ignorar los campos que no conozca y tratar un código de aviso que no conozca como «revise el documento».

Los errores siempre llevan un codigo estable (el que debe mirar tu programa) y un mensaje para personas. Esta API no usa los códigos HTTP 400, 403 ni 404: una ráfaga de ellos hace que el cortafuegos de entrada bloquee tu IP durante horas. Los fallos de llave son 401; los de la pool, 402; los de ritmo, 429; los del documento, 413, 415 o 422; y si el motor de lectura no está disponible en ese momento, 503 con Retry-After (al subir y al consultar). La documentación trae la tabla de todos los errores, con un ejemplo de cada uno.

Cómo conseguir acceso

Acceso bajo solicitud

La API se activa con alta previa: escríbenos y te preparamos el acceso.

Escríbenos

Con el acceso preparado, el ciclo empieza por la recogida de tu llave: lo cuenta Las llaves.

Arranque rápido

Con curl

El guion entero: inicio_rapido.sh. Se lanza con MC_VALE=… MC_NIF=… MC_NOMBRE="…" MC_TIPO=compra bash inicio_rapido.sh factura.pdf: el NIF y el nombre son los de la empresa cuyas facturas vas a enviar, y MC_TIPO dice si esa factura es una compra o una venta de esa empresa (ver Las llaves).

Paso 0 · lo que usan los pasos: la dirección de la API, la factura, compra o venta, y un lector de JSON

API="${MC_API:-https://api.munincloud.com}"
FACTURA="${1:?uso: MC_VALE=… MC_NIF=… MC_NOMBRE=… MC_TIPO=compra|venta bash inicio_rapido.sh factura.pdf}"
TIPO="${MC_TIPO:?pon en MC_TIPO compra (la empresa recibe la factura) o venta (la emite)}"
case "$TIPO" in compra|venta) ;; *) echo "MC_TIPO tiene que ser compra o venta, no «$TIPO»" >&2; exit 2 ;; esac
campo() { python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
umask 077   # los ficheros de las llaves nacen legibles solo por ti

Paso 1 · canjear el vale por la llave de administrar (una sola vez)

# El vale es lo que va detrás de «#vale=» en el enlace que recibes tras la compra. Sirve UNA vez.
curl -sS -X POST "$API/v1/recogida" \
  -H "Content-Type: application/json" \
  -d "{\"vale\": \"${MC_VALE:?pon en MC_VALE el vale de tu enlace}\"}" > recogida.json
campo llave < recogida.json > mc_llave_administrar   # sin «&&»: con set -e, un fallo aquí PARA el guion
rm recogida.json

Paso 2 · crear una llave de procesar para la empresa: su NIF y su nombre, los dos obligatorios

# El JSON lo escribe python3 para que un nombre con comillas o tildes vaya bien escrito.
: "${MC_NIF:?pon en MC_NIF el NIF de la empresa}" "${MC_NOMBRE:?pon en MC_NOMBRE el nombre o razón social de la empresa}"
python3 -c 'import json,os; print(json.dumps({"cif": os.environ["MC_NIF"], "nombre": os.environ["MC_NOMBRE"]}))' > llave_nueva.json
curl -sS -X POST "$API/v1/llaves" \
  -H "Authorization: Bearer $(cat mc_llave_administrar)" \
  -H "Content-Type: application/json" \
  -d @llave_nueva.json > llave.json
campo llave < llave.json > mc_llave_procesar
rm llave.json

Paso 3 · subir una factura diciendo si es compra o venta: la API contesta enseguida con un número de trabajo

curl -sS "$API/v1/trabajos" \
  -H "Authorization: Bearer $(cat mc_llave_procesar)" \
  -H "Idempotency-Key: $(basename "$FACTURA")-$TIPO-$(date +%Y%m%d)" \
  -F "tipo=$TIPO" \
  -F "factura=@$FACTURA" | tee trabajo.json
echo
TRABAJO=$(campo trabajo < trabajo.json)

Paso 4 · consultar el trabajo hasta que deje de estar «en_curso», respetando Retry-After

while :; do
  curl -sS -D cabeceras.txt "$API/v1/trabajos/$TRABAJO" \
    -H "Authorization: Bearer $(cat mc_llave_procesar)" > resultado.json
  [ "$(campo estado < resultado.json)" = "en_curso" ] || break
  ESPERA=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9]*\).*/\1/p' cabeceras.txt)
  sleep "${ESPERA:-5}"
done
cat resultado.json; echo

Paso 5 · ver lo que queda en la pool

curl -sS "$API/v1/pool" -H "Authorization: Bearer $(cat mc_llave_procesar)"
echo

Con Python

El guion entero: inicio_rapido.py (pip install requests; MC_LLAVE_PROCESAR=… python3 inicio_rapido.py factura.pdf compra).

Paso 0 · lo que usan los pasos

import json
import os
import sys
import time

import requests

API = os.environ.get("MC_API", "https://api.munincloud.com")


def error_de(r):
    """Los errores siempre traen un `codigo` estable (el que debe mirar tu programa) y un `mensaje` para personas."""
    try:
        return r.json()["error"]["codigo"]
    except (ValueError, KeyError):
        return f"HTTP {r.status_code}"

Paso 1 · la sesión, con la llave de procesar en la cabecera

sesion = requests.Session()
sesion.headers["Authorization"] = "Bearer " + os.environ["MC_LLAVE_PROCESAR"]

Paso 2 · subir la factura, diciendo si es compra o venta

if len(sys.argv) != 3 or sys.argv[2] not in ("compra", "venta"):
    sys.exit("uso: python3 inicio_rapido.py factura.pdf compra|venta")
ruta, tipo = sys.argv[1], sys.argv[2]
with open(ruta, "rb") as fh:
    r = sesion.post(f"{API}/v1/trabajos",
                    headers={"Idempotency-Key": f"{os.path.basename(ruta)}-{tipo}-{time.strftime('%Y%m%d')}"},
                    data={"tipo": tipo}, files={"factura": (os.path.basename(ruta), fh)}, timeout=60)
if r.status_code != 202:
    sys.exit(f"no se pudo subir: {error_de(r)}")
trabajo = r.json()["trabajo"]
print("trabajo:", trabajo)

Paso 3 · consultar hasta que deje de estar «en_curso», respetando Retry-After

while True:
    r = sesion.get(f"{API}/v1/trabajos/{trabajo}", timeout=30)
    if r.status_code in (429, 503) or (r.status_code == 200 and r.json()["estado"] == "en_curso"):
        time.sleep(int(r.headers.get("Retry-After", "5")))
        continue
    break
if r.status_code != 200:
    sys.exit(f"no se pudo consultar: {error_de(r)}")
resultado = r.json()
if resultado["estado"] == "terminado":
    documento = resultado["documento"]
    print(json.dumps(documento["factura"], ensure_ascii=False, indent=2))
    print("dirección:", documento["direccion"])     # RECIBIDA si dijiste compra, EMITIDA si dijiste venta
    print("avisos:", [a["codigo"] for a in documento.get("avisos", [])])
else:
    print("rechazado:", resultado["error"]["codigo"], "-", resultado["error"]["mensaje"])

Paso 4 · lo que queda en la pool

pool = sesion.get(f"{API}/v1/pool", timeout=30).json()
print("disponibles:", pool["disponibles"], "· estado:", pool["estado"])

Las llaves: quién paga, qué empresa se analiza, compra o venta

En la API hay dos papeles, y pueden ser la misma empresa o no:

Compra o venta se dice en cada subida (tipo), siempre desde el punto de vista de la empresa de la llave: es una compra si esa empresa recibe la factura (aparece como cliente; un proveedor se la emite) y una venta si la emite (aparece como proveedor). La factura se lee como digas, y el documento lo repite en direccion (RECIBIDA o EMITIDA). Si en el papel la empresa aparece en el lado contrario, se lee igual como dijiste y llega con el aviso TIPO_DISCREPANTE (ver Avisos).

El ciclo de la llave

  1. Recogida: tras la compra recibes un enlace de un solo uso (https://api.munincloud.com/v1/recogida#vale=…). Al abrirlo (o con POST /v1/recogida) obtienes la llave de administrar, que se ve una vez. Un vale usado, caducado o inexistente contesta 410 RECOGIDA_NO_VALIDA.
  2. Administrar: con ella creas una llave de procesar por empresa (su NIF y su nombre), las listas, las limitas a unas IP y las anulas.
  3. Procesar: con una llave de procesar subes facturas, consultas trabajos y ves la pool.
grupoqué hace y con qué llaveoperaciones
trabajos

Subir facturas y recoger su resultado. Llave de procesar.

POST /v1/trabajos
GET /v1/trabajos/{trabajo}
pool

El saldo del mes. Cualquier llave de la pool.

GET /v1/pool
llaves

Crear, listar, limitar por IP y anular las llaves de procesar. Llave de administrar.

GET /v1/llaves
POST /v1/llaves
PATCH /v1/llaves/{llave}
DELETE /v1/llaves/{llave}
recogida

El enlace de un solo uso que entrega la llave de administrar tras el cobro. Sin llave.

GET /v1/recogida
POST /v1/recogida

Errores

Cada error lleva un codigo estable, que es el que debe mirar tu programa, y un mensaje para personas. Esta tabla sale tal cual de los ejemplos del contrato (18 casos).

HTTPcodigocuándoejemplo (literal del contrato)dónde
200 (trabajo rechazado)MULTIFACTURA

El trabajo termina rechazado y no descuenta de la pool. El documento contiene más de una factura. Un proceso es una factura: súbalas por separado.

{
 "trabajo": "trb_01J9ZQ3K4T7W2B5N8C6D0F1G2K",
 "estado": "rechazado",
 "creado": "2026-10-02T16:20:00Z",
 "terminado": "2026-10-02T16:20:31Z",
 "error": {
  "codigo": "MULTIFACTURA",
  "mensaje": "El documento contiene más de una factura. Un proceso es una factura: súbalas por separado."
 },
 "pool": {
  "estado": "activa",
  "disponibles": 2412,
  "aviso_80": false
 }
}
GET /v1/trabajos/{trabajo}
200 (trabajo rechazado)NO_ES_FACTURA

El trabajo termina rechazado y no descuenta de la pool. El documento no es una factura (recibida o emitida). Hoy solo se procesan facturas.

{
 "trabajo": "trb_01J9ZQ3K4T7W2B5N8C6D0F1G2J",
 "estado": "rechazado",
 "creado": "2026-10-02T16:18:39Z",
 "terminado": "2026-10-02T16:18:41Z",
 "error": {
  "codigo": "NO_ES_FACTURA",
  "mensaje": "El documento no es una factura (recibida o emitida). Hoy solo se procesan facturas.",
  "tipo_detectado": null
 },
 "pool": {
  "estado": "activa",
  "disponibles": 2412,
  "aviso_80": false
 }
}
GET /v1/trabajos/{trabajo}
401IP_NO_PERMITIDA

La llave falta, no es válida, está anulada, se usa desde una IP no permitida o no sirve para esta ruta. Códigos: LLAVE_INVALIDA, LLAVE_ANULADA, IP_NO_PERMITIDA, LLAVE_SIN_PERMISO.

{
 "error": {
  "codigo": "IP_NO_PERMITIDA",
  "mensaje": "Esta llave no admite peticiones desde esta IP."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/trabajos
401LLAVE_ANULADA

La llave falta, no es válida, está anulada, se usa desde una IP no permitida o no sirve para esta ruta. Códigos: LLAVE_INVALIDA, LLAVE_ANULADA, IP_NO_PERMITIDA, LLAVE_SIN_PERMISO.

{
 "error": {
  "codigo": "LLAVE_ANULADA",
  "mensaje": "Esta llave está anulada. Usa otra llave de la pool o crea una nueva."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/trabajos
401LLAVE_INVALIDA

La llave falta, no es válida, está anulada, se usa desde una IP no permitida o no sirve para esta ruta. Códigos: LLAVE_INVALIDA, LLAVE_ANULADA, IP_NO_PERMITIDA, LLAVE_SIN_PERMISO.

{
 "error": {
  "codigo": "LLAVE_INVALIDA",
  "mensaje": "Falta la llave o no es válida. Envíala como «Authorization: Bearer mc_live_…»."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/trabajos
401LLAVE_SIN_PERMISO

La llave falta, no es válida, está anulada, se usa desde una IP no permitida o no sirve para esta ruta. Códigos: LLAVE_INVALIDA, LLAVE_ANULADA, IP_NO_PERMITIDA, LLAVE_SIN_PERMISO.

{
 "error": {
  "codigo": "LLAVE_SIN_PERMISO",
  "mensaje": "Esta llave no sirve para esta ruta (hace falta la de administrar o la de procesar)."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/trabajos
402POOL_AGOTADA

La pool no puede procesar. Códigos POOL_AGOTADA (parada, se recarga con las mismas llaves) y POOL_CERRADA.

{
 "error": {
  "codigo": "POOL_AGOTADA",
  "mensaje": "La pool está parada porque se han agotado el plan del mes y las recargas. Al recargarla, las mismas llaves vuelven a funcionar.",
  "recargar": "https://munincloud.com/api/recargar"
 }
}
POST /v1/trabajos
402POOL_CERRADA

La pool no puede procesar. Códigos POOL_AGOTADA (parada, se recarga con las mismas llaves) y POOL_CERRADA.

{
 "error": {
  "codigo": "POOL_CERRADA",
  "mensaje": "La pool está cerrada y sus llaves anuladas. Hace falta una pool nueva."
 }
}
GET /v1/pool
POST /v1/llaves
POST /v1/trabajos
410NO_ENCONTRADO

No existe, es de otra llave o ya caducó; las tres contestan igual (NO_ENCONTRADO).

{
 "error": {
  "codigo": "NO_ENCONTRADO",
  "mensaje": "No existe, no es de esta llave o ya caducó (los resultados se guardan 24 horas)."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
410RECOGIDA_NO_VALIDA

El vale está usado, caducado o no existe (RECOGIDA_NO_VALIDA).

{
 "error": {
  "codigo": "RECOGIDA_NO_VALIDA",
  "mensaje": "Este enlace ya se ha usado, ha caducado o no existe. Si no recogiste tu llave, escríbenos."
 }
}
POST /v1/recogida
413DOCUMENTO_NO_VALIDO

El fichero pasa de 15 MB (DOCUMENTO_NO_VALIDO).

{
 "error": {
  "codigo": "DOCUMENTO_NO_VALIDO",
  "mensaje": "El fichero pasa de 15 MB."
 }
}
POST /v1/trabajos
415DOCUMENTO_NO_VALIDO

El fichero no es PDF, JPEG ni PNG (DOCUMENTO_NO_VALIDO).

{
 "error": {
  "codigo": "DOCUMENTO_NO_VALIDO",
  "mensaje": "El fichero no es PDF, JPEG ni PNG."
 }
}
POST /v1/trabajos
422DOCUMENTO_NO_VALIDO

Falta el fichero o está roto (DOCUMENTO_NO_VALIDO), o la petición está mal formada (PETICION_NO_VALIDA).

{
 "error": {
  "codigo": "DOCUMENTO_NO_VALIDO",
  "mensaje": "Falta el campo «factura» o el fichero está vacío o roto."
 }
}
POST /v1/trabajos
422PETICION_NO_VALIDA

Falta el fichero o está roto (DOCUMENTO_NO_VALIDO), o la petición está mal formada (PETICION_NO_VALIDA).

{
 "error": {
  "codigo": "PETICION_NO_VALIDA",
  "mensaje": "El campo «cif» no tiene forma de NIF (9 caracteres, letras y números)."
 }
}
DELETE /v1/llaves/{llave}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/recogida
POST /v1/trabajos
429RITMO_EXCEDIDO

Demasiadas peticiones (RITMO_EXCEDIDO).

{
 "error": {
  "codigo": "RITMO_EXCEDIDO",
  "mensaje": "Demasiadas peticiones seguidas. Espera y reintenta.",
  "reintentar_en_s": 5
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/recogida
POST /v1/trabajos
500ERROR_INTERNO

Un fallo nuestro (ERROR_INTERNO). Nunca lleva trazas ni rutas internas.

{
 "error": {
  "codigo": "ERROR_INTERNO",
  "mensaje": "No se pudo completar la petición por un fallo nuestro. No se ha descontado nada; puedes reintentar."
 }
}
DELETE /v1/llaves/{llave}
GET /v1/llaves
GET /v1/pool
GET /v1/trabajos/{trabajo}
PATCH /v1/llaves/{llave}
POST /v1/llaves
POST /v1/recogida
POST /v1/trabajos
503ERROR_INTERNO

El motor de lectura no está disponible ahora mismo (ERROR_INTERNO); reintenta tras Retry-After. No se ha descontado nada.

{
 "error": {
  "codigo": "ERROR_INTERNO",
  "mensaje": "El motor de lectura no está disponible ahora mismo. No se ha descontado nada.",
  "reintentar_en_s": 30
 }
}
POST /v1/trabajos
503ERROR_INTERNO

El trabajo terminó, pero el motor de lectura no puede devolver el resultado ahora mismo (ERROR_INTERNO). El trabajo sigue guardado; vuelve a consultar tras Retry-After.

{
 "error": {
  "codigo": "ERROR_INTERNO",
  "mensaje": "El resultado no se puede recoger ahora mismo. El trabajo sigue guardado: vuelve a consultarlo en unos segundos.",
  "reintentar_en_s": 30
 }
}
GET /v1/trabajos/{trabajo}

Avisos

Un aviso no rechaza la factura: el trabajo llega terminado, con su documento, y descuenta de la pool como cualquier otro; pero conviene revisarla antes de contabilizarla. Van en documento.avisos, cada uno con su codigo estable y un mensaje. La lista crece: un código que tu programa no conozca, trátalo como «revise el documento». Los de hoy (7):

codigomensaje que recibes (… = el dato de tu factura)
LECTURA_INCOMPLETALa auditoría terminó sin que todas las comprobaciones cuadraran. Revise el documento antes de contabilizarlo.
DESCUADRELos importes del documento no cuadran entre sí (bases, cuotas y total) ni tras releerlo. Puede que la factura esté mal calculada en origen.
NIF_CONTRAPARTE_NO_LOCALIZADONo se pudo localizar el NIF de la otra parte de la factura (el emisor si es recibida, el receptor si es emitida).
CIERRE_DESCONOCIDONo se pudo determinar cómo terminó la auditoría. Revise el documento.
PAGINAS_NO_LEIDASEl documento tiene … páginas y solo se leen las … primeras.
EMPRESA_NO_APARECEEl NIF asociado a la llave (…) no aparece entre los identificadores leídos en el documento. Compruebe que la factura es de esa empresa.
TIPO_DISCREPANTESe ha leído como …, como se indicó al subirla, pero en el documento la empresa de la llave aparece como …. Compruebe si es una compra o una venta.

Referencia

Referencia interactiva (Swagger UI 5.24.2, servido desde este sitio) · openapi.yaml · respuesta.schema.json (el documento v1).

Conectores

Código abierto (licencia MIT) para llevar el documento v1 a tu programa de contabilidad:

a3® es una marca de Wolters Kluwer. MuninCloud no está asociado a, ni certificado por, Wolters Kluwer. a3ASESOR®, a3innuva® e Importia® también son marcas de Wolters Kluwer. ERPNext® es una marca de Frappe Technologies Pvt. Ltd. MuninCloud no está asociado a, ni certificado por, Frappe Technologies Pvt. Ltd. Frappe® también es una marca de Frappe Technologies Pvt. Ltd. Odoo® es una marca de Odoo S.A. MuninCloud no está asociado a, ni certificado por, Odoo S.A. Contasol® es una marca de Software DELSOL. MuninCloud no está asociado a, ni certificado por, Software DELSOL. Sage® es una marca de Sage Global Services Limited o sus licenciantes. MuninCloud no está asociado a, ni certificado por, Sage Global Services Limited o sus licenciantes. Holded® es una marca de Holded. MuninCloud no está asociado a, ni certificado por, Holded.