Saltar al contenido

Documentación

Referencia de la API de descarga de CFDI, versión 1.

Antes de empezar

La dirección base es https://dev.lagoontech.com.mx/v1. La versión va en la ruta.

Cada petición lleva tu llave en la cabecera Authorization: Bearer. La llave de prueba empieza con lt_prueba_ y la de uso real con lt_vivo_. Se pide en tu cuenta y se muestra una sola vez.

Las respuestas son JSON en UTF-8. Los importes van como texto decimal para no perder centavos. Las fechas van como AAAA-MM-DD.

El esquema OpenAPI de esta referencia está en /v1/openapi.json.

Entorno de prueba

Sin llave, las mismas rutas existen bajo /v1/sandbox/. Responden al instante con datos de ejemplo. Con el mismo RFC y periodo siempre recibes los mismos comprobantes.

Puedes mandar hasta 60 peticiones por minuto desde una misma dirección. Si tienes llave, también puedes usar el entorno de prueba.

PidescurlPythonJavaScriptPHP

curl -G https://dev.lagoontech.com.mx/v1/sandbox/cfdi \
  -d rfc=LAG170807KX3 \
  -d desde=2026-09-01 \
  -d hasta=2026-09-30 \
  -d limite=2
import requests

respuesta = requests.get(
    "https://dev.lagoontech.com.mx/v1/sandbox/cfdi",
    params={
        "rfc": "LAG170807KX3",
        "desde": "2026-09-01",
        "hasta": "2026-09-30",
        "limite": 2,
    },
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/sandbox/cfdi" + "?" + new URLSearchParams({
    "rfc": "LAG170807KX3",
    "desde": "2026-09-01",
    "hasta": "2026-09-30",
    "limite": 2,
}));
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/sandbox/cfdi" . "?" . http_build_query([
    "rfc" => "LAG170807KX3",
    "desde" => "2026-09-01",
    "hasta" => "2026-09-30",
    "limite" => 2,
]));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch);

Recibes

{
  "data": [
    {
      "uuid": "f0d7917d-6525-56bf-997b-88e5e98489dc",
      "rol": "emitido",
      "tipo_comprobante": "I",
      "serie_folio": "A4392",
      "fecha": "2026-09-01T10:03:37",
      "emisor": {
        "rfc": "LAG170807KX3",
        "nombre": "Contribuyente de ejemplo"
      },
      "receptor": {
        "rfc": "FFF060606FF6",
        "nombre": "Alimentos del Desierto"
      },
      "total": "12962.78",
      "moneda": "MXN",
      "estatus_sat": "vigente"
    },
    {
      "uuid": "1176d2c0-28bf-5208-98e1-7038f7ec227d",
      "rol": "recibido",
      "tipo_comprobante": "I",
      "serie_folio": "A6645",
      "fecha": "2026-09-01T10:11:20",
      "emisor": {
        "rfc": "XAXX010101000",
        "nombre": "Público en general"
      },
      "receptor": {
        "rfc": "LAG170807KX3",
        "nombre": "Contribuyente de ejemplo"
      },
      "total": "16363.22",
      "moneda": "MXN",
      "estatus_sat": "vigente"
    }
  ],
  "siguiente_cursor": "bzoy",
  "hay_mas": true,
  "completo": true,
  "total": 106
}

Límites

  • Periodos de hasta 12 meses por consulta.
  • El SAT conserva cinco años más el ejercicio en curso. Un periodo más viejo responde periodo_invalido.
  • Llave de prueba: 30 peticiones por minuto y 2000 al mes por RFC.
  • Llave de uso real: 120 peticiones por minuto y 20000 al mes por RFC contratado.

Al pasar un límite recibes limite_excedido con estado 429 y la cabecera Retry-After, con los segundos que debes esperar. Esa petición no cuenta.

Paginación

Las listas usan cursor. La respuesta trae siguiente_cursor. Pásalo como cursor, con los mismos demás parámetros, para pedir la página siguiente. Cuando hay_mas es falso, ya recibiste todo.

El campo completo dice si el periodo quedó descargado completo. Mientras sea falso, vuelve a consultar más tarde.

