Llaves de prueba y producción
mcfdi_test_ hace las mismas validaciones y cálculos que mcfdi_live_, pero no timbra ni gasta timbres.
Una API REST en español para emitir, consultar, descargar y cancelar CFDI 4.0 desde tu punto de venta, tu tienda en línea o tu ERP. Pruebas sin timbrar, idempotencia y webhooks desde el primer día.
La API usa los mismos timbres que el panel y las pruebas con mcfdi_test_ no gastan timbres. Cuánto cuesta.
Pensada para integrarse bien
mcfdi_test_ hace las mismas validaciones y cálculos que mcfdi_live_, pero no timbra ni gasta timbres.
Si repites la petición con la misma llave recibes la misma respuesta. Un reintento nunca timbra dos veces.
cfdi.timbrado, cfdi.cancelacion y más, firmados con HMAC-SHA256 y con reintentos automáticos.
Cada respuesta trae los enlaces al XML timbrado y al PDF con el formato de la empresa.
Motivos 01 a 04, con folio de sustitución en el 01, y el estatus del SAT en la misma respuesta.
application/problem+json (RFC 9457) con un código estable y los campos que fallaron, en español.
Ejemplo
Una factura global a público en general por $1.00 más IVA. Con una llave de pruebas no se timbra.
cURL
# Con una llave mcfdi_test_ se valida todo y no se timbra (estado «simulado»).
curl https://api.micfdi.com.mx/v1/cfdi \
-H "Authorization: Bearer $MICFDI_LLAVE" \
-H "Idempotency-Key: venta-mostrador-2026-10-12" \
-H "Content-Type: application/json" \
-d '{
"tipo": "factura",
"formaPago": "01",
"metodoPago": "PUE",
"receptor": {
"rfc": "XAXX010101000",
"nombre": "PUBLICO EN GENERAL",
"regimenFiscal": "616",
"usoCfdi": "S01"
},
"informacionGlobal": { "periodicidad": "01", "meses": "10", "anio": 2026 },
"conceptos": [{
"claveProdServ": "01010101",
"claveUnidad": "ACT",
"unidad": "Actividad",
"descripcion": "Venta",
"cantidad": 1,
"valorUnitario": 1.00,
"objetoImp": "02",
"impuestos": { "traslados": [{ "impuesto": "002", "tipoFactor": "Tasa", "tasaOCuota": 0.16 }] }
}]
}'
C#
using System.Net.Http.Json;
using System.Text.Json;
// La llave vive en una variable de entorno o en tu bóveda, nunca en el código.
using var http = new HttpClient { BaseAddress = new Uri("https://api.micfdi.com.mx") };
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("MICFDI_LLAVE"));
var solicitud = new HttpRequestMessage(HttpMethod.Post, "/v1/cfdi")
{
Content = JsonContent.Create(new
{
tipo = "factura",
formaPago = "01",
metodoPago = "PUE",
receptor = new { rfc = "XAXX010101000", nombre = "PUBLICO EN GENERAL", regimenFiscal = "616", usoCfdi = "S01" },
informacionGlobal = new { periodicidad = "01", meses = "10", anio = 2026 },
conceptos = new[]
{
new
{
claveProdServ = "01010101", claveUnidad = "ACT", unidad = "Actividad", descripcion = "Venta",
cantidad = 1, valorUnitario = 1.00m, objetoImp = "02",
impuestos = new { traslados = new[] { new { impuesto = "002", tipoFactor = "Tasa", tasaOCuota = 0.16m } } },
},
},
}),
};
// Ante un timeout o un 5xx, reintenta con la MISMA llave: nunca se timbra dos veces.
solicitud.Headers.Add("Idempotency-Key", "venta-mostrador-2026-10-12");
using HttpResponseMessage respuesta = await http.SendAsync(solicitud);
JsonElement cfdi = await respuesta.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"{cfdi.GetProperty("estado")} {cfdi.GetProperty("uuid")}");
JavaScript
// Node.js 18 o más reciente. Usa la llave solo en tu servidor, nunca en el navegador.
const respuesta = await fetch('https://api.micfdi.com.mx/v1/cfdi', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MICFDI_LLAVE}`,
'Idempotency-Key': 'venta-mostrador-2026-10-12',
'Content-Type': 'application/json'
},
body: JSON.stringify({
tipo: 'factura',
formaPago: '01',
metodoPago: 'PUE',
receptor: { rfc: 'XAXX010101000', nombre: 'PUBLICO EN GENERAL', regimenFiscal: '616', usoCfdi: 'S01' },
informacionGlobal: { periodicidad: '01', meses: '10', anio: 2026 },
conceptos: [{
claveProdServ: '01010101', claveUnidad: 'ACT', unidad: 'Actividad', descripcion: 'Venta',
cantidad: 1, valorUnitario: 1.00, objetoImp: '02',
impuestos: { traslados: [{ impuesto: '002', tipoFactor: 'Tasa', tasaOCuota: 0.16 }] }
}]
})
});
const cfdi = await respuesta.json();
console.log(cfdi.estado, cfdi.uuid);
{
"modo": "test",
"estado": "simulado",
"tipo": "factura",
"serie": "A",
"receptor": { "rfc": "XAXX010101000", "nombre": "PUBLICO EN GENERAL" },
"moneda": "MXN",
"subtotal": 1.00,
"impuestosTrasladados": 0.16,
"total": 1.16,
"uuid": "00000000-7E57-…",
"enlaces": { "self": "…/v1/cfdi/00000000-7E57-…", "solicitud": "…/v1/solicitudes/…" }
}
Referencia
La referencia completa, con cada campo, sus reglas y sus errores, está en la documentación interactiva.
Descargar la especificación OpenAPI (JSON)
Con ella generas tu cliente o una colección de Postman, Insomnia o Bruno (todos importan OpenAPI).
| Método | Ruta | Qué hace |
|---|---|---|
| POST | /v1/cfdi | Emite un CFDI: factura, nota de crédito, honorarios o complemento de pago. Pide Idempotency-Key. |
| GET | /v1/cfdi | Lista los CFDI del emisor con filtros por fecha, receptor, tipo, estado y referencia. |
| GET | /v1/cfdi/{uuid} | Detalle del CFDI. |
| GET | /v1/cfdi/{uuid}/xml · /pdf | Descarga el XML timbrado o el PDF. |
| GET | /v1/cfdi/{uuid}/estatus-sat | Estatus real en el SAT. |
| POST | /v1/cfdi/{uuid}/cancelacion | Cancela con motivo 01 a 04 (01 con folio de sustitución). |
| POST | /v1/cfdi/{uuid}/correo | Envía el CFDI por correo. |
| GET | /v1/solicitudes/{id} | Estado de una emisión que respondió 202 (verificando): pasa a timbrado o incierto. |
| GET | /v1/emisor | Datos del emisor: sucursales, CSD, series y timbres disponibles. |
| GET | /v1/catalogos · /{nombre} | Catálogos del SAT: usos del CFDI, formas de pago, regímenes y más. |
| POST | /v1/webhooks | Registra un webhook firmado (cfdi.timbrado, cfdi.cancelacion…). |
| GET | /v1/webhooks | Lista tus webhooks. |
| DELETE | /v1/webhooks/{id} | Borra un webhook. |
| POST | /v1/webhooks/{id}/prueba | Manda un evento webhook.prueba para probar tu receptor. |
Precio
Lo mismo que en el panel: cada CFDI que timbras por API usa un timbre del saldo de la empresa emisora, y las pruebas con mcfdi_test_ no gastan timbres.
| Opción | Compras | Por timbre |
|---|---|---|
| Básico | 100 timbres por $350 *más IVA | $3.50 *más IVA |
| Profesional | 250 timbres por $820 *más IVA | $3.28 *más IVA |
| 1,000 timbres | 1,000 timbres por $2,500 *más IVA | $2.50 *más IVA |
| Prepago a la medida | De 50 a 5,000 timbres en una compra | De $5.00 *más IVA a $1.00 *más IVA |
| Ilimitado | $4,800 *más IVA al año, por sucursal | Sin contar timbres durante su vigencia |
Webhooks
Cada evento llega con la cabecera MiCFDI-Firma: t=…,v1=…, donde v1 es el HMAC-SHA256 en hexadecimal de t + "." + cuerpo con tu secreto whsec_…. Usa el cuerpo tal como llegó.
C#
using System.Security.Cryptography;
using System.Text;
static bool FirmaValida(string cabecera, string secreto, string cuerpo, TimeSpan tolerancia)
{
string? t = null;
var firmas = new List<string>();
foreach (string parte in cabecera.Split(','))
{
string[] kv = parte.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") t = kv[1];
else if (kv[0] == "v1") firmas.Add(kv[1]);
}
if (t is null || !long.TryParse(t, out long marca)) return false;
// Anti-repetición: rechaza firmas viejas.
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - marca) > tolerancia.TotalSeconds) return false;
byte[] hmac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secreto), Encoding.UTF8.GetBytes(t + "." + cuerpo));
byte[] esperada = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac).ToLowerInvariant());
return firmas.Any(f => CryptographicOperations.FixedTimeEquals(esperada, Encoding.ASCII.GetBytes(f.ToLowerInvariant())));
}
// ASP.NET Core: lee el cuerpo tal como llegó (no lo vuelvas a serializar).
app.MapPost("/webhooks/micfdi", async (HttpRequest req) =>
{
using var lector = new StreamReader(req.Body, Encoding.UTF8);
string cuerpo = await lector.ReadToEndAsync();
if (!FirmaValida(req.Headers["MiCFDI-Firma"].ToString(), Environment.GetEnvironmentVariable("MICFDI_WHSEC")!, cuerpo, TimeSpan.FromMinutes(5)))
return Results.Unauthorized();
using var evento = System.Text.Json.JsonDocument.Parse(cuerpo);
// Nunca proceses un evento de pruebas como real.
if (evento.RootElement.GetProperty("modo").GetString() != "live") return Results.Ok();
// … procesa el evento (idempotente por "id") …
return Results.Ok();
});
Node.js
// Node.js 18 o más reciente, con Express. express.raw entrega el cuerpo tal como llegó.
import crypto from 'node:crypto';
import express from 'express';
function firmaValida(cabecera, secreto, cuerpo, toleranciaSegundos = 300) {
let t = null;
const firmas = [];
for (const parte of String(cabecera || '').split(',')) {
const [clave, valor] = parte.trim().split('=');
if (clave === 't') t = valor;
else if (clave === 'v1' && valor) firmas.push(valor.toLowerCase());
}
// Anti-repetición: rechaza firmas viejas.
if (!/^\d+$/.test(t || '') || Math.abs(Date.now() / 1000 - Number(t)) > toleranciaSegundos) return false;
const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpo}`).digest('hex');
return firmas.some(f => f.length === esperada.length &&
crypto.timingSafeEqual(Buffer.from(f), Buffer.from(esperada)));
}
const app = express();
app.post('/webhooks/micfdi', express.raw({ type: 'application/json' }), (req, res) => {
const cuerpo = req.body.toString('utf8');
if (!firmaValida(req.get('MiCFDI-Firma'), process.env.MICFDI_WHSEC, cuerpo)) return res.sendStatus(401);
const evento = JSON.parse(cuerpo);
// Nunca proceses un evento de pruebas como real.
if (evento.modo !== 'live') return res.sendStatus(200);
// … procesa el evento (idempotente por evento.id) …
res.sendStatus(200);
});
PHP
<?php
// PHP 7.4 o más reciente. Usa el cuerpo tal como llegó (php://input): no lo vuelvas a serializar.
function firmaValida(string $cabecera, string $secreto, string $cuerpo, int $tolerancia = 300): bool
{
$t = null;
$firmas = [];
foreach (explode(',', $cabecera) as $parte) {
$kv = explode('=', trim($parte), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = $kv[1];
elseif ($kv[0] === 'v1') $firmas[] = strtolower($kv[1]);
}
// Anti-repetición: rechaza firmas viejas.
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerancia) return false;
$esperada = hash_hmac('sha256', $t . '.' . $cuerpo, $secreto);
foreach ($firmas as $f) {
if (hash_equals($esperada, $f)) return true;
}
return false;
}
$cuerpo = file_get_contents('php://input');
if (!firmaValida($_SERVER['HTTP_MICFDI_FIRMA'] ?? '', getenv('MICFDI_WHSEC'), $cuerpo)) {
http_response_code(401);
exit;
}
$evento = json_decode($cuerpo, true);
// Nunca proceses un evento de pruebas como real.
if (($evento['modo'] ?? '') !== 'live') { http_response_code(200); exit; }
// … procesa el evento (idempotente por $evento['id']) …
http_response_code(200);
Errores
Todo error es application/problem+json con un codigo estable. La lista completa está en la documentación.
| Status | Código | Qué hacer |
|---|---|---|
| 401 | llave_invalida | Revisa la llave y la cabecera Authorization: Bearer. |
| 402 | sin_timbres | El emisor debe comprar timbres. La Idempotency-Key queda libre. |
| 409 | solicitud_en_proceso | Espera lo que diga Retry-After y repite con la misma Idempotency-Key. |
| 422 | validacion | Corrige los campos que vienen en «errores». La Idempotency-Key queda libre. |
| 422 | comprobante_rechazado | El SAT o el servicio de timbrado lo rechazó (el detail trae el código, p. ej. CFDI40145). Corrige y usa otra llave. |
| 422 | sin_csd_vigente · sin_folios | Configuración del emisor: súbela o amplíala en el panel. |
| 429 | demasiadas_peticiones | Espera lo que diga Retry-After. |
| 503 | ocupado · sat_no_disponible | Reintenta más tarde; al emitir, con la misma Idempotency-Key. |
Cómo empezar
Ligamos tu integración a la empresa emisora y te damos una llave de pruebas y una de producción. Te respondemos en menos de un día hábil.
Con mcfdi_test_ validas tus datos, el cálculo de impuestos y tus webhooks sin gastar timbres.
Cambia a tu llave mcfdi_live_ y timbra CFDI reales. Revisa el estado del servicio cuando lo necesites.