Cómo timbrar un CFDI completo
Conecta una venta de tu sistema con un CFDI de ingreso: guarda al cliente, crea un borrador, agrega sus conceptos y confirma el timbrado. Al terminar tendrás el ID de FactuCat, el UUID fiscal y los archivos de la factura.
El recorrido es: cuenta preparada → cliente → borrador → conceptos → revisión → timbrado → archivos. Cada paso usa datos guardados en el anterior. Ejecuta los bloques en orden y continúa únicamente cuando la solicitud termine correctamente.
Antes de empezar
Sección titulada «Antes de empezar»Empieza en sandbox (sin tarjeta ni CSD propio)
Sección titulada «Empieza en sandbox (sin tarjeta ni CSD propio)»Para este recorrido usa el ambiente de pruebas. El emisor es tu cuenta; el receptor es el cliente al que facturas. En sandbox no necesitas CSD propio, plan de pago ni tarjeta para el primer timbrado de prueba.
- Regístrate en sandbox.factucat.com.
- Crea una llave
fc_test_...en Configuración → API keys concustomer:read,customer:write,invoice:read,invoice:writeeinvoice:stamp. - Sandbox precarga el CSD de prueba del SAT EKU9003173C9 (persona moral, régimen
601) cuando la cuenta aún no tiene CSD. Los datos fiscales de ese CSD compartido son de solo lectura en sandbox.
No necesitas enviar el CSD ni dar de alta un emisor en cada solicitud. La API pública actual no tiene endpoints para cargar CSD o configurar emisores. La presentación del PDF también se configura en la webapp.
Cuando el flujo de prueba funcione de punta a punta, pasa a producción con tu propio emisor (sección al final de esta guía).
Prepara los datos de la venta
Sección titulada «Prepara los datos de la venta»Necesitas los datos fiscales del receptor, el uso de CFDI que solicita, qué vendiste, cantidad, precio sin impuestos, clasificación SAT, impuestos aplicables y cómo se pagó. No necesitas crear un producto en FactuCat: los conceptos se agregan directamente al borrador.
El ejemplo ilustra un servicio nacional de programación de aplicaciones por $1,000 MXN antes de IVA, pagado totalmente por transferencia al facturar. Supone un emisor persona moral del régimen general y una operación con IVA del 16%, sin retenciones, cuyo receptor solicita G03. Si tu operación es distinta, ajusta los datos fiscales y los impuestos antes de timbrar.
Elige cURL, Node.js, Python o Go en cualquier ejemplo. La selección se mantiene en los demás pasos y guías. Cada bloque es un programa completo: guárdalo y ejecútalo por separado, siempre desde el mismo directorio que los archivos JSON. No pegues varios programas Go en un mismo archivo.
| Cliente | Requisito | Cómo ejecutar cada bloque |
|---|---|---|
| cURL | cURL y jq |
sh ejemplo.sh |
| Node.js | Node.js 22+ con fetch nativo |
node ejemplo.mjs |
| Python | Python 3.10+, biblioteca estándar | python3 ejemplo.py |
| Go | Go 1.22+, biblioteca estándar | go run ejemplo.go |
Para probar el recorrido, prepara tu cuenta en sandbox y guarda su llave fc_test_... en la variable de entorno FACTUCAT_API_KEY de tu servidor. Antes de ejecutar los programas, define la URL de pruebas en la terminal:
export FACTUCAT_API_URL='https://sandbox.factucat.com/api/v1'Los programas guardan las respuestas de cliente y borrador en archivos JSON; los pasos siguientes leen sus IDs automáticamente (UUID ya URL-safe). No hay reintentos automáticos. Si falla una solicitud, revisa el error y la sección de recuperación antes de continuar. En Node.js también puedes usar el SDK de FactuCat.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"
curl --fail-with-body --request GET \ --url "$FACTUCAT_API_URL/me" \ --header "X-API-Key: $FACTUCAT_API_KEY"const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const response = await fetch(`${baseUrl}/me`, { method: "GET", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, },})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)console.log(JSON.parse(text))import jsonimport osimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")request = urllib.request.Request( f"{base_url}/me", method="GET", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], },)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorprint(json.loads(text))package main
import ( "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } request, err := http.NewRequest("GET", baseURL + "/me", nil) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } fmt.Print(string(text))}Un 200 comprueba la conexión y muestra los permisos. En sandbox el CSD de prueba ya está disponible; en producción un 200 no valida por sí solo que tu CSD y datos fiscales estén listos para timbrar.
La URL anterior es sandbox: los comprobantes de prueba no tienen validez fiscal ni generan cargos por timbres. El checklist de producción (CSD propio, plan y tarjeta) va al final de esta guía.
1. Guarda o recupera al cliente
Sección titulada «1. Guarda o recupera al cliente»Si tu sistema ya guardó el customerId de este cliente, reutilízalo y pasa al siguiente paso. No crees un cliente por cada venta. Crear y reutilizar clientes explica cómo localizarlo si no tienes su ID.
Para uno nuevo, prepara cliente.json. Estos valores son ficticios y no sirven para timbrar: reemplázalos por los datos reales de tu cliente, tal como están registrados ante el SAT.
{ "legalName": "EMPRESA DE EJEMPLO", "rfc": "AAA010101AAA", "postalCode": "06000", "taxRegime": "601", "sendInvoices": false, "invoiceLanguage": "es", "currency": "MXN"}RFC, nombre, régimen y código postal son datos requeridos del receptor en CFDI 4.0. Consulta los requisitos del SAT.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"
curl --fail-with-body --request POST \ --url "$FACTUCAT_API_URL/customers" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --header 'Content-Type: application/json' \ --data @'cliente.json' \ --output 'cliente-respuesta.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const body = await readFile("cliente.json", 'utf8')const response = await fetch(`${baseUrl}/customers`, { method: "POST", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, 'Content-Type': 'application/json', }, body,})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("cliente-respuesta.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")body = Path("cliente.json").read_bytes()request = urllib.request.Request( f"{base_url}/customers", method="POST", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], "Content-Type": "application/json", }, data=body,)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("cliente-respuesta.json").write_text(text, encoding="utf-8")package main
import ( "bytes" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } body, _ := os.ReadFile("cliente.json") request, err := http.NewRequest("POST", baseURL + "/customers", bytes.NewReader(body)) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("cliente-respuesta.json", text, 0600)}La respuesta 201 devuelve el cliente en data. Guarda data.id junto al ID de ese cliente en tu base de datos. El ejemplo conserva esta respuesta en cliente-respuesta.json. Crear el registro no certifica sus datos ante el SAT ni emite una factura.
En este recorrido tu integración entregará los archivos. Por eso se usa sendInvoices: false y más adelante se desactiva explícitamente el envío al timbrar.
2. Crea el borrador con ese cliente
Sección titulada «2. Crea el borrador con ese cliente»El programa lee data.id de cliente-respuesta.json y lo envía como customerId. Si reutilizas un cliente, consulta y guarda primero su respuesta en ese mismo archivo.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"CUSTOMER_ID="$(jq -er '.data.id' 'cliente-respuesta.json')"
curl --fail-with-body --request POST \ --url "$FACTUCAT_API_URL/invoice-drafts" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --header 'Content-Type: application/json' \ --data "{\"currency\":\"MXN\",\"cfdiUse\":\"G03\",\"exportacion\":\"01\",\"customerId\":\"$CUSTOMER_ID\"}" \ --output 'borrador-respuesta.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const customerId = JSON.parse(await readFile("cliente-respuesta.json", 'utf8')).data.idconst body = JSON.stringify({ currency: "MXN", cfdiUse: "G03", exportacion: "01", customerId })const response = await fetch(`${baseUrl}/invoice-drafts`, { method: "POST", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, 'Content-Type': 'application/json', }, body,})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("borrador-respuesta.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")customerId = json.loads(Path("cliente-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]body = json.dumps({"currency": "MXN", "cfdiUse": "G03", "exportacion": "01", "customerId": customerId}).encode("utf-8")request = urllib.request.Request( f"{base_url}/invoice-drafts", method="POST", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], "Content-Type": "application/json", }, data=body,)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("borrador-respuesta.json").write_text(text, encoding="utf-8")package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } customerIdJSON, _ := os.ReadFile("cliente-respuesta.json") var customerIdResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(customerIdJSON, &customerIdResponse) customerId := customerIdResponse.Data.ID body, _ := json.Marshal(map[string]any{ "currency": "MXN", "cfdiUse": "G03", "exportacion": "01", "customerId": customerId, }) request, err := http.NewRequest("POST", baseURL + "/invoice-drafts", bytes.NewReader(body)) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("borrador-respuesta.json", text, 0600)}El 201 devuelve data.id y data.status: "draft". FactuCat copia los datos del cliente al receptor del borrador. Comprueba también data.customerId y data.receiverRfc para confirmar que vinculaste al cliente correcto.
Guarda data.id junto a tu venta antes de continuar. Los ejemplos lo conservan en borrador-respuesta.json y lo reutilizan en cada paso. Será el mismo ID después del timbrado. El borrador todavía no tiene UUID fiscal ni genera un timbre.
3. Define cómo se pagó
Sección titulada «3. Define cómo se pagó»Guarda estos datos en comprobante.json para el caso de esta guía:
{ "cfdiUse": "G03", "exportacion": "01", "paymentMethod": "PUE", "paymentForm": "03", "currency": "MXN"}paymentMethod describe cuándo se paga; paymentForm, cómo. Aquí PUE corresponde al pago total al facturar y 03 a transferencia. G03 es el uso solicitado por el receptor; 01 indica que no aplica exportación. El uso debe ser compatible con su régimen fiscal. Orientación del SAT sobre forma y método de pago.
Si tu venta sigue pendiente de pago, este ejemplo de PUE no corresponde. Un flujo de pago diferido necesita su tratamiento fiscal y complementos de pago; la API pública actual no expone la emisión de esos complementos.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request PATCH \ --url "$FACTUCAT_API_URL/invoice-drafts/$ID/meta" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --header 'Content-Type: application/json' \ --data @'comprobante.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst body = await readFile("comprobante.json", 'utf8')const response = await fetch(`${baseUrl}/invoice-drafts/${id}/meta`, { method: "PATCH", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, 'Content-Type': 'application/json', }, body,})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)console.log(JSON.parse(text))import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]body = Path("comprobante.json").read_bytes()request = urllib.request.Request( f"{base_url}/invoice-drafts/{id}/meta", method="PATCH", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], "Content-Type": "application/json", }, data=body,)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorprint(json.loads(text))package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID body, _ := os.ReadFile("comprobante.json") request, err := http.NewRequest("PATCH", baseURL + "/invoice-drafts/" + id + "/meta", bytes.NewReader(body)) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } fmt.Print(string(text))}El 200 devuelve el borrador actualizado. Envía estos campos explícitamente para que los datos de tu venta determinen el comprobante.
4. Agrega los conceptos
Sección titulada «4. Agrega los conceptos»Guarda el concepto en concepto.json. Para este servicio ilustrativo se usan 81111504 —servicios de programación de aplicaciones— y E48 —unidad de servicio—. Elige la clasificación que describa tu venta real: estas claves no son una configuración general para cualquier servicio.
{ "productCode": "81111504", "unitCode": "E48", "description": "Servicio de programación de aplicaciones", "quantity": 1, "unitPrice": 1000, "vatRate": 0.16, "isrWithholdingRate": 0}unitPrice es el precio antes de impuestos. Las tasas se expresan como fracciones: 0.16 significa 16%. Los impuestos explícitos corresponden al caso descrito; no los reutilices para operaciones con retenciones, exención u otro tratamiento.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request POST \ --url "$FACTUCAT_API_URL/invoice-drafts/$ID/items" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --header 'Content-Type: application/json' \ --data @'concepto.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst body = await readFile("concepto.json", 'utf8')const response = await fetch(`${baseUrl}/invoice-drafts/${id}/items`, { method: "POST", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, 'Content-Type': 'application/json', }, body,})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)console.log(JSON.parse(text))import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]body = Path("concepto.json").read_bytes()request = urllib.request.Request( f"{base_url}/invoice-drafts/{id}/items", method="POST", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], "Content-Type": "application/json", }, data=body,)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorprint(json.loads(text))package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID body, _ := os.ReadFile("concepto.json") request, err := http.NewRequest("POST", baseURL + "/invoice-drafts/" + id + "/items", bytes.NewReader(body)) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } fmt.Print(string(text))}La respuesta devuelve el borrador completo, con sus conceptos en data.items y los totales recalculados. Guarda el id de cada concepto si necesitas corregirlo. Para otra partida, envía otra solicitud con sus datos.
No envíes subtotal, taxTotal o total como campos editables: FactuCat los calcula. Los catálogos de productos y unidades no tienen endpoints en esta API; conserva las claves de tus productos en tu propio sistema.
5. Revisa la factura completa
Sección titulada «5. Revisa la factura completa»FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request GET \ --url "$FACTUCAT_API_URL/invoice-drafts/$ID" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --output 'revision.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst response = await fetch(`${baseUrl}/invoice-drafts/${id}`, { method: "GET", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, },})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("revision.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]request = urllib.request.Request( f"{base_url}/invoice-drafts/{id}", method="GET", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], },)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("revision.json").write_text(text, encoding="utf-8")package main
import ( "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID request, err := http.NewRequest("GET", baseURL + "/invoice-drafts/" + id, nil) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("revision.json", text, 0600)}Revisa data antes de avanzar:
| Parte del borrador | Qué confirmar |
|---|---|
| Receptor | receiverRfc, receiverLegalName, receiverPostalCode y receiverTaxRegime corresponden al cliente |
| Comprobante | cfdiUse, paymentMethod, paymentForm, currency y exportacion describen la venta |
| Conceptos | items contiene todas las partidas, con claves SAT, cantidad, precio e impuestos correctos |
| Totales | En este ejemplo: subtotal: 1000, taxTotal: 160, total: 1160 |
Esta consulta no es una validación previa ante el SAT. Las validaciones de emisión se ejecutan al timbrar.
Si necesitas corregir algo, modifica este mismo borrador:
- Receptor:
PATCH /invoice-drafts/{id}/receiver. Envía los camposreceiver*a corregir; cambiar solocustomerIden este endpoint no copia automáticamente los datos del nuevo cliente. - Comprobante:
PATCH /invoice-drafts/{id}/meta. - Concepto:
PATCH /invoice-drafts/{id}/items/{itemId}; para quitarlo,DELETEsobre esa ruta.
Usa una clave nueva para cada corrección y vuelve a consultar los totales. Los campos admitidos están en la referencia API.
6. Timbra el CFDI
Sección titulada «6. Timbra el CFDI»Con el borrador revisado, prepara timbrado.json. Estas opciones dejan la entrega de archivos a cargo de tu integración:
{ "skipIssuedEmailSend": true, "sendToCustomerContacts": false, "sendCustomerEmail": false, "sendCustomerWhatsApp": false}Fecha de emisión (opcional)
Sección titulada «Fecha de emisión (opcional)»Si el borrador no tiene issueDate y no lo envías al timbrar, el CFDI se emite con la fecha y hora del timbrado. Si el borrador ya tiene issueDate, timbrar sin el campo conserva esa fecha. Para emitirlo con una fecha anterior, envía issueDate (YYYY-MM-DD o datetime ISO) en POST /invoice-drafts, en PATCH /invoice-drafts/{id}/meta o en el cuerpo del timbrado (timbrado.json).
- El SAT permite que la fecha del CFDI (
Fecha) sea de hasta 72 horas antes del timbrado y nunca futura, contadas en la zona horaria del código postal de expedición (LugarExpedicion); lo valida el PAC según la regla 2.7.2.9, fracción I, de la Resolución Miscelánea Fiscal 2026. En la práctica puedes elegir hoy o hasta 3 días de calendario atrás, siempre que al timbrar no hayan pasado más de 72 horas. - Una fecha inválida o fuera de rango responde
400conerror.code: "INVALID_ISSUE_DATE". - La fecha se valida otra vez al timbrar. La hora del CFDI es la del timbrado, recorrida al día elegido; en el tercer día hacia atrás se ajusta a 71 h 50 min antes del timbrado (reservamos 10 minutos por el reloj del PAC), así que ese día deja de aceptarse en los últimos 10 minutos de cada día.
- En el SDK es el mismo campo:
createDraft,setDraftMetaystampDraftaceptanissueDate. En el CLI usa--fecha(ver CLI). En el MCP,draft_create,draft_set_metaeinvoice_stampaceptanissue_date, y la previa deinvoice_stampmuestraissue_date,fechaytime_zone.
Este paso emite el CFDI y genera consumo en producción. Conserva el ID del borrador antes de enviarlo.
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request POST \ --url "$FACTUCAT_API_URL/invoice-drafts/$ID/stamp" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --header 'Content-Type: application/json' \ --data @'timbrado.json' \ --output 'timbrado-respuesta.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst body = await readFile("timbrado.json", 'utf8')const response = await fetch(`${baseUrl}/invoice-drafts/${id}/stamp`, { method: "POST", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, 'Content-Type': 'application/json', }, body,})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("timbrado-respuesta.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]body = Path("timbrado.json").read_bytes()request = urllib.request.Request( f"{base_url}/invoice-drafts/{id}/stamp", method="POST", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], "Content-Type": "application/json", }, data=body,)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("timbrado-respuesta.json").write_text(text, encoding="utf-8")package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID body, _ := os.ReadFile("timbrado.json") request, err := http.NewRequest("POST", baseURL + "/invoice-drafts/" + id + "/stamp", bytes.NewReader(body)) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("timbrado-respuesta.json", text, 0600)}Confirma data.status: "issued" y data.satUuid presente en la respuesta exitosa. Guarda data.id, data.satUuid y data.issuedAt: id se usa en las rutas de FactuCat y satUuid identifica el CFDI ante el SAT.
La factura emitida se consulta en GET /invoices/{id}. El endpoint de borradores deja de devolverla una vez emitida; un 404 allí no significa que se perdió.
7. Descarga y entrega XML y PDF
Sección titulada «7. Descarga y entrega XML y PDF»Descarga primero el XML:
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request GET \ --url "$FACTUCAT_API_URL/invoices/$ID/xml" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --output 'xml-respuesta.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst response = await fetch(`${baseUrl}/invoices/${id}/xml`, { method: "GET", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, },})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("xml-respuesta.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]request = urllib.request.Request( f"{base_url}/invoices/{id}/xml", method="GET", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], },)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("xml-respuesta.json").write_text(text, encoding="utf-8")package main
import ( "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID request, err := http.NewRequest("GET", baseURL + "/invoices/" + id + "/xml", nil) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("xml-respuesta.json", text, 0600)}Luego, su representación en PDF:
FACTUCAT_API_URL="${FACTUCAT_API_URL:-https://sandbox.factucat.com/api/v1}"ID="$(jq -er '.data.id' 'borrador-respuesta.json')"
curl --fail-with-body --request GET \ --url "$FACTUCAT_API_URL/invoices/$ID/pdf" \ --header "X-API-Key: $FACTUCAT_API_KEY" \ --output 'pdf-respuesta.json'import { readFile, writeFile } from 'node:fs/promises'
const baseUrl = process.env.FACTUCAT_API_URL || "https://sandbox.factucat.com/api/v1"const id = JSON.parse(await readFile("borrador-respuesta.json", 'utf8')).data.idconst response = await fetch(`${baseUrl}/invoices/${id}/pdf`, { method: "GET", headers: { 'X-API-Key': process.env.FACTUCAT_API_KEY, },})const text = await response.text()if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)await writeFile("pdf-respuesta.json", text)import jsonimport osfrom pathlib import Pathimport urllib.errorimport urllib.request
base_url = os.environ.get("FACTUCAT_API_URL", "https://sandbox.factucat.com/api/v1")id = json.loads(Path("borrador-respuesta.json").read_text(encoding="utf-8"))["data"]["id"]request = urllib.request.Request( f"{base_url}/invoices/{id}/pdf", method="GET", headers={ "X-API-Key": os.environ["FACTUCAT_API_KEY"], },)try: with urllib.request.urlopen(request) as response: text = response.read().decode("utf-8")except urllib.error.HTTPError as error: raise RuntimeError(f"HTTP {error.code}: {error.read().decode('utf-8')}") from errorPath("pdf-respuesta.json").write_text(text, encoding="utf-8")package main
import ( "encoding/json" "fmt" "io" "net/http" "os")
func main() { baseURL := os.Getenv("FACTUCAT_API_URL") if baseURL == "" { baseURL = "https://sandbox.factucat.com/api/v1" } idJSON, _ := os.ReadFile("borrador-respuesta.json") var idResponse struct { Data struct { ID string `json:"id"` } `json:"data"` } _ = json.Unmarshal(idJSON, &idResponse) id := idResponse.Data.ID request, err := http.NewRequest("GET", baseURL + "/invoices/" + id + "/pdf", nil) if err != nil { panic(err) } request.Header.Set("X-API-Key", os.Getenv("FACTUCAT_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() text, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { panic(fmt.Sprintf("HTTP %d: %s", response.StatusCode, text)) } _ = os.WriteFile("pdf-respuesta.json", text, 0600)}Ambas respuestas son JSON con data.fileName, data.mimeType y data.base64. Decodifica los dos archivos de respuesta desde el mismo directorio. La opción cURL usa jq y la utilidad base64:
for extension in xml pdf; do jq -er '.data.base64' "$extension-respuesta.json" | base64 --decode > "factura.$extension"doneimport { readFile, writeFile } from 'node:fs/promises'
for (const extension of ['xml', 'pdf']) { const response = JSON.parse( await readFile(extension + '-respuesta.json', 'utf8'), ) await writeFile( 'factura.' + extension, Buffer.from(response.data.base64, 'base64'), )}import base64import jsonfrom pathlib import Path
for extension in ("xml", "pdf"): response = json.loads( Path(extension + "-respuesta.json").read_text(encoding="utf-8") ) Path("factura." + extension).write_bytes( base64.b64decode(response["data"]["base64"]) )package main
import ( "encoding/base64" "encoding/json" "os")
func main() { for _, extension := range []string{"xml", "pdf"} { var response struct { Data struct { Base64 string } } content, _ := os.ReadFile(extension + "-respuesta.json") _ = json.Unmarshal(content, &response) data, _ := base64.StdEncoding.DecodeString(response.Data.Base64) _ = os.WriteFile("factura." + extension, data, 0600) }}Pon ambos archivos a disposición del cliente desde tu sistema. El XML timbrado es el comprobante fiscal; el PDF es su representación. Si falla una descarga, repite ese GET, sin volver a timbrar. Más sobre archivos y cancelaciones.
Cuando pases a producción
Sección titulada «Cuando pases a producción»El checklist de emisor propio aplica después de validar el flujo en sandbox. En producción la cuenta corresponde a un solo RFC emisor y la preparación se hace en la webapp:
| Debes tener guardado | Dónde se configura | Para qué se usa |
|---|---|---|
| RFC, nombre fiscal, régimen y código postal del emisor | Configuración → Datos fiscales | Identificar al negocio que emite |
CSD vigente: .cer, .key y contraseña de la llave |
Configuración → Datos fiscales | Sellar los CFDI de ese mismo RFC |
| Plan FactuCat API o Miau Pro habilitado | Configuración → Suscripción | Acceder a la API; el plan API requiere tarjeta registrada |
API key fc_live_... de tu integración |
Configuración → API keys | Autenticar las solicitudes hacia producción |
Usa https://factucat.com/api/v1 y una llave fc_live_.... El timbrado en producción emite CFDI con validez fiscal y genera consumo. Consulta Ambientes y pruebas.
Qué guardar en tu sistema
Sección titulada «Qué guardar en tu sistema»| Referencia | Cuándo guardarla | Para qué te sirve |
|---|---|---|
Tu ID del cliente ↔ customerId |
Al crear o localizar al cliente | Reutilizarlo en otras ventas |
Tu ID de venta ↔ id del documento |
Al crear el borrador | Continuar el mismo flujo tras un reinicio |
| Clave de idempotencia, ruta y body de cada mutación | Antes de cada solicitud | Reintentar exactamente la misma operación |
satUuid, status e issuedAt |
Al confirmar la emisión | Registrar el resultado fiscal |
| XML y PDF | Después del timbrado | Entregarlos y conservarlos según tu operación |
meta.requestId o error.requestId |
En cada respuesta | Identificar una solicitud al investigar una falla |
Los archivos locales permiten seguir este tutorial. En tu integración, persiste estas referencias en tu base de datos; una variable de terminal no sobrevive a un reinicio.
Si un paso falla
Sección titulada «Si un paso falla»| Situación | Cómo continuar |
|---|---|
| 401 o 403 | Revisa llave, ambiente y permisos; en producción, también el acceso del plan |
| Cliente con RFC ya registrado | Localiza el registro y reutiliza su ID |
| Datos inválidos o CSD pendiente | Corrige la configuración o el mismo borrador; usa una clave nueva para la solicitud corregida |
| Timeout o conflicto al timbrar | Consulta GET /invoices/{id} con el ID guardado; no crees otro borrador |
Documento issued con satUuid |
Guarda la confirmación y continúa con los archivos |
| Timbrado pendiente o resultado desconocido | Conserva documento y evidencia; espera su conciliación antes de decidir un nuevo intento |
Idempotency-Key es opcional. Si la usas, reutilízala solo para la misma ruta, método y body. Un error de red no demuestra que no se timbró, y ver todavía un estado de borrador tampoco garantiza que el PAC haya rechazado la operación. Idempotencia y recuperación.