Crear una consulta

POST /v1/consultas

Pide la descarga de los CFDI de un RFC en un periodo de hasta 12 meses. Devuelve la consulta.

Parámetros
NombreTipoDescripción
rfc obligatoriostringRFC del contribuyente, de 12 o 13 caracteres.
desde obligatoriostring AAAA-MM-DDPrimer día del periodo, AAAA-MM-DD.
hasta obligatoriostring AAAA-MM-DDÚltimo día del periodo, AAAA-MM-DD. El periodo no pasa de 12 meses.
rolemitidos | recibidos | ambosQué comprobantes: emitidos, recibidos o ambos.

Estados que puede responder: 201, 400, 401, 403, 404, 409, 429, 503. Los errores están más abajo.

PidescurlPythonJavaScriptPHP

curl -X POST https://dev.lagoontech.com.mx/v1/consultas \
  -H "Authorization: Bearer $LLAVE" \
  -H "Content-Type: application/json" \
  -d '{"rfc": "LAG170807KX3", "desde": "2026-09-01", "hasta": "2026-09-30"}'
import os
import requests

respuesta = requests.post(
    "https://dev.lagoontech.com.mx/v1/consultas",
    headers={"Authorization": f"Bearer {os.environ['LLAVE']}"},
    json={
        "rfc": "LAG170807KX3",
        "desde": "2026-09-01",
        "hasta": "2026-09-30",
    },
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/consultas", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.LLAVE}`, "Content-Type": "application/json" },
  body: JSON.stringify({
      "rfc": "LAG170807KX3",
      "desde": "2026-09-01",
      "hasta": "2026-09-30",
  }),
});
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/consultas");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        "rfc" => "LAG170807KX3",
        "desde" => "2026-09-01",
        "hasta" => "2026-09-30",
    ]),
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LLAVE"), "Content-Type: application/json"],
]);
echo curl_exec($ch);

Recibes

{
  "id": "con_ej_TEFHMTcwODA3S1gzfDIwMjYtMDktMDF8MjAyNi0wOS0zMHxhbWJvcw",
  "rfc": "LAG170807KX3",
  "desde": "2026-09-01",
  "hasta": "2026-09-30",
  "rol": "ambos",
  "estado": "completa",
  "completo": true,
  "total": 106,
  "creada": "2026-09-30T23:59:59-06:00",
  "terminada": "2026-09-30T23:59:59-06:00",
  "error": null
}

Ver una consulta

GET /v1/consultas/{id}

Devuelve el estado de una consulta. Si `completo` es verdadero, el periodo quedó descargado completo.

Parámetros
NombreTipoDescripción
id obligatoriostringIdentificador de la consulta.

Estados que puede responder: 200, 401, 404, 429. Los errores están más abajo.

PidescurlPythonJavaScriptPHP

curl https://dev.lagoontech.com.mx/v1/consultas/con_4f9a1c0b7d2e83a65b10 \
  -H "Authorization: Bearer $LLAVE"
import os
import requests

respuesta = requests.get(
    "https://dev.lagoontech.com.mx/v1/consultas/con_4f9a1c0b7d2e83a65b10",
    headers={"Authorization": f"Bearer {os.environ['LLAVE']}"},
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/consultas/con_4f9a1c0b7d2e83a65b10", {
  headers: { Authorization: `Bearer ${process.env.LLAVE}` },
});
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/consultas/con_4f9a1c0b7d2e83a65b10");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LLAVE")],
]);
echo curl_exec($ch);

Recibes

{
  "id": "con_ej_TEFHMTcwODA3S1gzfDIwMjYtMDktMDF8MjAyNi0wOS0zMHxhbWJvcw",
  "rfc": "LAG170807KX3",
  "desde": "2026-09-01",
  "hasta": "2026-09-30",
  "rol": "ambos",
  "estado": "completa",
  "completo": true,
  "total": 106,
  "creada": "2026-09-30T23:59:59-06:00",
  "terminada": "2026-09-30T23:59:59-06:00",
  "error": null
}

Leer CFDI

GET /v1/cfdi

