# TimbraMX API - Documentación completa para LLMs Base URL: https://app.timbra-mx.com/api/v1 Especificación machine-readable: https://timbra-mx.com/docs/api/openapi.yaml ## Información general | | | |---|---| | URL base (sandbox y producción) | `https://app.timbra-mx.com/api/v1` - la misma URL para ambos ambientes, el que cambia es el header `X-Api-Key`. | | Versión actual | v1 | | Autenticación | Header `X-Api-Key` (ver abajo) | | Formato de solicitudes | `application/json` en el body (excepto donde se indique lo contrario) | | Formato de respuestas | `application/json`, salvo `/xml` (`application/xml`) y `/pdf` (`application/pdf`), que regresan el archivo crudo | | Codificación | UTF-8 | | Zona horaria | Fechas en formato ISO 8601. Cuando no se manda zona horaria explícita, se interpreta en la zona horaria de Ciudad de México. | | HTTPS | Obligatorio - no hay endpoint sin TLS | | `Content-Type` requerido | `application/json` en toda petición con body | | Tamaño máximo de solicitud | 10 MB por petición | | Timeout recomendado del lado del cliente | 30 segundos - el timbrado real contra el PAC normalmente responde en 2-8 segundos, pero deja margen para reintentos de red del lado del PAC. Si tu petición hace timeout, no sabes si sí se completó - ver la sección de Idempotencia. | ## Tabla maestra de endpoints | Método | Endpoint | Descripción | |---|---|---| | POST | `/facturas/validar` | Validar un XML (modo relay) sin timbrar | | POST | `/facturas` | Timbrar un CFDI (modo relay o modo sellar) | | GET | `/facturas` | Listar facturas (paginado; filtros `estado`, `uuid`, `rfc`, `desde`/`hasta`) | | GET | `/facturas/saldo` | Consultar saldo de timbres | | GET | `/facturas/{id}` | Consultar una factura | | GET | `/facturas/{id}/xml` | Descargar el XML timbrado | | GET | `/facturas/{id}/pdf` | Descargar el PDF | | GET | `/facturas/{id}/acuse` | Acuse de cancelación | | GET | `/facturas/{id}/estatus-sat` | Estatus ante el SAT en vivo | | POST | `/facturas/{id}/cancelar` | Cancelar un CFDI | | GET | `/facturas/{id}/estatus-cancelacion` | Confirmar cancelación pendiente | | POST | `/facturas/{id}/nota-credito` | Generar nota de crédito | | POST | `/complementos-pago` | Timbrar complemento de pago | | GET | `/complementos-pago` | Listar complementos de pago (filtros `estado`, `uuid`, `rfc`, `desde`/`hasta`) | | GET | `/complementos-pago/{id}` | Consultar un complemento | | GET | `/complementos-pago/{id}/xml` | Descargar XML | | GET | `/complementos-pago/{id}/pdf` | Descargar PDF | | POST | `/complementos-pago/{id}/cancelar` | Cancelar | | GET | `/complementos-pago/{id}/estatus-cancelacion` | Confirmar cancelación pendiente | | POST | `/nomina` | Timbrar recibo de nómina | | GET | `/nomina` | Listar recibos de nómina (filtros `estado`, `uuid`, `rfc`, `desde`/`hasta`) | | GET | `/nomina/{id}` | Consultar un recibo | | GET | `/nomina/{id}/xml` | Descargar XML | | GET | `/nomina/{id}/pdf` | Descargar PDF | | POST | `/nomina/{id}/cancelar` | Cancelar | | GET | `/nomina/{id}/estatus-cancelacion` | Confirmar cancelación pendiente | | POST | `/retenciones` | Timbrar retención | | GET | `/retenciones` | Listar retenciones (filtros `tipo`, `estado`, `uuid`, `rfc`, `desde`/`hasta`) | | GET | `/retenciones/{id}` | Consultar una retención | | GET | `/retenciones/{id}/xml` | Descargar XML | | GET | `/retenciones/{id}/pdf` | Descargar PDF | | POST | `/retenciones/{id}/cancelar` | Cancelar | | GET | `/retenciones/{id}/estatus-cancelacion` | Confirmar cancelación pendiente | ## Configuración previa en el portal (antes de usar la API) La API nunca crea clientes, series ni sube tu CSD - todo eso se prepara una vez desde el portal web: 1. **Crea tu cuenta** en `https://app.timbra-mx.com/portal-login.html`. 2. **Sube tu CSD** (Mi empresa → CSD) - obligatorio para timbrar, tanto desde el portal como por API en modo sellar. En modo relay no hace falta (tú sellas con tu propio CSD). 3. **Da de alta al menos un cliente** (Mis clientes) por cada `receptorRfc` que vayas a usar - la API rechaza con 400 un RFC que no exista ya en tu catálogo. 4. **Crea una serie activa** por cada tipo de documento que vayas a timbrar (factura, nota de crédito, complemento de pago, nómina, retenciones) - la API rechaza con 400 si no hay ninguna serie activa de ese tipo. 5. **Activa el módulo de API** (contrátalo si tu cuenta no lo tiene) - sin esto, cualquier llamada regresa 403. 6. **Genera tu API key de sandbox** desde la vista "API" del portal - no requiere aprobación, disponible de inmediato. 7. **Certifica tu cuenta** (contacta a soporte) cuando tu integración funcione bien en sandbox - solo así se puede generar la API key de producción. ## Autenticación Header `X-Api-Key` en cada petición. Cada cuenta tiene DOS keys, generadas desde el portal (vista "API"): - **Sandbox**: pruebas, sin costo, sin valor fiscal, timbra siempre contra el PAC de pruebas. Vence automáticamente a los 90 días de GENERADA (no de inactividad - el reloj no se reinicia con el uso). Regenerarla desde el portal renueva la vigencia otros 90 días. - **Producción**: solo se puede generar cuando la cuenta está certificada (revisión manual de TimbraMX). Consume timbres reales. El ambiente (sandbox o producción) lo decide el servidor según contra cuál de las dos keys hizo match el header - la URL es siempre la misma. Sin header `X-Api-Key`: 401. Key inválida: 401. Key de sandbox expirada: 401. Cuenta sin el módulo de API contratado: 403. Key de producción sin certificación: 403. ## ¿Qué tan real es el sandbox? - **Sí timbra de verdad** contra el ambiente de pruebas del PAC (no es una simulación local) - los CFDI tienen estructura real y las mismas validaciones XSD/reglas de negocio que producción. - Los **UUID que regresa son reales** (generados por el ambiente de pruebas del PAC), pero **sin valor fiscal** - no existen ante el SAT real. - El **XML tiene la estructura completa real** del CFDI, sello y certificado incluidos (de prueba, no tu CSD de producción... salvo en modo relay, donde el sello es el que tú mismo generaste). - **Sí puedes probar cancelación** de punta a punta, incluyendo el flujo de "cancelación pendiente" con motivo 01/02/03/04. - **Sí puedes provocar errores reales del PAC** (502 con `codigo`/`detalle`) mandando datos que el PAC de pruebas rechace - útil para probar tu manejo de errores antes de ir a producción. - El **saldo de timbres que ves en `GET /facturas/saldo` es siempre el real de producción**, sin importar con cuál key consultes - sandbox nunca lo descuenta, pero el número mostrado no es "simulado". - La key de sandbox se genera libre (no requiere certificación) y **expira a los 90 días de inactividad** - regenerarla renueva la vigencia sin perder historial. | Característica | Sandbox | Producción | |---|---|---| | Timbra de verdad contra el PAC | Sí | Sí | | UUID | Real (generado por el PAC de pruebas) | Real, con valor fiscal | | Valor fiscal ante el SAT | No | Sí | | Consume timbres de tu saldo | No, nunca | Sí, 1 por timbrado exitoso | | Ambiente del PAC | Pruebas | Producción | | Requiere certificación previa | No | Sí (contacta a soporte) | | API key | Se genera libre desde el portal | Solo se genera si `apiCertificado = true` | | Vigencia de la key | 90 días de inactividad, se renueva sola al regenerarla | No expira por inactividad | ## Idempotencia Para reintentos seguros (ej. timeout de red), manda el header `Idempotency-Key` (o el campo `idempotency_key` en el body JSON), con cualquier string único de hasta 255 caracteres que tú generes. Si ya existe un documento tuyo con esa misma clave, el servidor regresa el comprobante original (200, con `"idempotente": true`) en vez de timbrar y cobrar otra vez. La clave es única por cuenta, no global. Aplica a los 5 endpoints de creación (`/facturas`, `/facturas/{id}/nota-credito`, `/complementos-pago`, `/nomina`, `/retenciones`). ### Lo que no es obvio de la implementación real (verificado en código, no supuesto) - **No expira.** La clave se guarda como índice único permanente ligado al documento (`clienteId` + `idempotencyKey`), no como un caché con TTL. Mientras ese documento exista, la clave sigue siendo válida para siempre - no hay una "ventana de idempotencia" después de la cual dejaría de funcionar. - **No compara el body.** Si reusas la misma `Idempotency-Key` con un body **distinto** al de la petición original, TimbraMX **no valida que coincidan** - simplemente ignora el body nuevo y regresa el documento original tal cual quedó la primera vez. Es tu responsabilidad, del lado de tu sistema, no reusar una clave para dos operaciones distintas. - **Es solo un candado de creación, no una caché de lectura.** Si el primer intento con esa clave falló antes de llegar a crear el registro (por ejemplo, un 400 por datos inválidos), no queda nada guardado con esa clave - puedes corregir el body y reenviar la misma clave sin problema. - **Qué pasa con dos peticiones verdaderamente simultáneas con la misma clave** (mismo milisegundo, antes de que la primera termine de escribir): es una ventana de carrera muy angosta y no probada exhaustivamente contra el motor actual - en el peor caso, la segunda petición puede toparse con un error de la base de datos en vez de la respuesta idempotente limpia. Si tu integración dispara reintentos en paralelo (no solo secuenciales), un pequeño backoff antes del segundo intento elimina este riesgo en la práctica. ### Cuándo reintentar (y cuándo no) | Código | ¿Reintentar? | Notas | |---|---|---| | 400 | ❌ No | El body está mal - corrige el dato que señala `error` antes de reintentar. | | 401 | ❌ No | Revisa el header `X-Api-Key` o regenera la key. | | 402 | ❌ No | Compra timbres primero. | | 403 | ❌ No | Módulo no contratado o key sin certificar - no es un problema temporal. | | 404 | ❌ No | El recurso no existe en ese ambiente/cuenta. | | 409 | ⏱️ Sí, tras unos segundos | Ya hay otra petición en curso sobre el MISMO documento (mismo `id`) - por ejemplo, dos llamadas casi simultáneas a `/cancelar` sobre la misma factura. Es un candado por registro, no una validación de que tu body sea distinto - espera y reintenta (no hace falta cambiar el Idempotency-Key). Dos creaciones con `Idempotency-Key` diferentes NUNCA chocan entre sí con un 409: cada una crea su propio documento. | | 429 | ✅ Sí, con backoff | Verificado en código: la respuesta trae los headers `RateLimit-Limit`, `RateLimit-Remaining` y `RateLimit-Reset` (este último en SEGUNDOS restantes de la ventana, no una fecha) - no hay header `Retry-After`. Usa `RateLimit-Reset` para calcular cuánto esperar. | | 502 | ✅ Sí, con el MISMO Idempotency-Key | El PAC rechazó o no respondió - si mandas la misma clave, un timbrado que en realidad sí se logró no se duplica ni se cobra dos veces. Nunca reintentes un 502 con una clave distinta o sin clave. | | Timeout de red (sin respuesta) | ✅ Sí, con el MISMO Idempotency-Key | No sabes si el timbrado sí se completó del lado del servidor - la idempotencia es justo para este caso. | ## POST /api/v1/facturas/validar - Validar un CFDI sin timbrarlo Solo modo relay (necesitas un XML ya armado para validar contra algo). Corre la misma validación que TimbraMX aplica internamente antes de timbrar en modo sellar: XSD oficial del SAT + reglas de negocio que el XSD no expresa pero que el PAC sí rechaza en tiempo real. No gasta ningún intento con el PAC ni consume timbres. Request: mismo body que modo relay, `{ "xml": "..." }` en base64. Response, siempre 200 (un XML inválido es un resultado de la validación, no un error de la petición): ```json { "ok": true, "data": { "valido": false, "errores": ["[VAL-04] Falta capturar \"RFC\" en Receptor."] } } ``` ## POST /api/v1/facturas - Timbrar un CFDI Dos modos, detectados automáticamente por la forma del body: ### Modo relay Tú ya armaste y sellaste el CFDI con tu propio CSD. Solo mandas el XML para que TimbraMX lo timbre ante el SAT. Request: ```json { "xml": "PHNmZGk6Q29tcHJvYmFudGUgeG1sbnM6Y2ZkaT0i..." } ``` (`xml` es el CFDI completo, ya sellado, codificado en base64.) Response 201: ```json { "ok": true, "data": { "id": "b1e2...", "estado": "timbrada", "uuidFiscal": "3f9a1c2e-....", "pac": "...", "serie": "F", "folio": "123", "total": 1160.00, "timbradaEn": "2026-09-15T18:22:03.000Z", "xml": "PHNmZGk6Q29tcHJvYmFudGUgeG1sbnM6dGZkPS..." } } ``` ### Diccionario de campos de la respuesta (`data`) Aplica al mismo `data` que regresan Facturas, Notas de crédito, Complementos de pago y Nómina (Retenciones es análogo, sin `serie`/`folio`/`total` propios de venta). | Campo | Tipo | Siempre presente | Descripción | |---|---|---|---| | `id` | string (UUID) | Sí | Id interno de TimbraMX - úsalo para `GET /:id`, `/xml`, `/pdf`, `/cancelar`. No es el UUID fiscal. | | `estado` | string | Sí | Ver la tabla de estados más abajo. | | `uuidFiscal` | string (UUID) | Solo si `estado` es `timbrada` o posterior | El UUID real del SAT - este es el que identifica el CFDI ante el mundo fiscal. | | `pac` | string | Solo si ya se intentó timbrar | Identificador interno del PAC que timbró (no lo uses para lógica de negocio, puede cambiar). | | `serie` / `folio` | string | Solo en Facturas/Notas de crédito/Complementos de pago/Nómina | La serie y folio asignados al timbrar (el folio no existe antes de timbrar). | | `total` | number | Solo en Facturas/Notas de crédito | Total del comprobante. | | `timbradaEn` | string (fecha-hora ISO) | Solo si `estado` es `timbrada` o posterior | Cuándo se completó el timbrado. | | `canceladaEn` | string (fecha-hora ISO) | Solo si `estado` es `cancelada` | Cuándo se confirmó la cancelación. | | `xml` | string (base64) | Solo si `estado` es `timbrada` o posterior | El CFDI completo timbrado, en base64 - decodifícalo para obtener el XML real. | | `errorCodigo` / `errorMensaje` | string | Solo si `estado` es `error` | El código y mensaje que dio el PAC en el intento fallido. | ### Modo sellar TimbraMX arma el XML, lo sella con el CSD que ya subiste al portal, y lo timbra. Solo CFDI de Ingreso simple en v1 (sin complementos como Carta Porte o Comercio Exterior - para eso usa el portal o modo relay). El receptor (por RFC) y la serie (por código) deben existir ya en tu cuenta. Request: ```json { "receptorRfc": "XAXX010101000", "serie": "F", "formaPago": "03", "metodoPago": "PUE", "moneda": "MXN", "conceptos": [ { "claveProdServ": "81112101", "claveUnidad": "E48", "descripcion": "Servicio de consultoría", "cantidad": 1, "valorUnitario": 1000, "impuestos": [ { "impuesto": "002", "categoria": "traslado", "tipoFactor": "Tasa", "tasaIva": 0.16 } ] } ] } ``` ### Contrato de campos - Modo sellar | Campo | Tipo | Obligatorio | Longitud/formato | Valores | Ejemplo | |---|---|---|---|---|---| | `receptorRfc` | string | Sí | 12 o 13 caracteres | RFC de un cliente ya dado de alta en "Mis clientes" | `XAXX010101000` | | `serie` | string | Sí | 1-50 | Código de una serie de facturas activa | `F` | | `formaPago` | string | No | 2 caracteres | Catálogo SAT c_FormaPago | `03` (transferencia) | | `metodoPago` | string | No (default `PUE`) | enum | `PUE`, `PPD` | `PUE` | | `moneda` | string | No (default `MXN`) | enum ISO 4217 | Catálogo SAT c_Moneda | `MXN` | | `conceptos` | array | Sí | 1 a 200 items | - | ver abajo | | `conceptos[].claveProdServ` | string | Sí | mínimo 6 caracteres | Catálogo SAT c_ClaveProdServ | `81112101` | | `conceptos[].claveUnidad` | string | Sí | mínimo 2 caracteres | Catálogo SAT c_ClaveUnidad | `E48` | | `conceptos[].descripcion` | string | Sí | máximo 1000 | texto libre | `Servicio de consultoría` | | `conceptos[].cantidad` | number | Sí | > 0 | - | `1` | | `conceptos[].valorUnitario` | number | Sí | >= 0 | - | `1000` | | `conceptos[].descuento` | number | No | >= 0 | - | `0` | | `conceptos[].impuestos` | array | No | máximo 10 items | - | ver abajo | | `conceptos[].impuestos[].impuesto` | string | No (default `002`) | enum | `001` ISR, `002` IVA, `003` IEPS (catálogo c_Impuesto) | `002` | | `conceptos[].impuestos[].categoria` | string | No (default `traslado`) | enum | `traslado`, `retencion` | `traslado` | | `conceptos[].impuestos[].tipoFactor` | string | No (default `Tasa`) | enum | `Tasa`, `Exento` | `Tasa` | | `conceptos[].impuestos[].tasaIva` | number | No | 0 a 1 | - | `0.16` | | `idempotency_key` | string | No | máximo 255 | alternativa al header `Idempotency-Key` | `pedido-8214` | Response: mismo formato 201/200 que modo relay. ### Tabla resumen de errores y qué hacer | Código | Significado | ¿Reintentar? | Acción recomendada | |---|---|---|---| | 400 | Datos inválidos | No | Lee `error` (dice exactamente qué campo falló) y corrige el body antes de reintentar. | | 401 | API key inválida o expirada | No | Revisa el header `X-Api-Key`; si era de sandbox y expiró, regenérala desde el portal. | | 402 | Sin timbres disponibles | No | Compra/recarga timbres desde el portal (Mis compras) antes de reintentar. | | 403 | Sin acceso a la API | No | Contrata el módulo de API, o certifica tu cuenta si intentabas usar la key de producción. | | 404 | Recurso inexistente | No | Revisa el `id` y que corresponda al mismo ambiente (sandbox/producción) de tu key. | | 409 | Operación concurrente | Sí, tras unos segundos | Espera y usa `GET /:id` para ver en qué quedó antes de reintentar la acción. | | 429 | Límite de peticiones excedido | Sí, con backoff | Espera unos segundos (ver tabla de reintentos) y reintenta. | | 502 | El PAC rechazó el CFDI/cancelación | Sí, con el MISMO `Idempotency-Key` | Lee `codigo`/`detalle` (la razón real del PAC) para saber qué corregir; si el dato estaba bien, reintenta con la misma clave de idempotencia. | ### Ejemplos reales de respuesta 400 - dato inválido (el mensaje siempre dice exactamente cuál campo): ```json { "ok": false, "error": "No existe un cliente activo con RFC \"XAXX010101000\". Créalo primero en Mis clientes desde el portal." } ``` 401 - sin key, key inválida, o sandbox expirada: ```json { "ok": false, "error": "API key invalida." } ``` 402 - sin timbres (solo producción; sandbox nunca cobra): ```json { "ok": false, "error": "No tienes timbres disponibles. Compra mas timbres para poder timbrar." } ``` 403 - módulo no contratado, o key de producción sin certificar: ```json { "ok": false, "error": "Tu cuenta todavia no esta certificada para produccion. Prueba primero con tu API key de sandbox." } ``` 429 - límite de 60 peticiones/min excedido: ```json { "ok": false, "error": "Demasiadas solicitudes. Intenta de nuevo en unos minutos." } ``` 502 - el PAC rechazó el timbrado (`codigo` y `detalle` son la razón real reportada por el PAC, no genérica de TimbraMX): ```json { "ok": false, "error": "El PAC rechazo el CFDI", "codigo": "CFDI40145", "detalle": "El atributo FormaPago no cumple con la condicion establecida." } ``` ## GET /api/v1/facturas - Listar tus facturas Query params: `pagina` (default 1), `limite` (default 20, máx 100), `estado` (borrador/error/timbrada/cancelacion_pendiente/cancelada), `uuid` (búsqueda parcial sobre el UUID fiscal), `rfc` (búsqueda parcial sobre el RFC del receptor), `desde`/`hasta` (rango de fecha de creación, formato YYYY-MM-DD). Solo regresa las facturas del mismo ambiente que tu API key (sandbox y producción nunca se mezclan). Orden fijo: más recientes primero (por fecha de creación) - no es configurable en v1. ```json { "ok": true, "data": [ /* comprobantes, ver ComprobanteResumen */ ], "total": 42, "pagina": 1, "limite": 20 } ``` `total` es el conteo completo (no solo de esta página) - úsalo para calcular `Math.ceil(total / limite)` si necesitas el total de páginas. Una `pagina` fuera de rango (ej. pagina=999 con solo 2 páginas de datos) no da error: regresa `data: []` con el mismo `total`. **Filtros en v1 (honesto sobre lo que falta):** el listado de Facturas, Complementos de pago y Nómina **solo acepta `pagina` y `limite`** - no hay filtro por RFC, estado, rango de fechas, ni por UUID todavía. Retenciones es la única excepción, con filtro `tipo`. Si tu integración necesita encontrar un documento por su UUID fiscal, guarda tú mismo el `id` interno que te regresó el POST original (ambos viajan juntos en esa respuesta) - no hay endpoint `GET /facturas?uuidFiscal=...` ni `GET /facturas/uuid/:uuid` en v1. ## GET /api/v1/facturas/{id} - Consultar una factura `:id` es el id interno (UUID que TimbraMX asignó), no el UUID fiscal del SAT - ambos se ven parecidos pero son campos distintos (`id` vs `uuidFiscal` en la respuesta del POST). Regresa 404 si no existe o pertenece a otro ambiente/cuenta. ## GET /api/v1/facturas/{id}/xml - Descargar el XML timbrado crudo Regresa el archivo XML directo (`Content-Type: application/xml`, `Content-Disposition: inline; filename="{id}.xml"`), no envuelto en JSON ni en base64. Si la factura ya tiene una addenda anexada, regresa el XML con la addenda incluida (el definitivo). 404 si el `id` no existe o es de otro ambiente/cuenta; 400 (no 404) si el `id` sí existe pero todavía no tiene ningún XML generado (factura en borrador). ## GET /api/v1/facturas/{id}/pdf - Descargar el PDF Regresa el archivo PDF directo (`Content-Type: application/pdf`, `Content-Disposition: inline; filename="{folio}.pdf"`), no un JSON. Usa el diseño configurado en tu cuenta. 404 si el `id` no existe o es de otro ambiente/cuenta; 400 (no 404) si el documento todavía no está timbrado. `Content-Disposition` es `inline`, no `attachment` - si tu integración necesita forzar la descarga en vez de mostrarlo en el navegador, guarda el binario y sirve tú el header que necesites. ## GET /api/v1/facturas/{id}/acuse - Acuse de cancelación Solo existe si la factura ya fue cancelada (404 si no). El acuse viene en base64, igual criterio que el campo `xml` del resto de la API. ```json { "ok": true, "data": { "acuse": "PGFjdXNlPi4uLjwvYWN1c2U+" } } ``` ## GET /api/v1/facturas/{id}/estatus-sat - Estatus ante el SAT (en vivo) Pregunta directo al SAT si el CFDI sigue vigente o ya fue cancelado - distinto del campo `estado` guardado localmente en TimbraMX. Útil si otro sistema o el receptor solicitó la cancelación fuera de TimbraMX. ```json { "ok": true, "data": { "uuid": "3f9a1c2e-...", "estado": "Vigente" } } ``` `estado` puede ser `"Vigente"`, `"Cancelado"` o `"No Encontrado"`. 502 si no se pudo consultar al SAT en ese momento. ## GET /api/v1/facturas/saldo - Consultar tu saldo de timbres ```json { "ok": true, "data": { "saldo": 1234, "proximaVigencia": "2027-03-15T00:00:00.000Z", "esSandbox": false } } ``` El saldo mostrado es siempre el real de producción, sin importar con cuál de las dos keys consultes - sandbox nunca lo descuenta, pero es útil para decidir cuándo pasar a producción. ## Estados de un documento (ciclo de vida) Aplica igual a Facturas, Notas de crédito, Complementos de pago y Nómina (los 4 son internamente el mismo modelo, con distinto `TipoDeComprobante`). Retenciones usa un modelo separado con el mismo patrón, salvo donde se indica. ``` borrador ──(timbrar)──> timbrando ──(el PAC responde bien)──> timbrada ↑error│ │ └─────┘(el PAC rechaza)──> error ──(reintentar)──> timbrando timbrada ──(cancelar)──> cancelando ──> cancelada │ └──(el SAT exige aprobación del receptor)──> cancelacion_pendiente ──(el receptor aprueba, o pasan 72h sin objeción)──> cancelada │ └──(el receptor rechaza)──> se queda timbrada (la cancelación no procede) ``` | Estado | Significado | Acción del integrador | |---|---|---| | `borrador` | Existe en TimbraMX pero nunca se envió al PAC (la API pública nunca crea este estado directamente - solo aplica a documentos capturados desde el portal). | Ninguna vía API - este estado no lo produce tu integración. | | `timbrando` | Petición en curso hacia el PAC - estado transitorio, no deberías verlo en una respuesta (protege contra doble-clic/doble-petición vía compare-and-set). | Si lo ves en un `GET`, espera unos segundos y vuelve a consultar. | | `timbrada` | CFDI válido, con `uuidFiscal` y `xml`. | Guarda `id` y `uuidFiscal` en tu propio sistema; ya puedes descargar `/xml` y `/pdf`. | | `error` | El PAC rechazó el timbrado - revisa `errorCodigo`/`errorMensaje` en el detalle del recurso (`GET /:id`). | Muestra el error al usuario; corrige el dato y vuelve a intentar (nuevo POST, no hay "reintentar" in-place para modo sellar). | | `cancelando` | Solicitud de cancelación en curso - transitorio. | Espera unos segundos y vuelve a consultar. | | `cancelacion_pendiente` | El SAT exige aprobación del receptor antes de cancelar de verdad (aplica a ciertos CFDI según monto/antigüedad, regla del SAT, no de TimbraMX). | Llama a `GET /{id}/estatus-cancelacion` (disponible en los 4 recursos: Facturas, Complementos de pago, Nómina y Retenciones) para refrescar/confirmar el estatus real ante el PAC sin volver a solicitar la cancelación. | | `cancelada` | Cancelación confirmada por el SAT. | Actualiza tu sistema - este documento ya no es válido fiscalmente. | **`GET /{id}/estatus-cancelacion` ya está unificado en los 4 recursos** (Facturas, Complementos de pago, Nómina y Retenciones) - antes solo existía para Retenciones, dejando una asimetría real; se corrigió el 2026-09-16. Consulta (no vuelve a solicitar la cancelación) el estatus real ante el PAC y, si ya se confirmó, actualiza el `estado` guardado en TimbraMX a `cancelada` automáticamente. 400 si el documento no tiene una cancelación pendiente que confirmar. Adicionalmente, `GET /api/v1/facturas/{id}/estatus-sat` te dice si el SAT ya reconoce el CFDI como "Cancelado" en tiempo real, pero ese endpoint específico **no actualiza el campo `estado` guardado en TimbraMX** - solo informa; usa `estatus-cancelacion` cuando necesites que tu registro interno se actualice. ## POST /api/v1/facturas/{id}/cancelar - Cancelar un CFDI Request: ```json { "motivo": "02", "uuidReemplazo": null } ``` | Campo | Tipo | Obligatorio | Valores | Notas | |---|---|---|---|---| | `motivo` | string | Sí | `01`, `02`, `03`, `04` (catálogo SAT c_MotivoCancelacion) | Ver tabla de motivos abajo. | | `uuidReemplazo` | string (UUID) | Solo si `motivo` es `01` | UUID del CFDI que sustituye a este | Sin este campo, motivo `01` regresa 400. | | Motivo | Significado | ¿Cuándo usarlo? | ¿Pide uuidReemplazo? | |---|---|---|---| | `01` | Comprobante emitido con errores **con** relación | Ya timbraste el CFDI correcto que sustituye a este, y quieres ligarlos. | Sí, obligatorio. | | `02` | Comprobante emitido con errores **sin** relación | El más común: te equivocaste y no vas a re-facturar de inmediato (o el sustituto se hace después, sin ligarlo). | No. | | `03` | La operación no se llevó a cabo | La venta/operación se canceló antes de completarse. | No. | | `04` | Operación nominativa relacionada en una factura global | Casos de facturación global (ventas a público en general). | No. | Response 200 con el comprobante actualizado: ```json { "ok": true, "data": { "id": "b1e2...", "estado": "cancelada", "uuidFiscal": "3f9a1c2e-...", "canceladaEn": "2026-09-15T19:10:00.000Z" } } ``` Si el SAT exige aprobación del receptor, `data.estado` regresa `"cancelacion_pendiente"` en vez de `"cancelada"` (ver el ciclo de vida arriba) - la petición sigue siendo 200 (la solicitud sí se envió correctamente, solo que no quedó resuelta de inmediato). **Reintentar sobre un comprobante ya cancelado:** `POST .../cancelar` sobre un documento que ya está `cancelada` no re-envía nada al PAC - regresa 400 con un mensaje indicando que ya no aplica ("Solo se puede cancelar un CFDI ya timbrado."). Si dos peticiones de cancelación para el mismo comprobante llegan casi simultáneas, solo una se procesa - la otra recibe 409 (protección contra ráfagas de reintentos duplicados, no hace falta que el integrador implemente su propio lock); en ese caso espera unos segundos y consulta `GET /:id` para ver en qué quedó, en vez de reintentar el cancelar de inmediato. El mismo endpoint (`POST .../cancelar`, mismo body) existe también para Complementos de pago, Nómina y Retenciones, en su propia base de ruta (`/complementos-pago/{id}/cancelar`, `/nomina/{id}/cancelar`, `/retenciones/{id}/cancelar`) - mismos motivos, mismo comportamiento. **Confirmar una `cancelacion_pendiente`:** `GET /api/v1/facturas/{id}/estatus-cancelacion` (y su equivalente en los otros 3 recursos: `/complementos-pago/{id}/estatus-cancelacion`, `/nomina/{id}/estatus-cancelacion`, `/retenciones/{id}/estatus-cancelacion`) refresca el estatus real ante el PAC SIN volver a solicitar la cancelación - si ya se confirmó, actualiza `estado` a `cancelada` en la misma llamada. 400 si el documento no tiene una cancelación pendiente de confirmar. ## POST /api/v1/facturas/{id}/nota-credito - Generar una nota de crédito Acredita, total o parcialmente, una factura de Ingreso ya timbrada por esta misma API key. `:id` es el `id` (no el UUID fiscal) que regresó el endpoint de timbrado. La forma de pago se copia de la factura original (regla del Anexo 20) y el método de pago siempre es PUE. Request: ```json { "importe": 1160.00, "descripcion": "Devolución de mercancía", "serie": "NC" } ``` `serie` es opcional (código de una serie tipo Notas de crédito; si se omite, se usa la favorita). `importe` no puede ser mayor al total de la factura original. Response 201: mismo formato que timbrar una factura (`id`, `estado`, `uuidFiscal`, `xml`). Errores propios: `[NC-002]` importe inválido, `[NC-003]` falta la descripción. También aplican los genéricos 401/402/404 (factura no timbrada o no encontrada). ## Complementos de pago (CFDI tipo P) - base `/api/v1/complementos-pago` Recurso hermano de `/facturas` (URL propia, no anidado). Timbra un CFDI tipo P para registrar uno o varios pagos de facturas PPD ya timbradas. ### POST /api/v1/complementos-pago Las facturas a cubrir se identifican por su **UUID fiscal** (`facturaUuid`), no por id interno - deben ser PPD, estar timbradas, y ser del mismo receptor. Un mismo `pago` puede cubrir varios `documentos`, y el body puede traer varios `pagos` (fechas o formas de pago distintas) en un solo complemento. Soporta `Idempotency-Key`. Request: ```json { "receptorRfc": "COE850101AB1", "serie": "PAGOS", "pagos": [ { "fechaPago": "2026-09-15T12:00:00", "formaPago": "03", "moneda": "MXN", "documentos": [ { "facturaUuid": "3F9A1C2E-4B7D-4E3A-9C2F-8B1D6A5E7F90", "impPagado": 12500.00 } ] } ] } ``` Si `moneda` no es `MXN`, `tipoCambio` es obligatorio. ### Contrato de campos - Complementos de pago | Campo | Tipo | Obligatorio | Valores / formato | Descripción | |---|---|---|---|---| | `receptorRfc` | string | Sí | - | RFC del cliente al que le cubres facturas. | | `serie` | string | No | 1-50 | Código de una serie de tipo "complemento_pago" activa; si se omite, usa la serie activa favorita de ese tipo. | | `pagos` | array | Sí | mínimo 1 | Uno o varios pagos en el mismo CFDI. | | `pagos[].fechaPago` | string (fecha-hora) | Sí | ISO 8601 | Fecha y hora en que se recibió el pago. | | `pagos[].formaPago` | string | Sí | 2 dígitos | Catálogo SAT c_FormaPago. | | `pagos[].moneda` | string | No (default `MXN`) | catálogo SAT c_Moneda | Moneda del pago (no de la factura cubierta). | | `pagos[].tipoCambio` | number | Obligatorio si `moneda` ≠ `MXN` | > 0 | Tipo de cambio del pago. | | `pagos[].numOperacion` | string | No | máximo 100 | Referencia de transferencia/SPEI. | | `pagos[].rfcEmisorCtaOrd` | string | No | máximo 13 | RFC del banco ordenante (si aplica). | | `pagos[].nomBancoOrdExt` | string | No | máximo 300 | Nombre del banco ordenante extranjero (si aplica). | | `pagos[].ctaOrdenante` | string | No | máximo 50 | Cuenta de origen del pago. | | `pagos[].rfcEmisorCtaBen` | string | No | máximo 13 | RFC del banco beneficiario (si aplica). | | `pagos[].ctaBeneficiario` | string | No | máximo 50 | Cuenta que recibió el pago. | | `pagos[].documentos` | array | Sí | mínimo 1 | Las facturas que cubre ESTE pago. | | `pagos[].documentos[].facturaUuid` | string (UUID) | Sí | - | UUID fiscal de una factura PPD ya timbrada del mismo receptor. | | `pagos[].documentos[].impPagado` | number | Sí | > 0 | Monto que este pago aplica a esa factura específica. | **Lo que tú mandas:** RFC del receptor, serie, y por cada pago sus documentos cubiertos con el monto aplicado. **Lo que calcula TimbraMX:** el saldo insoluto/anterior de cada factura cubierta (los lee directo de la factura original, no se los mandas tú), el total del complemento, el folio, el sello y el UUID fiscal del CFDI tipo P. Response 201: ```json { "ok": true, "data": { "id": "b7e2...", "estado": "timbrada", "facturaId": "c4f1...", "pac": "..." } } ``` Errores propios: `[PAG-001]` monto pagado inválido, `[PAG-002]` forma de pago inválida, `[PAG-004]` un pago sin documentos, `[PAG-005]` falta tipo de cambio, `[PAG-008]` sin pagos, `[PAG-009]` falta el UUID, `[PAG-010]` falta el RFC del receptor. 400 también si `facturaUuid` no corresponde a una factura PPD timbrada de ese receptor. ### Otros endpoints de Complementos de pago - `GET /api/v1/complementos-pago?pagina=1&limite=20&estado=&uuid=&rfc=&desde=&hasta=` - listar (mismo criterio de paginación, ambiente y filtros que facturas; `uuid` busca sobre el UUID fiscal del CFDI tipo P asociado). - `GET /api/v1/complementos-pago/{id}` - consultar (incluye el detalle de `pagos` y las facturas cubiertas). - `GET /api/v1/complementos-pago/{id}/xml` / `/pdf` - descargar archivo (no JSON). - `POST /api/v1/complementos-pago/{id}/cancelar` - cancelar el CFDI tipo P (mismo body `{motivo, uuidReemplazo}` que facturas). - `GET /api/v1/complementos-pago/{id}/estatus-cancelacion` - confirmar una cancelación pendiente (ver la sección de cancelación de Facturas arriba). ## Nómina (CFDI de Nómina 1.2, tipo N) - base `/api/v1/nomina` ### POST /api/v1/nomina Arma, sella y timbra el CFDI de Nómina 1.2 en un solo paso - el body es la misma estructura de datos que el diálogo de Nómina del portal. Siempre timbra (no guarda borrador; para eso usa el portal). El `receptorRfc` debe existir ya en tu catálogo de "Mis clientes". **Todos los campos numéricos dentro de `nomina` viajan como string**, no number - mismo formato que exige el XSD del SAT. Soporta `Idempotency-Key`. Request (mínimo, un empleado con una percepción): ```json { "receptorRfc": "MARL900101AB2", "serie": "NOM", "nomina": { "tipoNomina": "O", "fechaPago": "2026-09-15", "fechaInicialPago": "2026-09-01", "fechaFinalPago": "2026-09-15", "numDiasPagados": "15", "totalPercepciones": "7500.00", "receptor": { "curp": "MARL900101HDFRZS02", "tipoContrato": "01", "tipoRegimen": "02", "numEmpleado": "1", "periodicidadPago": "04", "claveEntFed": "JAL" }, "percepciones": { "totalGravado": "7500.00", "totalExento": "0.00", "percepciones": [ { "tipoPercepcion": "001", "clave": "001", "concepto": "Sueldos", "importeGravado": "7500.00", "importeExento": "0.00" } ] } } } ``` Debe existir `totalPercepciones` o `totalOtrosPagos` (o ambos). `tipoNomina` es "O" (Ordinaria) o "E" (Extraordinaria). Response 201: mismo formato que timbrar una factura (`id`, `estado`, `uuidFiscal`, `xml`). Errores propios más comunes: `[FC-219]` falta TotalPercepciones/TotalOtrosPagos, `[FC-220]` fecha inicial posterior a la final, `[FC-173]` falta número de empleado, `[FC-176]` salario diario integrado inválido, `[FC-198]` sin percepciones capturadas. ### Estructura completa del payload de Nómina Es el schema más grande de la API - cubre el mismo formulario de Nómina del portal, con TODOS los tipos de percepción/deducción/otro pago que soporta el CFDI de Nómina 1.2. Regla general que aplica a TODO este bloque: **cada campo numérico es un string** (`"7500.00"`, no `7500.00`), porque así lo exige el XSD del SAT. **Sobre raíz** (fuera de `nomina`): `receptorRfc` (string, obligatorio), `serie` (string, opcional), `fecha` (fecha-hora, opcional), `notaPdf` (string, opcional, máximo 2000), `camposPersonalizados` (objeto libre `{clave: valor}`, opcional). **`nomina` (datos generales):** | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `tipoNomina` | string | Sí | `O` (Ordinaria) o `E` (Extraordinaria). | | `fechaPago` / `fechaInicialPago` / `fechaFinalPago` | string (aaaa-mm-dd) | Sí | `fechaInicialPago` no puede ser posterior a `fechaFinalPago`. | | `numDiasPagados` | string | Sí | Puede tener hasta 3 decimales. | | `totalPercepciones` / `totalDeducciones` / `totalOtrosPagos` | string | Debe existir `totalPercepciones` o `totalOtrosPagos` (o ambos) | Totales del recibo. | | `receptor` | object | Sí | Ver tabla abajo. | | `percepciones` | object | No (pero normalmente necesario) | Ver tabla abajo. | | `deducciones` | object | No | Ver tabla abajo. | | `otrosPagos` | array | No (default `[]`) | Ver tabla abajo. | | `incapacidades` | array | No (default `[]`) | `diasIncapacidad`, `tipoIncapacidad`, `importeMonetario` (opcional) por cada una. | **`nomina.receptor`:** | Campo | Tipo | Obligatorio | |---|---|---| | `curp` | string (18) | Sí | | `tipoContrato` / `tipoRegimen` / `numEmpleado` / `periodicidadPago` / `claveEntFed` | string | Sí (los 5) | | `numSeguridadSocial` | string | No | | `fechaInicioRelLaboral` | string (aaaa-mm-dd) | No | | `antiguedad`, `tipoJornada`, `riesgoPuesto`, `banco`, `cuentaBancaria`, `departamento`, `puesto` | string | No | | `sindicalizado` | `"Sí"` / `"No"` | No | | `salarioBaseCotApor` / `salarioDiarioIntegrado` | string (importe) | No | | `subContratacion` | array | No | **`nomina.percepciones`:** | Campo | Tipo | Obligatorio | |---|---|---| | `totalGravado` / `totalExento` | string | Sí | | `percepciones` | array, mínimo 1 | Sí - cada una: `tipoPercepcion`, `clave` (3-15 caracteres), `concepto`, `importeGravado`, `importeExento` (todas obligatorias), más `horasExtra[]` opcional (`dias`, `tipoHoras`, `horasExtra`, `importePagado`) y `accionesOTitulos` opcional (`valorMercado`, `precioAlOtorgarse`). | | `totalSueldos`, `totalSeparacionIndemnizacion`, `totalJubilacionPensionRetiro` | string | No | | `jubilacionPensionRetiro` | object | No - `ingresoAcumulable`/`ingresoNoAcumulable` obligatorios si se incluye; `totalUnaExhibicion`/`totalParcialidad`/`montoDiario` opcionales. | | `separacionIndemnizacion` | object | No - `totalPagado`, `numAniosServicio`, `ultimoSueldoMensOrd`, `ingresoAcumulable`, `ingresoNoAcumulable` (todas obligatorias si se incluye el bloque). | **`nomina.deducciones`:** | Campo | Tipo | Obligatorio | |---|---|---| | `deducciones` | array, mínimo 1 | Sí - cada una: `tipoDeduccion`, `clave` (3-15), `concepto`, `importe` (todas obligatorias). | | `totalOtrasDeducciones` / `totalImpuestosRetenidos` | string | No | **`nomina.otrosPagos[]`** (cada elemento): `tipoOtroPago`, `clave` (3-15), `concepto`, `importe` obligatorios; `subsidioAlEmpleo` opcional (`subsidioCausado`); `compensacionSaldosAFavor` opcional (`saldoAFavor`, `anio`, `remanenteSalFav`, todas obligatorias si se incluye). **Lo que tú mandas:** todo el detalle de percepciones/deducciones/otros pagos del periodo. **Lo que calcula TimbraMX:** el CFDI completo (sello, certificado, folio, UUID), usando el emisor ya configurado en tu cuenta - no mandas datos del emisor salvo el bloque opcional `emisor` para casos especiales (outsourcing). Referencia técnica exacta campo por campo: `openapi.yaml` (`schemas.NominaDatos`) o la colección de Postman. ### Otros endpoints de Nómina - `GET /api/v1/nomina?pagina=1&limite=20&estado=&uuid=&rfc=&desde=&hasta=` - listar (mismos filtros que facturas). - `GET /api/v1/nomina/{id}` - consultar. - `GET /api/v1/nomina/{id}/xml` / `/pdf` - descargar archivo. - `POST /api/v1/nomina/{id}/cancelar` - cancelar. - `GET /api/v1/nomina/{id}/estatus-cancelacion` - confirmar una cancelación pendiente (ver la sección de cancelación de Facturas arriba). ## Retenciones e Información de Pagos - base `/api/v1/retenciones` **Alcance v1: solo 3 de los 12 tipos** del catálogo completo (`dividendos`, `enajenacion_acciones`, `pagos_extranjero`). Los otros 9 (intereses, premios, plataformas tecnológicas, fideicomiso no empresarial, planes de retiro, intereses hipotecarios, operaciones con derivados, sector financiero, arrendamiento en fideicomiso) solo están disponibles desde el portal por ahora. ### POST /api/v1/retenciones Timbra en un solo paso. Soporta `Idempotency-Key`. Request (ejemplo: dividendos): ```json { "tipoOperacion": "dividendos", "serie": "RET", "rfcContraparte": "MARL900101AB2", "nombreContraparte": "Luis Martinez Rodriguez", "codigoPostalContraparte": "44100", "fechaOperacion": "2026-09-15", "montoOperacion": 50000, "cveRetenc": "14", "mesPeriodo": 9, "ejercicio": 2026, "impRetenidos": [ { "impuesto": "001", "montoRet": 5000, "tipoPagoRet": "02" } ] } ``` `impuesto` usa el catálogo c_Impuesto (001 ISR, 002 IVA, 003 IEPS) y `tipoPagoRet` el catálogo c_TipoPagoRet - ligado al impuesto: si `impuesto` es `001` (ISR), `tipoPagoRet` debe ser `03` o `04`; si es `002` (IVA), debe ser `01`; si es `003` (IEPS), debe ser `02`. Si la contraparte es extranjera, manda `numRegIdTribContraparte` en vez de `codigoPostalContraparte`. **Regla importante:** con el RFC genérico `XAXX010101000`, `nombreContraparte` debe ser exactamente `"PÚBLICO EN GENERAL"` (con acento) - el PAC lo rechaza sin el acento. ### Contrato de campos comunes (los 3 tipos) | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `tipoOperacion` | string | Sí | `dividendos`, `enajenacion_acciones` o `pagos_extranjero` (únicos 3 soportados en API v1). | | `rfcContraparte` | string | No | RFC de quien recibe la retención - obligatorio en la práctica salvo casos genéricos. | | `nombreContraparte` | string | No | Ver regla del RFC genérico arriba. | | `paisResidencia` | string | No | Código de país (catálogo c_Pais del SAT, ISO 3166-1 alfa-3, ej. `MEX`, `USA`). Si se omite o es `MEX`, la contraparte se trata como nacional. | | `codigoPostalContraparte` | string | Obligatorio si es nacional | C.P. de la contraparte. | | `numRegIdTribContraparte` | string | Obligatorio si es extranjera | Registro fiscal en su país de residencia. | | `fechaOperacion` | string (fecha) | Sí | aaaa-mm-dd. | | `montoOperacion` | number | Sí | > 0. Techo de todos los montos de abajo. | | `montoRetenido` | number | No | No puede exceder `montoOperacion`. | | `cveRetenc` | string | No | Clave de 2 dígitos del catálogo c_CveRetenc - si la mandas, debe corresponder exactamente al `tipoOperacion` (`14` dividendos, `06` enajenación de acciones, `18` pagos al extranjero). | | `mesPeriodo` / `mesFinPeriodo` | number | No | 1-12. `mesFinPeriodo` no puede ser menor a `mesPeriodo`. | | `ejercicio` | number | No | Año, >= 2019. | | `montoTotGrav` / `montoTotExent` | number | No | Su suma no puede exceder `montoOperacion`. | | `impRetenidos` | array | No | Impuestos retenidos - ver arriba (`impuesto`, `baseRet`, `montoRet`, `tipoPagoRet`). | | `datosEspecificos` | object | Depende del tipo | Campos propios de cada complemento - ver tablas abajo. **Los nombres de los campos van en PascalCase exacto, igual que en el XML del SAT** (no camelCase). | | `serie` | string | No | Código de una serie de tipo "retenciones" activa. | ### `datosEspecificos` para `tipoOperacion: "dividendos"` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `CveTipDivOUtil` | string | Sí | Clave del tipo de dividendo/utilidad (catálogo del SAT). | | `TipoSocDistrDiv` | string | No (default `Sociedad Nacional`) | `Sociedad Nacional` o `Sociedad Extranjera`. | | `MontISRAcredRetMexico` | string | No (default `"0"`) | Monto de ISR acreditable retenido en México. | | `MontISRAcredRetExtranjero` | string | No (default `"0"`) | Monto de ISR acreditable retenido en el extranjero. | | `MontRetExtDivExt` | string | No | Monto retenido en el extranjero por dividendos de fuente extranjera. | | `MontISRAcredNal` | string | No | ISR acreditable nacional. | | `MontDivAcumNal` | string | No | Dividendo acumulable nacional. | | `MontDivAcumExt` | string | No | Dividendo acumulable extranjero. | | `ProporcionRem` | string | No | Proporción de remanente distribuible. | Ejemplo de `datosEspecificos` para dividendos: ```json { "CveTipDivOUtil": "01", "TipoSocDistrDiv": "Sociedad Nacional", "MontISRAcredRetMexico": "0", "MontISRAcredRetExtranjero": "0" } ``` ### `datosEspecificos` para `tipoOperacion: "enajenacion_acciones"` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ContratoIntermediacion` | string | Sí | Número de contrato de intermediación bursátil. | | `Ganancia` | string | No (default `"0"`) | Monto de la ganancia en la operación. | | `Perdida` | string | No (default `"0"`) | Monto de la pérdida en la operación. | Ejemplo: ```json { "ContratoIntermediacion": "CI-2026-001", "Ganancia": "15000.00", "Perdida": "0" } ``` ### `datosEspecificos` para `tipoOperacion: "pagos_extranjero"` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `EsBenefEfectDelCobro` | string | No (default `"SI"`) | `SI` o `NO` - si el beneficiario extranjero es el efectivo receptor del cobro. | | `NoBeneficiario` | object | Solo si `EsBenefEfectDelCobro` es `"NO"` | Datos de quien NO es el beneficiario efectivo. | | `NoBeneficiario.PaisDeResidParaEfecFisc` | string | - | País de residencia fiscal (catálogo c_Pais). | | `NoBeneficiario.ConceptoPago` | string | - | Clave del concepto del pago. | | `NoBeneficiario.DescripcionConcepto` | string | - | Descripción del concepto. | | `Beneficiario` | object | No | Datos del beneficiario efectivo, si se conocen. | | `Beneficiario.RFC` | string | - | RFC del beneficiario, si es mexicano. | | `Beneficiario.CURP` | string | - | CURP del beneficiario, si aplica. | | `Beneficiario.NomDenRazSocB` | string | - | Nombre o razón social del beneficiario. | | `Beneficiario.ConceptoPago` | string | - | Clave del concepto del pago. | | `Beneficiario.DescripcionConcepto` | string | - | Descripción del concepto. | Ejemplo: ```json { "EsBenefEfectDelCobro": "SI", "Beneficiario": { "RFC": "XEXX010101000", "NomDenRazSocB": "Acme Corp", "ConceptoPago": "15", "DescripcionConcepto": "Servicios profesionales" } } ``` Response 201: mismo formato que timbrar una factura. Errores propios: `[RET-001]` monto de operación inválido, `[RET-003]` clave de retención inválida, `[RET-010]`/`[RET-011]` nombre incorrecto para RFC genérico, `[RET-015]` país de residencia inexistente en el catálogo del SAT. ### Otros endpoints de Retenciones - `GET /api/v1/retenciones?tipo=dividendos&estado=&uuid=&rfc=&desde=&hasta=&pagina=1&limite=20` - listar (filtrable por `tipo`, `estado`, `uuid`, `rfc` -de la contraparte-, `desde`/`hasta`). - `GET /api/v1/retenciones/{id}` - consultar. - `GET /api/v1/retenciones/{id}/xml` / `/pdf` - descargar archivo. - `POST /api/v1/retenciones/{id}/cancelar` - cancelar. - `GET /api/v1/retenciones/{id}/estatus-cancelacion` - consultar el estatus de una cancelación en curso ante el PAC (puede quedar en un estatus transitorio hasta confirmarse). ## Ejemplos de XML (abreviados, sello/certificado omitidos por espacio) Factura de Ingreso (CFDI 4.0): ```xml ``` Complemento de Pago (Pagos 2.0): ```xml ``` La estructura oficial completa de cada nodo (incluidos los que no se usan en estos ejemplos) está en el [Anexo 20 del SAT](https://timbra-mx.com/docs/anexo20-sat.pdf) - la guía de llenado oficial de 120 páginas que define nodos, atributos y catálogos del CFDI. Esta documentación de TimbraMX cubre cómo usar la API, no sustituye esa especificación técnica. ## Webhooks (no existen todavía) TimbraMX **no tiene webhooks en v1** - no hay forma de que TimbraMX le avise a tu sistema cuando algo cambia de estado (por ejemplo, cuando una `cancelacion_pendiente` se resuelve). El patrón recomendado mientras tanto es **polling**: guarda el `id` de cada documento que te interese y consulta `GET /:id` periódicamente (cada pocos minutos es razonable para casos como una cancelación pendiente, que normalmente no cambia en segundos). Ten en cuenta el límite de 60 peticiones/minuto al diseñar la frecuencia de tu polling si vigilas muchos documentos a la vez. ## Límites y seguridad - **60 peticiones por minuto, pero por RECURSO, no una sola bolsa global.** Facturas, Complementos de pago, Nómina y Retenciones tienen cada uno su propio contador independiente de 60/min - una cuenta activa en los 4 recursos a la vez puede hacer hasta 240 peticiones/min combinadas, no 60. Cada contador ya está separado entre tu ambiente de sandbox y el de producción. El límite es por cuenta autenticada (no por IP): no importa cuántas direcciones uses, ni te sirve usar varias para evadirlo. - Verificado en código: el algoritmo es de **ventana fija** (no deslizante) de 60 segundos - la respuesta trae los headers `RateLimit-Limit`, `RateLimit-Remaining` y `RateLimit-Reset` (segundos restantes de la ventana actual). - ⚠️ **Ráfaga anti-bot: 4 peticiones en 5 segundos, por IP, corre ANTES de validar tu API key.** Si la disparas, tu IP queda bloqueada de forma **permanente** (no temporal) hasta que se libere a mano por soporte - aplica incluso con una API key válida, porque este candado corre antes de siquiera revisar la key. Si tu integración procesa lotes (por ejemplo, timbrar varias facturas pendientes de una cola), agrega al menos ~1.5 segundos entre peticiones consecutivas desde la misma IP/servidor para evitar quedar bloqueado por tráfico enteramente legítimo. Esto es distinto del límite normal de 60/min de arriba y bastante más estricto - trátalo como el límite real a respetar si haces ráfagas cortas. - Cada timbrado exitoso en producción consume 1 timbre de tu saldo - usar la API no tiene costo adicional sobre timbrar desde el portal. - Body máximo 10MB por petición. - La API key de sandbox vence a los 90 días de generada (se renueva regenerándola desde el portal); la de producción no tiene vencimiento automático. ### Seguridad de tu API key Tu API key es literalmente la contraseña de tu cuenta - quien la tenga puede timbrar, cancelar y consultar todo lo que tú puedes. - **Solo desde tu backend.** Nunca la incluyas en código que corra en el navegador, en una app móvil, ni en ningún cliente que el usuario final pueda inspeccionar (JavaScript de una página, un APK, etc.) - siempre desde tu servidor. - **Nunca la subas a un repositorio.** Guárdala en variables de entorno o en el manejo de secretos de tu infraestructura, no en el código fuente ni en archivos de configuración versionados. - **Nunca la registres en logs.** Si tu sistema loguea headers de request completos por default, excluye explícitamente `X-Api-Key`. - **Úsala solo sobre HTTPS.** La API no acepta HTTP plano. - **Si se compromete, regénerala de inmediato** desde el portal (vista API) - la key anterior deja de funcionar en el momento en que generas una nueva. ## Qué NO soporta la API hoy (léelo antes de construir tu integración) Para que no descubras estas limitaciones después de haber integrado, aquí está la lista completa y honesta: - **Complementos en modo sellar**: el modo sellar de `/facturas` solo arma CFDI de Ingreso simple (tipo "I") sin complementos - Carta Porte, Comercio Exterior, IEDU, etc. requieren que tú armes el XML completo y uses el modo relay, o que uses el portal. - **Retenciones**: solo 3 de los 12 tipos del catálogo del SAT (`dividendos`, `enajenacion_acciones`, `pagos_extranjero`) - los otros 9 solo están en el portal. - **Addendas**: se pueden agregar a una factura ya timbrada, pero solo desde el portal - no hay endpoint de API para addendas todavía. - **Webhooks**: no existen. No hay forma de que TimbraMX le avise a tu sistema cuando algo cambia - usa polling (`GET /:id`). - **Búsqueda por UUID fiscal**: los listados aceptan `uuid` como búsqueda parcial (`GET /facturas?uuid=...`, y equivalentes en Complementos de pago/Nómina/Retenciones), pero no hay un lookup exacto por UUID que regrese un solo registro directo - sigue siendo más simple guardar tú el `id` interno que regresó el POST original (viaja junto con `uuidFiscal` en esa respuesta). - **Crear clientes/series/CSD por API**: no existe - todo se prepara una vez desde el portal (ver "Configuración previa en el portal" arriba). ### Funcionalidad por complemento (modo relay vs. modo sellar) | Funcionalidad | Modo relay | Modo sellar | |---|---|---| | CFDI de Ingreso simple | ✅ | ✅ | | Carta Porte | ✅ (tú armas el XML) | ❌ | | Comercio Exterior | ✅ (tú armas el XML) | ❌ | | IEDU / otros complementos especiales | ✅ (tú armas el XML) | ❌ | | Addenda | ✅ (tú la incluyes en el XML) | ❌ (agrégala desde el portal después de timbrar) | ## Catálogos del SAT usados por esta API Esta API no reinventa catálogos propios: usa directamente los catálogos oficiales del SAT (Anexo 20). No los reproducimos aquí completos porque el SAT los actualiza sin previo aviso y una copia desactualizada en esta documentación causaría más problemas que ayuda - **usa siempre la versión vigente publicada por el SAT** como fuente. Referencia de cuáles aplican a qué campo: | Catálogo | Campos donde se usa | Consulta en TimbraMX | |---|---|---| | `c_FormaPago` | `formaPago` (Facturas, Complementos de pago) | `GET /catalogos/forma-pago` | | `c_Moneda` | `moneda` | `GET /catalogos/moneda` | | `c_ClaveProdServ` | `conceptos[].claveProdServ` | `GET /catalogos/clave-prod-serv?buscar=...` | | `c_ClaveUnidad` | `conceptos[].claveUnidad` | `GET /catalogos/clave-unidad?buscar=...` | | `c_RegimenFiscal` | Régimen fiscal del receptor (ya dado de alta en "Mis clientes", no se manda por API) | `GET /catalogos/regimen-fiscal` | | `c_UsoCFDI` | Uso CFDI del receptor (idem, se resuelve desde el cliente ya dado de alta) | `GET /catalogos/uso-cfdi` | | `c_Impuesto` | `conceptos[].impuestos[].impuesto`, `impRetenidos[].impuesto` | 001 ISR / 002 IVA / 003 IEPS (los 3 únicos valores, no cambian) | | `c_TipoFactor` | `conceptos[].impuestos[].tipoFactor` | `Tasa` / `Exento` (más `Cuota`, no expuesto aún en modo sellar) | | `c_TipoPagoRet` | `impRetenidos[].tipoPagoRet` | 01-04 (ver la tabla de Retenciones arriba para el mapeo por impuesto) | | `c_CveRetenc` | `cveRetenc` | Fijo por `tipoOperacion` - ver la tabla de Retenciones arriba. | | `c_Pais` | `paisResidencia` (Retenciones) | ISO 3166-1 alfa-3 (`MEX`, `USA`, etc.) - validado contra la tabla real del SAT al timbrar. | **Bono verificado:** los endpoints `GET /catalogos/...` de arriba son de **lectura pública, sin necesidad de `X-Api-Key`** (por ejemplo, `GET https://app.timbra-mx.com/catalogos/forma-pago` responde 200 sin ningún header de autenticación) - úsalos libremente si tu integración necesita armar un selector propio con los catálogos vigentes, sin tener que copiar y mantener tu propia versión del catálogo del SAT. No cuentan contra tu límite de 60/min (viven fuera de los routers de `/api/v1/*`). ## Casos de prueba en Sandbox Antes de pedir tu certificación para producción, verifica que tu integración maneja bien estos casos - todos se pueden probar en sandbox sin costo ni riesgo. | Caso | Cómo provocarlo | Resultado esperado | |---|---|---| | Timbrado exitoso | Body válido, key de sandbox | 201, `estado: "timbrada"` | | API key inválida | Header `X-Api-Key` con un valor cualquiera | 401 | | Sin header `X-Api-Key` | Omite el header | 401 | | RFC de receptor inexistente | `receptorRfc` que no está en tus clientes | 400 | | Serie inexistente o inactiva | `serie` con un código que no existe | 400 | | Sin timbres | Solo se puede en producción - sandbox nunca cobra ni bloquea por saldo | 402 (solo producción) | | Módulo no contratado | Usa un endpoint de un recurso sin ese módulo activo | 403 | | Id inexistente | `GET /facturas/{id}` con un UUID que no existe | 404 | | Id de otro ambiente | Consulta con la key de sandbox un `id` creado en producción (o viceversa) | 404 | | Petición duplicada (mismo Idempotency-Key) | Repite la misma petición con el mismo header | 200, `idempotente: true` | | Operación concurrente | Dos llamadas casi simultáneas a `/cancelar` sobre el mismo `id` | La segunda: 409 | | Rate limit | Más de 60 peticiones/min al mismo recurso con la misma key | 429 | | Cancelación exitosa | `POST /facturas/{id}/cancelar` con motivo válido sobre una factura timbrada | `estado: "cancelada"` o `"cancelacion_pendiente"` | | Validación sin timbrar | `POST /facturas/validar` con un XML mal formado | 200, `data.valido: false` con la lista de errores | | Descarga de XML/PDF de un documento sin timbrar | `GET /facturas/{id}/xml` sobre un `id` en `estado: "borrador"` | 400 ("todavia no tiene ningun XML generado") - el `id` sí existe (200 en `GET /:id`), solo el archivo no. | Timbrar una factura en modo sellar (`POST /facturas`), en varios lenguajes: **PHP (Laravel, usando el HTTP Client):** ```php $response = Http::withHeaders([ 'X-Api-Key' => config('services.timbramx.api_key'), 'Content-Type' => 'application/json', ])->post('https://app.timbra-mx.com/api/v1/facturas', [ 'receptorRfc' => 'XAXX010101000', 'serie' => 'F', 'metodoPago' => 'PUE', 'moneda' => 'MXN', 'conceptos' => [[ 'claveProdServ' => '81112101', 'claveUnidad' => 'E48', 'descripcion' => 'Servicio de consultoría', 'cantidad' => 1, 'valorUnitario' => 1000, 'impuestos' => [[ 'impuesto' => '002', 'categoria' => 'traslado', 'tipoFactor' => 'Tasa', 'tasaIva' => 0.16, ]], ]], ]); $factura = $response->json('data'); ``` **Node.js (fetch nativo):** ```javascript const respuesta = await fetch("https://app.timbra-mx.com/api/v1/facturas", { method: "POST", headers: { "X-Api-Key": process.env.TIMBRAMX_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ receptorRfc: "XAXX010101000", serie: "F", metodoPago: "PUE", moneda: "MXN", conceptos: [{ claveProdServ: "81112101", claveUnidad: "E48", descripcion: "Servicio de consultoría", cantidad: 1, valorUnitario: 1000, impuestos: [{ impuesto: "002", categoria: "traslado", tipoFactor: "Tasa", tasaIva: 0.16 }], }], }), }); const resultado = await respuesta.json(); ``` **Python (requests):** ```python import requests resultado = requests.post( "https://app.timbra-mx.com/api/v1/facturas", headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, json={ "receptorRfc": "XAXX010101000", "serie": "F", "metodoPago": "PUE", "moneda": "MXN", "conceptos": [{ "claveProdServ": "81112101", "claveUnidad": "E48", "descripcion": "Servicio de consultoría", "cantidad": 1, "valorUnitario": 1000, "impuestos": [{"impuesto": "002", "categoria": "traslado", "tipoFactor": "Tasa", "tasaIva": 0.16}], }], }, ).json() ``` **C# (.NET, HttpClient):** ```csharp var body = new { receptorRfc = "XAXX010101000", serie = "F", metodoPago = "PUE", moneda = "MXN", conceptos = new[] { new { claveProdServ = "81112101", claveUnidad = "E48", descripcion = "Servicio de consultoría", cantidad = 1, valorUnitario = 1000, impuestos = new[] { new { impuesto = "002", categoria = "traslado", tipoFactor = "Tasa", tasaIva = 0.16 } }, }, }, }; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-Api-Key", apiKey); var response = await client.PostAsJsonAsync("https://app.timbra-mx.com/api/v1/facturas", body); var resultado = await response.Content.ReadFromJsonAsync(); ``` En cualquiera de los cinco (curl, PHP, Node.js, Python, C#), usa tu API key de sandbox para probar y la de producción cuando ya esté certificada tu cuenta - la URL nunca cambia. ## Primera integración en 10 pasos (con curl) ```bash # 1. Crea tu cuenta # https://app.timbra-mx.com/portal-login.html # 2. Genera tu API key de sandbox desde el portal, vista "API" export TIMBRAMX_API_KEY="tu_api_key_de_sandbox" # 3. Confirma que tu key funciona consultando tu saldo curl https://app.timbra-mx.com/api/v1/facturas/saldo \ -H "X-Api-Key: $TIMBRAMX_API_KEY" # 4. Da de alta un cliente de prueba y una serie desde el portal web # (la API todavía no tiene endpoints para crear clientes/series, solo para timbrar contra los que ya existen) # 5. Timbra tu primera factura (modo sellar) - guarda el "id" de la respuesta curl -X POST https://app.timbra-mx.com/api/v1/facturas \ -H "X-Api-Key: $TIMBRAMX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: prueba-001" \ -d '{ "receptorRfc": "XAXX010101000", "serie": "F", "metodoPago": "PUE", "moneda": "MXN", "conceptos": [{ "claveProdServ": "81112101", "claveUnidad": "E48", "descripcion": "Servicio de prueba", "cantidad": 1, "valorUnitario": 100, "impuestos": [{ "impuesto": "002", "categoria": "traslado", "tipoFactor": "Tasa", "tasaIva": 0.16 }] }] }' # 6. Consulta la factura que acabas de timbrar (usa el "id" de la respuesta anterior) curl https://app.timbra-mx.com/api/v1/facturas/FACTURA_ID \ -H "X-Api-Key: $TIMBRAMX_API_KEY" # 7. Descarga el XML curl https://app.timbra-mx.com/api/v1/facturas/FACTURA_ID/xml \ -H "X-Api-Key: $TIMBRAMX_API_KEY" -o factura.xml # 8. Descarga el PDF curl https://app.timbra-mx.com/api/v1/facturas/FACTURA_ID/pdf \ -H "X-Api-Key: $TIMBRAMX_API_KEY" -o factura.pdf # 9. Cancélala (motivo 02, sin relación) y revisa si quedó "cancelada" o "cancelacion_pendiente" curl -X POST https://app.timbra-mx.com/api/v1/facturas/FACTURA_ID/cancelar \ -H "X-Api-Key: $TIMBRAMX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "motivo": "02" }' # 10. Cuando tu integración funcione bien en sandbox, solicita la certificación # de tu cuenta para poder generar tu API key de producción (vista "API" del portal). ```