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.
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.
| Nombre | Tipo | Descripción |
|---|---|---|
rfc | string | RFC del contribuyente, de 12 o 13 caracteres. |
desde | string AAAA-MM-DD | Primer día del periodo, AAAA-MM-DD. |
hasta | string AAAA-MM-DD | Último día del periodo, AAAA-MM-DD. El periodo no pasa de 12 meses. |
rol | emitidos | recibidos | ambos | Qué comprobantes: emitidos, recibidos o ambos. |
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.
| Nombre | Tipo | Descripción |
|---|---|---|
id | string | Identificador de la consulta. |
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.
| Nombre | Tipo | Descripción |
|---|---|---|
rfc | string | RFC del contribuyente, de 12 o 13 caracteres. |
desde | string AAAA-MM-DD | Primer día del periodo, AAAA-MM-DD. |
hasta | string AAAA-MM-DD | Último día del periodo, AAAA-MM-DD. El periodo no pasa de 12 meses. |
rol | emitidos | recibidos | ambos | Qué comprobantes: emitidos, recibidos o ambos. |
cursor | string | Valor de siguiente_cursor de la página anterior. |
limite | integer | Cuántos CFDI por página, de 1 a 200. |
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.
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`.
| Nombre | Tipo | Descripción |
|---|---|---|
url | string | URL https pública que recibe los avisos. |
eventos | array | Eventos que quieres recibir. |
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- La petición no trae la cabecera Authorization con una llave. Manda Authorization: Bearer y tu llave en cada petición.
llave_invalida- 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- La llave es de solo lectura y esta operación escribe. Usa una llave con ámbito completo.
rfc_no_permitido- La llave no tiene permiso para el RFC que pediste. Pide solo los RFC de tu llave. Los ves en tu cuenta.
parametro_invalido- 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- 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- Una fecha no existe o no está en formato AAAA-MM-DD. Manda la fecha completa, por ejemplo 2026-10-31.
periodo_invalido- 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- 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- 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- No hay una consulta con ese identificador en tu cuenta. Revisa el identificador. Solo ves las consultas hechas con tus llaves.
ruta_no_encontrada- Esa ruta no existe en la versión 1. Revisa la ruta contra la referencia.
metodo_no_permitido- La ruta existe pero no acepta ese método. Usa el método que indica la referencia.
rfc_no_disponible- 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- 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- 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- 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- 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.