Lista los CFDI de un RFC en un periodo, de más viejo a más nuevo, con paginación por cursor.

Parámetros
NombreTipoDescripción
rfc obligatoriostringRFC del contribuyente, de 12 o 13 caracteres.
desde obligatoriostring AAAA-MM-DDPrimer día del periodo, AAAA-MM-DD.
hasta obligatoriostring AAAA-MM-DDÚltimo día del periodo, AAAA-MM-DD. El periodo no pasa de 12 meses.
rolemitidos | recibidos | ambosQué comprobantes: emitidos, recibidos o ambos.
cursorstringValor de siguiente_cursor de la página anterior.
limiteintegerCuántos CFDI por página, de 1 a 200.

Estados que puede responder: 200, 400, 401, 403, 404, 429, 503. Los errores están más abajo.

PidescurlPythonJavaScriptPHP

curl -G https://dev.lagoontech.com.mx/v1/cfdi \
  -H "Authorization: Bearer $LLAVE" \
  -d rfc=LAG170807KX3 \
  -d desde=2026-09-01 \
  -d hasta=2026-09-30 \
  -d limite=2
import os
import requests

respuesta = requests.get(
    "https://dev.lagoontech.com.mx/v1/cfdi",
    headers={"Authorization": f"Bearer {os.environ['LLAVE']}"},
    params={
        "rfc": "LAG170807KX3",
        "desde": "2026-09-01",
        "hasta": "2026-09-30",
        "limite": 2,
    },
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/cfdi" + "?" + new URLSearchParams({
    "rfc": "LAG170807KX3",
    "desde": "2026-09-01",
    "hasta": "2026-09-30",
    "limite": 2,
}), {
  headers: { Authorization: `Bearer ${process.env.LLAVE}` },
});
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/cfdi" . "?" . http_build_query([
    "rfc" => "LAG170807KX3",
    "desde" => "2026-09-01",
    "hasta" => "2026-09-30",
    "limite" => 2,
]));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LLAVE")],
]);
echo curl_exec($ch);

Recibes

{
  "data": [
    {
      "uuid": "f0d7917d-6525-56bf-997b-88e5e98489dc",
      "rol": "emitido",
      "tipo_comprobante": "I",
      "serie_folio": "A4392",
      "fecha": "2026-09-01T10:03:37",
      "emisor": {
        "rfc": "LAG170807KX3",
        "nombre": "Contribuyente de ejemplo"
      },
      "receptor": {
        "rfc": "FFF060606FF6",
        "nombre": "Alimentos del Desierto"
      },
      "total": "12962.78",
      "moneda": "MXN",
      "estatus_sat": "vigente"
    },
    {
      "uuid": "1176d2c0-28bf-5208-98e1-7038f7ec227d",
      "rol": "recibido",
      "tipo_comprobante": "I",
      "serie_folio": "A6645",
      "fecha": "2026-09-01T10:11:20",
      "emisor": {
        "rfc": "XAXX010101000",
        "nombre": "Público en general"
      },
      "receptor": {
        "rfc": "LAG170807KX3",
        "nombre": "Contribuyente de ejemplo"
      },
      "total": "16363.22",
      "moneda": "MXN",
      "estatus_sat": "vigente"
    }
  ],
  "siguiente_cursor": "bzoy",
  "hay_mas": true,
  "completo": true,
  "total": 106
}

Ver tu uso

GET /v1/uso

Peticiones que hizo tu llave en el mes, por RFC y por día, y lo que queda de tu límite.

Estados que puede responder: 200, 401, 429. Los errores están más abajo.

PidescurlPythonJavaScriptPHP

curl https://dev.lagoontech.com.mx/v1/uso \
  -H "Authorization: Bearer $LLAVE"
import os
import requests

respuesta = requests.get(
    "https://dev.lagoontech.com.mx/v1/uso",
    headers={"Authorization": f"Bearer {os.environ['LLAVE']}"},
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/uso", {
  headers: { Authorization: `Bearer ${process.env.LLAVE}` },
});
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/uso");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LLAVE")],
]);
echo curl_exec($ch);

Recibes

{
  "periodo": "2026-10",
  "llave": "lt_prueba_",
  "entorno": "prueba",
  "total": 2,
  "por_rfc": [
    {
      "rfc": "LAG170807KX3",
      "cantidad": 2
    }
  ],
  "por_dia": [
    {
      "dia": "2026-10-23",
      "cantidad": 2
    }
  ],
  "limite_minuto": 60,
  "restante_minuto": 58,
  "limite_mensual": 2000,
  "restante_mes": 1998
}

Registrar un webhook

POST /v1/webhooks

Registra una URL https que recibe un aviso firmado cuando una consulta termina. El secreto se muestra una sola vez; con él verificas la cabecera `Lagoon-Firma`.

Parámetros
NombreTipoDescripción
url obligatoriostringURL https pública que recibe los avisos.
eventosarrayEventos que quieres recibir.

Estados que puede responder: 201, 400, 401, 403, 429. Los errores están más abajo.

PidescurlPythonJavaScriptPHP

curl -X POST https://dev.lagoontech.com.mx/v1/webhooks \
  -H "Authorization: Bearer $LLAVE" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://tu-sistema.mx/hooks/lagoon", "eventos": ["consulta.completa", "consulta.fallida"]}'
import os
import requests

respuesta = requests.post(
    "https://dev.lagoontech.com.mx/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['LLAVE']}"},
    json={
        "url": "https://tu-sistema.mx/hooks/lagoon",
        "eventos": ["consulta.completa", "consulta.fallida"],
    },
)
print(respuesta.json())
const respuesta = await fetch("https://dev.lagoontech.com.mx/v1/webhooks", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.LLAVE}`, "Content-Type": "application/json" },
  body: JSON.stringify({
      "url": "https://tu-sistema.mx/hooks/lagoon",
      "eventos": ["consulta.completa", "consulta.fallida"],
  }),
});
console.log(await respuesta.json());
<?php
$ch = curl_init("https://dev.lagoontech.com.mx/v1/webhooks");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://tu-sistema.mx/hooks/lagoon",
        "eventos" => ["consulta.completa", "consulta.fallida"],
    ]),
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LLAVE"), "Content-Type: application/json"],
]);
echo curl_exec($ch);

Recibes

{
  "id": "whk_ejemplo",
  "url": "https://tu-sistema.mx/hooks/lagoon",
  "eventos": [
    "consulta.completa",
    "consulta.fallida"
  ],
  "secreto": "whsec_ejemplo_no_firma_nada",
  "creado": "2026-10-23T09:00:00-06:00"
}

Webhooks

Registra una URL https pública con POST /v1/webhooks y recibe un aviso cuando una consulta termina. Los eventos son consulta.completa y consulta.fallida.

Cada aviso es un POST con JSON y tres cabeceras: Lagoon-Evento, Lagoon-Entrega y Lagoon-Firma. La firma trae la hora y un HMAC SHA-256 de la hora, un punto y el cuerpo, calculado con el secreto que recibiste al registrar el webhook.

Verifica la firma antes de procesar el aviso y rechaza los que tengan más de cinco minutos. Responde con cualquier 2xx. Si no, reintentamos con esperas de 1 min, 5 min, 30 min, 2 h, 12 h; después del último intento la entrega queda fallida. Puede llegar el mismo aviso más de una vez: usa el campo id para no procesarlo dos veces.

VerificasPythonJavaScriptPHP

import hashlib
import hmac
import time


def firma_valida(secreto: str, cuerpo: bytes, cabecera: str) -> bool:
    campos = dict(parte.split("=", 1) for parte in cabecera.split(","))
    if abs(time.time() - int(campos["t"])) > 300:
        return False
    mensaje = campos["t"].encode() + b"." + cuerpo
    esperada = hmac.new(secreto.encode(), mensaje, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, campos["v1"])
import { createHmac, timingSafeEqual } from "node:crypto";

export function firmaValida(secreto, cuerpo, cabecera) {
  const campos = Object.fromEntries(cabecera.split(",").map((parte) => parte.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(campos.t)) > 300) return false;
  const esperada = createHmac("sha256", secreto).update(`${campos.t}.${cuerpo}`).digest("hex");
  if (esperada.length !== campos.v1.length) return false;
  return timingSafeEqual(Buffer.from(esperada), Buffer.from(campos.v1));
}
<?php
function firmaValida(string $secreto, string $cuerpo, string $cabecera): bool {
    parse_str(str_replace(",", "&", $cabecera), $campos);
    if (abs(time() - (int) $campos["t"]) > 300) {
        return false;
    }
    $esperada = hash_hmac("sha256", $campos["t"] . "." . $cuerpo, $secreto);
    return hash_equals($esperada, $campos["v1"]);
}

Errores

Todo error tiene la misma forma. El estado HTTP dice la clase del problema, tipo es estable para tu código y ayuda lleva a esta sección.

autenticacion_requerida 401
La petición no trae la cabecera Authorization con una llave. Manda Authorization: Bearer y tu llave en cada petición.
llave_invalida 401
La llave no existe, está revocada o ya venció. Revisa que copiaste la llave completa. Si la revocaste o venció, crea otra en tu cuenta.
ambito_insuficiente 403
La llave es de solo lectura y esta operación escribe. Usa una llave con ámbito completo.
rfc_no_permitido 403
La llave no tiene permiso para el RFC que pediste. Pide solo los RFC de tu llave. Los ves en tu cuenta.
parametro_invalido 400
Un parámetro falta, sobra o no tiene el formato esperado. El campo parametro dice cuál. Corrígelo y repite la petición.
rfc_invalido 400
El RFC no tiene 12 o 13 caracteres con la forma de un RFC. Escríbelo sin espacios, por ejemplo LAG170807KX3. Las minúsculas se aceptan.
fecha_invalida 400
Una fecha no existe o no está en formato AAAA-MM-DD. Manda la fecha completa, por ejemplo 2026-10-31.
periodo_invalido 400
El periodo pasa de 12 meses, termina antes de empezar, llega al futuro o es más viejo de lo que guarda el SAT Pide periodos de hasta 12 meses y desde el primero de enero de hace cinco años.
cursor_invalido 400
El cursor no es uno que entregamos para esta consulta. Usa tal cual el valor de siguiente_cursor de la respuesta anterior, con los mismos parámetros.
url_invalida 400
La dirección del webhook no es una URL https pública. Registra una URL https que se pueda alcanzar desde internet.
consulta_no_encontrada 404
No hay una consulta con ese identificador en tu cuenta. Revisa el identificador. Solo ves las consultas hechas con tus llaves.
ruta_no_encontrada 404
Esa ruta no existe en la versión 1. Revisa la ruta contra la referencia.
metodo_no_permitido 405
La ruta existe pero no acepta ese método. Usa el método que indica la referencia.
rfc_no_disponible 404
El RFC no está dado de alta para descarga con tu llave. Da de alta el RFC en tu cuenta y vuelve a intentar.
rfc_sin_descarga 409
El RFC está dado de alta pero no se puede descargar ahora, por ejemplo por su e.firma. El mensaje dice la causa. Corrígela en tu cuenta y vuelve a intentar.
limite_excedido 429
Pasaste el límite de peticiones por minuto o por mes de tu llave. Espera los segundos de la cabecera Retry-After y repite la petición.
servicio_no_disponible 503
No pudimos consultar el SAT en este momento. Repite la petición en unos minutos. La respuesta de los CFDI ya descargados no depende del SAT.
error_interno 500
Falló algo de nuestro lado. Repite la petición. Si sigue igual, escríbenos con la hora y la ruta.

Recibes

{
  "error": {
    "tipo": "rfc_invalido",
    "mensaje": "El RFC no tiene la forma de un RFC.",
    "parametro": "rfc",
    "ayuda": "https://dev.lagoontech.com.mx/docs/#errores-rfc_invalido"
  }
}

Versión y cambios

Esta es la versión 1. Agregar campos o rutas no cambia la versión: tu código debe ignorar los campos que no conoce. Un cambio que rompa algo sale en /v2/ y /v1/ sigue respondiendo.

23 de octubre de 2026: versión 1.0 con consultas, CFDI, uso y webhooks.