Winal timbra CFDI 4.0 contra un PAC (hoy: Finkok o Facturama, el que tú elijas) — base e IVA se calculan exactos a partir del total en centavos, nunca con redondeo de punto flotante. Facturas lo que vendiste, haya pasado o no por un cobro de Winal: el mostrador que cobró en efectivo emite su CFDI igual que quien cobró con nuestra API. Y para el grueso de un mostrador — las ventas de las que nadie pidió comprobante — está la factura global periódica: la obligación mensual que agrupa todo eso en un solo CFDI.
POST de esta página —/v1/invoices, /pue,
/ppd y /global— timbran antes de responder. Si el PAC acepta el
comprobante, la misma respuesta 201 ya trae el CFDI en status: "stamped"
con su uuid_fiscal: no hay un estado pending que sondear ni nada que
esperar para saber si se timbró. Si el PAC lo rechaza, el error llega en esa misma
respuesta (invoice.pac_error), nunca después. El webhook
invoice.stamped/invoice.error que además se dispara es un aviso
adicional para quien lo quiera usar como fuente asíncrona (p. ej. para actualizar otro sistema) —
no el mecanismo con el que te enteras del resultado.
POST de esta página responden
invoice.fiscal_profile_missing
y luego
invoice.pac_credentials_missing:
son configuración de la cuenta, no bugs de tu código, y puedes integrar todo lo demás sin
ellos.
invoice.csd_is_efirma), igual
que a las solicitudes .req/.ren
(invoice.csd_is_certificate_request).
Cuál es cuál y de dónde sale cada uno:
CSD, e.firma y solicitudes.
¿Cuál de los caminos es el tuyo?
Elige por si tu cliente te dio su RFC — eso decide más que cómo cobraste. En un mostrador, la mayoría no lo da: esas ventas son el primer caso de la tabla, y son casi siempre el grueso del mes.
| Tu caso | Ruta | Qué necesita |
|---|---|---|
| Nadie te pidió factura — efectivo, tarjeta, transferencia, lo que sea, sin RFC de por medio. El grueso de un mostrador. | POST /v1/invoices/global |
El período (periodicidad/meses/año) y tus ventas del mes agrupadas por tasa de IVA. Sin receptor: lo arma Winal. |
| Tu cliente te dio su RFC y cobraste tú, fuera de Winal — efectivo, transferencia directa a tu banco, terminal ajena | POST /v1/invoices/pue |
El total, el RFC real de tu cliente y la forma de pago del catálogo del SAT. Sin payment_intent_id. |
Tu cliente te dio su RFC y cobró Winal, y ese cobro ya está succeeded |
POST /v1/invoices |
El payment_intent_id y el RFC real de tu cliente. La forma de pago se deduce del método real del cobro. |
| Tu cliente te dio su RFC, pero todavía no te pagan o te van a pagar en parcialidades | POST /v1/invoices/ppd + REP |
El total acordado y el RFC real de tu cliente. Cada pago posterior se documenta con su complemento de pagos. |
| Que el cliente se facture solo, con su RFC real y el código de su ticket | Autofactura pública | El receipt_code impreso en el ticket y el RFC real de tu cliente — el genérico ya no vale aquí tampoco. |
El SAT no admite un CFDI de ingreso individual con el RFC genérico nacional
XAXX010101000 — ni siquiera con la tercia correcta (616 + S01 +
PUBLICO EN GENERAL, sin acento).
Esas ventas se acumulan siempre en la
factura global del período. Por eso POST /v1/invoices,
POST /v1/invoices/pue y POST /v1/invoices/ppd ahora exigen el RFC real de
tu cliente: mándales el genérico y Winal lo rechaza sin gastar un timbre.
El rechazo es local y el mismo con cualquier PAC: la regla vive en la ruta de emisión, no en
el timbrado, así que llega ANTES de resolver tus credenciales del PAC o gastar un timbre —con
finkok y con facturama por igual. Mandar el genérico nacional a
POST /v1/invoices, /pue o /ppd responde siempre
400 invoice.global_required_for_publico_en_general
— y lo hace antes de revisar si el resto del receptor (nombre, régimen, uso) está bien: un
RFC genérico nacional nunca llega a ver invoice.receptor_incoherente, sin importar qué
traigan esos otros campos. El arreglo es el mismo: usa la factura global.
PPD declara ante el SAT que el pago está
pendiente, obliga a complementos de pago que nunca van a llegar y le impide a tu cliente acreditar
el IVA en el mes de la operación. Una venta ya cobrada es PUE, y por eso existe una
ruta propia.
Si el PAC no contesta: el único error que NO significa "no se timbró"
Vale para las cuatro rutas de emisión (POST /v1/invoices, /pue,
/ppd y /global) y también para el REP y la nota de crédito. Casi todos los
rechazos de esta página son firmes: el PAC miró tu comprobante y dijo que no. Dos no lo son, y
confundirlos con los demás es lo que emite un segundo CFDI por la misma venta.
code | Qué pasó | Qué hacer |
|---|---|---|
invoice.pac_unreachable |
El PAC no respondió (red, mantenimiento, indisponibilidad suya). No hubo veredicto: pudo haber timbrado y haberse perdido solo la respuesta. | Reintenta la MISMA llamada con la MISMA Idempotency-Key, con backoff. Esa llave no se queda con el fallo pegado: la repetición REANUDA ese mismo comprobante —misma fila, misma fecha de expedición, mismo XML— y le pide al PAC evidencia de si ya lo timbró. No emitas de nuevo y no generes una llave nueva: una llave nueva es una venta nueva para Winal, y ahí sí acabas con dos CFDI. También puedes cerrarlo por POST /v1/invoices/{id}/retry. |
invoice.pac_unreachable_unverified |
Lo mismo, y tu PAC no publica la consulta que permitiría recuperar el timbre. El resultado es incierto y nadie lo puede aclarar preguntando. | No reintentes automáticamente. Aquí la Idempotency-Key no se libera, justamente para que tu bucle no emita un segundo comprobante. Comprueba en la consola de tu PAC si ese CFDI existe y, solo si no existe, ciérralo con POST /v1/invoices/{id}/retry. |
Un rechazo firme (invoice.pac_error,
invoice.cfdi_field_invalid…) se arregla corrigiendo y volviendo a emitir.
Un pac_unreachable se arregla repitiendo lo mismo. Tratar el segundo como el
primero es exactamente cómo se timbra dos veces la misma venta — y un CFDI de más no se borra: se
cancela, y los dos quedan en tu historial fiscal. Los dos códigos están declarados en el contrato
de las cuatro rutas, así que tu SDK los trae y puedes ramificar por code sin
adivinarlos.
Venta con RFC: facturar sin cobro
POST /v1/invoices/pue emite un CFDI de ingreso, método de pago
PUE (una sola exhibición), por una venta que ya está pagada y que no pasó por
ningún cobro de Winal. Es el caso de la farmacia que vende en el mostrador, cobra en efectivo, y
ese cliente sí te dio su RFC y quiere su factura nominativa. Si no lo dio, esa venta no pasa
por aquí: se ampara en la factura global del período.
Como no hay payment_intent del cual deducirla, tú declaras la forma de pago
del catálogo c_FormaPago del SAT. Es el único campo que esta ruta pide de más frente
a las otras.
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
total_minor | integer, requerido | Total de la venta en centavos, IVA incluido. Entero, jamás decimal. Debe ser positivo. |
receptor | objeto, requerido | Los cinco campos de siempre: rfc, nombre, uso_cfdi, regimen_fiscal, cp. Ninguno es opcional. |
forma_pago | string, requerido | Clave de dos dígitos del catálogo c_FormaPago — la tabla completa abajo. 99 no se acepta aquí. |
currency | string, opcional | ISO 4217; por defecto MXN. Ver los límites de hoy antes de mandar otra. |
descripcion | string, opcional | Descripción del concepto. Si se omite, se usa la genérica (Servicios). |
conceptos | arreglo, opcional | IVA por línea para un ticket con tasas mezcladas — ver Conceptos. Si se omite, tasa única del 16%. |
serie | string, opcional | Serie de ESTE comprobante — ver Serie y folio. Si se omite, la serie_default de tu perfil fiscal. |
folio | string, opcional | Folio de ESTE comprobante (tu numeración de ticket) — ver Serie y folio. Sin default. |
Ejemplo
curl -s https://api.winal.com.mx/v1/invoices/pue \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"total_minor": 23200,
"currency": "MXN",
"forma_pago": "01",
"receptor": {
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
},
"descripcion": "Venta de mostrador",
"serie": "CAJA-2",
"folio": "00981"
}'
$232.00 cobrados en efectivo: Winal desglosa 200.00 de base y
32.00 de IVA, exactos al centavo. El receptor de arriba es un RFC de ejemplo —usa el
real de tu cliente—; los cinco campos siguen siendo obligatorios (ver
la regla completa del receptor si tu
cliente sí opera con el genérico de residente extranjero XEXX010101000, que es el
único genérico que esta ruta todavía acepta). serie/folio son opcionales
—omítelos y no pasa nada— pero un POS con varias cajas normalmente sí los manda.
invoice){
"id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"object": "invoice",
"status": "stamped",
"uuid_fiscal": "...",
"serie": "CAJA-2",
"folio": "00981",
"total_minor": 23200,
"base_minor": 20000,
"iva_minor": 3200,
"currency": "MXN",
"receptor": { "...": "..." },
"pac": "finkok",
"metodo_pago": "PUE",
"parcialidades": 0,
"created_at": "...",
"updated_at": "..."
}
No verás payment_intent_id (no hay cobro que colgar) ni
saldo_insoluto_minor (una PUE nace liquidada): Winal omite los campos nulos en
vez de devolverlos en null. serie es la del request si la mandaste, si no
la de tu perfil fiscal, o se omite si ninguna de las dos aplica; folio es el que
mandaste, o se omite (ver Serie y folio para límites y qué rutas los
aceptan). Después puedes descargar el XML y el PDF con el id.
La forma de pago: catálogo c_FormaPago del SAT
La clave viaja verbatim al PAC y de ahí al SAT, así que Winal la valida contra el catálogo
real antes de gastar un timbre: una cadena inventada ("efectivo", "1",
"07") se rechaza en milisegundos con
invoice.invalid_forma_pago
y sin consumir folio.
| Clave | Forma de pago | Cuándo la usas |
|---|---|---|
01 | Efectivo | La venta de mostrador de una farmacia o una tienda de abarrotes. |
02 | Cheque nominativo | Te pagaron con cheque. |
03 | Transferencia electrónica de fondos | SPEI, DiMo o CoDi recibidos directo en tu banco. |
04 | Tarjeta de crédito | Cobro con tarjeta en una terminal que no es de Winal. |
05 | Monedero electrónico | |
06 | Dinero electrónico | |
08 | Vales de despensa | |
28 | Tarjeta de débito | Igual que 04, cuando sabes que fue débito. |
29 | Tarjeta de servicios | |
30 | Aplicación de anticipos | Aplicas un anticipo que ya habías facturado. |
31 | Intermediario de pagos | |
99 | Por definir | Solo en PPD. En esta ruta se rechaza con invoice.forma_pago_requires_ppd: un comprobante PUE declara una venta ya pagada, así que decir que la forma está "por definir" es una contradicción y el SAT la rechaza. |
Winal acepta además el resto de claves vigentes del catálogo —12, 13,
14, 15, 17, 23, 24,
25, 26 y 27, las formas de extinción de obligaciones— y las
manda tal cual; su definición exacta está en el Anexo 20. Lo que no existe son los huecos
del catálogo: 07, 09, 10, 11, 16
y 18–22 no son claves válidas, por eso la validación es una lista blanca
y no un rango.
Idempotencia: obligatoria, y aquí más que en ningún lado
Idempotency-Key es un header requeridoPOST bajo /v1/invoices timbra un comprobante fiscal con costo real,
así que la llave es obligatoria: sin ella, la respuesta es 400
idempotency_key_required.
En esta ruta importa el doble: como no hay cobro asociado, no existe ningún índice de unicidad
que te salve — dos ventas distintas del mismo día, del mismo importe y al mismo público en
general son legítimas y se facturan por separado, así que el sistema no puede adivinar cuál es un
duplicado. Un reintento tras timeout sin llave timbraría un segundo CFDI, y un CFDI de más
hay que cancelarlo ante el SAT. Genera un UUID por venta, no por request.
Idempotency-Key es de tu ambiente, y vale para los tres comprobantes
La llave identifica una operación dentro del ambiente de la credencial que la manda: tu
sk_test_ y tu sk_live_ no comparten espacio de llaves, ni aquí ni en la
factura, ni en la nota de crédito, ni en el complemento de pago. Así que si derivas la llave de tu
folio de ticket —lo recomendable— y esos folios ya pasaron por tu integración de pruebas, tu primera
venta real sí se factura: la misma llave con una llave de producción es una petición NUEVA y
emite su propio comprobante, en vez de devolverte el CFDI de prueba como si fuera el fiscal. Lo que
no cambia es la garantía dentro de un ambiente: misma llave y misma credencial = un solo comprobante
y un solo timbre.
Errores propios de esta ruta
| code | Cuándo |
|---|---|
invoice.invalid_body | Falta total_minor o no es positivo. |
invoice.invalid_receptor | Falta receptor o alguno de sus cinco campos. |
invoice.invalid_forma_pago | Falta forma_pago, o la clave no está en el catálogo. |
invoice.forma_pago_requires_ppd | Mandaste "99" en una PUE. |
invoice.global_required_for_publico_en_general | El rfc es el genérico nacional XAXX010101000 — rechazo CFDI40130, se revisa antes que cualquier otro campo del receptor. Usa POST /v1/invoices/global. |
invoice.receptor_incoherente | RFC genérico de residente extranjero (XEXX010101000) sin la tercia regimen_fiscal 616 + uso_cfdi S01. (El nacional nunca llega aquí: lo atrapa la fila de arriba primero.) |
invoice.invalid_currency | currency no es un código ISO 4217. |
invoice.conceptos_sum_mismatch | Los conceptos no suman total_minor al centavo. |
invoice.livemode_mismatch | La llave (sk_test_/sk_live_) no corresponde al ambiente del PAC de tu perfil. Sin cobro de por medio, la llave es la única señal de ambiente que existe. |
invoice.cfdi_field_invalid | serie o folio excede el largo máximo, o trae | — ver Serie y folio. |
invoice.pac_error | El PAC rechazó el timbrado. El CFDI queda persistido en error —auditable— y el mensaje trae el detalle del proveedor. |
Facturar un cobro que procesó Winal
El caso "te cobro Y te facturo": un CFDI de ingreso, método de pago PUE, sobre
un payment_intent ya succeeded — de un cliente que sí te dio su RFC
al pagar. Aquí no mandas forma_pago: Winal la deriva del método real del cobro
— tarjeta (incluidos MSI y card-present) → 04, SPEI/DiMo/CoDi → 03,
OXXO/Paynet/efectivo → 01.
curl -s https://api.winal.com.mx/v1/invoices \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"payment_intent_id": "a55fc12f-0af3-4858-ba84-52a3e7240f52",
"receptor": {
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
},
"serie": "CAJA-2",
"folio": "00981"
}'
El monto sale del cobro, no del cuerpo: por construcción no puedes facturar por un importe
distinto del que cobraste. Si el intent no existe responde
invoice.intent_not_found; si
todavía no está liquidado,
invoice.intent_not_succeeded;
y si ese cobro ya tiene un CFDI vivo,
invoice.already_exists — ese sí
es un backstop de unicidad, porque aquí sí hay un cobro del cual colgarse. Si el timbrado tiene
éxito, la respuesta es un invoice stamped con uuid_fiscal,
serie y folio —serie/folio son opcionales, ver
Serie y folio; shape completo en
Referencia de API → Facturas.
Mandar el RFC genérico nacional XAXX010101000 a esta ruta (o a
/pue//ppd) se rechaza de inmediato con 400
invoice.global_required_for_publico_en_general
(rechazo CFDI40130 del SAT): un CFDI de ingreso individual a público en general no
existe, tiene que ir por la factura global del período — ver el aviso arriba de
¿Cuál de los caminos es el tuyo?. Esta comprobación va primero que
cualquier otra del receptor: no importa si nombre/regimen_fiscal/uso_cfdi
vienen correctos o no, el genérico nacional nunca llega a verlos.
El genérico de residente extranjero (XEXX010101000) es distinto: no cae en la
prohibición de arriba, así que sí puede timbrarse en un CFDI individual — pero el SAT le exige la
tercia completa (regimen_fiscal 616, uso_cfdi S01;
el nombre, a diferencia del nacional, es el real de tu cliente). Winal valida esa coherencia ANTES de
mandar el CFDI al PAC y responde 400
invoice.receptor_incoherente
diciéndote cuál de las dos falló. Con un RFC real (ni XAXX ni XEXX), ninguna de las dos reglas
aplica: el nombre es el de tu cliente y eliges el uso_cfdi que te pida (G01,
G03, …).
Serie y folio: tu propia numeración
El Anexo 20 deja Serie y Folio como atributos opcionales del
comprobante — el identificador fiscal único de un CFDI es el uuid_fiscal del timbre,
no estos dos. En la práctica sirven de control interno: el POS de tu mostrador ya lleva su
propia numeración de tickets ("caja 2, folio 981"), y Winal te deja escribirla tal cual en el
comprobante en vez de obligarte a correlacionarla por fuera.
| Campo | Tipo | Descripción |
|---|---|---|
serie | string, opcional, máx. 25 caracteres | Serie de ESTE comprobante. Si la omites, se usa la serie_default de tu perfil fiscal; si tampoco hay una configurada, el atributo Serie simplemente no se escribe. |
folio | string, opcional, máx. 40 caracteres | Folio de ESTE comprobante — tu propia numeración de ticket. Sin default de cuenta (a diferencia de serie): si lo omites, el atributo Folio no se escribe. |
Los dos límites son los del XSD del SAT (cfdv40.xsd), no un número que nos inventamos;
y ninguno de los dos puede llevar el carácter | — es el separador de la cadena
original que se firma, así que aparecer ahí desplazaría el resto del comprobante. Un valor que se
pasa de largo o trae | se rechaza EN LA PUERTA, antes de reservar tu
Idempotency-Key o intentar nada, con
invoice.cfdi_field_invalid y
el nombre del campo (Serie o Folio) en el mensaje.
| Ruta | ¿Acepta serie/folio? |
|---|---|
POST /v1/invoices | Sí. |
POST /v1/invoices/pue | Sí. |
POST /v1/invoices/ppd | Sí. |
POST /v1/invoices/global | Sí — es la numeración PROPIA de tus globales (p. ej. "G"/"0001" para la de enero), sin relación con el no_identificacion por línea que numera cada TICKET que la global agrupa (ver esa sección: son dos numeraciones distintas, a dos niveles distintos). |
POST /v1/invoices/{id}/payments (REP) | Sí — su PROPIA serie/folio, independiente de la serie/folio de la factura PPD que liquida (que viaja como documento relacionado, con sus propios atributos internos del Anexo 20). |
POST /v1/invoices/{id}/credit-notes y /credit-notes/pue | Sí — muchos comercios llevan una serie PROPIA para sus notas de crédito (p. ej. "NC"), distinta de la de sus facturas. |
serie/folio que mandas ya se usó antes, ni
te avisa. Es una decisión, no un descuido: el identificador fiscal único ante el SAT es el
uuid_fiscal, así que un serie/folio repetido no es un CFDI
inválido — y hay razones legítimas para repetirlo (un comercio que reinicia su numeración al
cambiar de ejercicio fiscal, o varias sucursales con series independientes que tú administras).
La numeración es tuya: si necesitas detectar un duplicado, tu propio POS —que es quien la
asigna— ya sabe si la repitió.
GET /v1/invoices/{id}/pdf lo genera tu PAC con su propia
plantilla; si necesitas el importe con letra en un formato tuyo, calcúlalo tú a partir de
total_minor — no lo busques en ningún campo de la API, porque no existe.
Conceptos con IVA mezclado (retail: 0%, exento, 8% frontera)
Por default un CFDI se timbra como un único concepto a la tasa general del 16%. Un ticket de
RETAIL casi nunca es así de simple: en el mismo ticket conviven productos gravados al 16%,
alimentos/medicinas a tasa 0% (sí gravados, IVA en cero), servicios exentos (sí
objeto de impuesto pero SIN traslado de IVA — fiscalmente distinto de tasa 0%) y, si el
emisor opera en la región fronteriza, líneas al 8% (estímulo fiscal). El campo opcional
conceptos[] — disponible en las cuatro rutas de emisión
(POST /v1/invoices, POST /v1/invoices/pue,
POST /v1/invoices/ppd y POST /v1/invoices/global)
— declara el tratamiento de IVA por línea en vez de forzar una sola tasa a todo el
comprobante. En la factura global, además, cada línea puede llevar no_identificacion
(el folio del ticket que ampara) — detalle abajo.
curl -s https://api.winal.com.mx/v1/invoices/pue \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"total_minor": 15600,
"forma_pago": "01",
"receptor": {
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
},
"conceptos": [
{ "importe_minor": 11600, "treatment": "tasa16", "descripcion": "Artículos de perfumería" },
{ "importe_minor": 4000, "treatment": "exento", "descripcion": "Medicamento de patente" }
]
}'
$156.00 de ticket: 116.00 gravados al 16% y 40.00 exentos.
El comprobante queda con base_minor 14000 (los 100.00 gravados más los
40.00 exentos) e iva_minor 1600 — la línea exenta no traslada IVA, que
es justo lo que la distingue de una de tasa 0%.
Cada elemento de conceptos[] es un CfdiConceptoDto:
| Campo | Tipo | Descripción |
|---|---|---|
importe_minor | integer, requerido | Total de la línea en centavos (incluye su IVA). Igual que el resto de la API: entero, jamás decimal. |
treatment | string, requerido | Tratamiento de IVA de la línea: tasa16, tasa8 (frontera), tasa0 (alimentos/medicinas) o exento. |
clave_prod_serv | string, opcional | Clave ProdServ del SAT de la línea. Si se omite, se usa una clave genérica. |
clave_unidad | string, opcional | Clave de unidad del SAT de la línea. Si se omite, se usa una clave genérica. |
descripcion | string, opcional | Descripción de la línea. Si se omite, se usa una descripción genérica. |
descuento_minor | integer, opcional | Descuento de la línea en centavos, con impuestos incluidos — igual que importe_minor. Tiene que ser MENOR que el importe. Ver Descuentos por línea. |
cantidad_micro | integer, opcional | Cuántas unidades se vendieron, en millonésimas de unidad (3 piezas → 3000000; medio kilo → 500000). Se manda junto con valor_unitario_micro. Ver Cantidad y valor unitario. |
valor_unitario_micro | integer, opcional | Precio de UNA unidad sin impuestos, en millonésimas de peso ($45.00 → 45000000). Se manda junto con cantidad_micro. Ver Cantidad y valor unitario. |
conceptos es completamente opcional: si se omite (o se manda vacío), Winal sigue
emitiendo el concepto único de tasa 16% de siempre — nada cambia para quien ya integró antes de
que existiera este campo.
Cantidad y valor unitario: que la factura diga lo mismo que el ticket
Sin declararlos, cada concepto sale del comprobante como una unidad del valor de la línea:
una farmacia que vende 3 cajas a $45 emite un CFDI que dice «1 unidad de $135». Es válido ante
el SAT, pero describe mal la venta — y eso es lo que el cliente lee. Con
cantidad_micro y valor_unitario_micro el comprobante dice lo mismo que el
papel que el cliente tiene en la mano.
"conceptos": [
{
"importe_minor": 15660,
"treatment": "tasa16",
"descripcion": "Caja de analgésico 500 mg",
"clave_unidad": "H87",
"cantidad_micro": 3000000,
"valor_unitario_micro": 45000000
},
{
"importe_minor": 4640,
"treatment": "tasa16",
"descripcion": "Producto a granel",
"clave_unidad": "KGM",
"cantidad_micro": 500000,
"valor_unitario_micro": 80000000
}
]
| Línea | Cantidad | ValorUnitario | Importe |
|---|---|---|---|
| Caja de analgésico | 3.000000 | 45.000000 | 135.00 |
| Producto a granel | 0.500000 | 80.000000 | 40.00 |
Eso es lo que queda escrito en el concepto del CFDI: Cantidad y ValorUnitario
con los seis decimales de cantidad_micro/valor_unitario_micro, e
Importe con los dos decimales de siempre — es la BASE de la línea, sin IVA
(135.00, no los 156.60 con IVA de importe_minor: 16% de
135.00 son los 21.60 que faltan). Importe no pasa a seis
decimales con esta capacidad; solo lo hacen los dos atributos nuevos.
Las dos cifras van en millonésimas —seis decimales, los que admite el Anexo 20 en ambos
atributos— y como enteros, igual que el resto de la API. La cantidad, en millonésimas de
unidad: 3 piezas son 3000000, medio kilo 500000. El valor unitario,
en millonésimas de peso: $45.00 son 45000000. No es capricho: el litro de gasolina
se vende a 23.459 pesos, y con centavos ese precio no se puede escribir.
ValorUnitario y el Importe del concepto son la base del Anexo 20,
no el precio de mostrador — como ya ocurre hoy con el importe de la línea. Un ticket de «3 × $45 con
IVA» se factura declarando el valor unitario sin IVA. Es la causa número uno de rechazo del PAC en
este campo; la número dos es mandar el precio en centavos.
{
"error": {
"type": "invalid_request_error",
"code": "invoice.pac_error",
"message": "El PAC rechazó el timbrado (CFDI a0182936-9b76-4989-9ff2-581f78a9240f quedó en 'error'): Finkok CFDI40167: El valor del campo Importe no se encuentra entre el limite inferior y superior permitido. (El valor del atributo Conceptos/Concepto:Importe:130.00 del Concepto con ClaveProdServ:01010101 y NoIdentificacion:, es menor al limite inferior:134.99 o es mayor al limite superior:135.01) — el importe de un concepto no coincide con cantidad × valor unitario. El SAT tolera un centavo arriba o abajo; el mensaje del PAC dice los límites exactos que esperaba. Winal escribe los tres números tal como se los manda, sin corregirlos: revise cuál de los tres está mal en el ticket. Dos causas típicas: (a) el valor unitario va CON impuestos —el 'ValorUnitario' y el 'Importe' del concepto son la BASE, sin IVA ni IEPS—, o (b) 'valor_unitario_micro' se mandó en centavos: va en millonésimas de peso ($45.00 son 45000000). Corregirlo es emitir un comprobante NUEVO: el reintento reexpide este mismo XML.",
"doc_url": "https://winal.com.mx/docs/errores.html#err-invoice.pac_error",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
Reconócelo por invoice.pac_error (400) y por los nombres de los tres atributos
(Importe, Cantidad, ValorUnitario) en el texto — el mensaje
exacto depende de tu PAC, éste es de Finkok. El propio mensaje te dice la salida: emite un
comprobante nuevo con los números corregidos. POST /retry no sirve aquí —reexpide
TAL CUAL los mismos tres números, así que fallaría exactamente igual—, y por eso en este caso el
mensaje NO te ofrece esa ruta, a diferencia de los rechazos que sí se resuelven reintentando.
(Respuesta real, capturada el 2026-09-04 emitiendo a propósito un trío incoherente contra el
PAC: 3 × $45.00 declarado como $130.00. De ahí sale también la tolerancia, que no la
inventamos: el SAT admite un centavo arriba o abajo de cantidad × valor unitario
—el propio mensaje dice «límite inferior 134.99, superior 135.01»—, así que un redondeo normal de
tu punto de venta no te va a rebotar.)
Los dos campos van juntos o ninguno: con solo uno, Winal tendría que dividir para inventar el
que falta, y ese número redondeado ya no sería el del ticket (invoice.cantidad_incompleta).
Omitir los dos deja el comportamiento de siempre, intacto para quien ya factura.
Si declaras cantidad, conviene mandar también la clave_unidad que corresponda: sin
ella el comprobante dirá «3 Actividad» —la clave genérica de ACT que Winal usa
por defecto—, que es justo la descripción confusa que estos campos vienen a eliminar. Winal no la
exige —rechazar por eso dejaría sin facturar a un comercio cuyo catálogo no las tiene—. Las más
usadas del catálogo c_ClaveUnidad del SAT para retail:
clave_unidad | Unidad |
|---|---|
H87 | Pieza — la más común para producto físico contable (una caja, un frasco, una unidad). |
KGM | Kilogramo — producto a granel por peso. |
GRM | Gramo. |
LTR | Litro — líquidos a granel, el caso del litro de gasolina de arriba. |
MLT | Mililitro. |
XBX | Caja. |
PR | Par. |
E48 | Unidad de servicio — para servicios, no producto físico. |
ACT | Actividad — la clave genérica que Winal usa por defecto cuando se omite clave_unidad. |
Con IEPS por cuota conviven sin estorbarse: la cantidad_micro del concepto es
cuántas piezas se venden y la cantidad_micro del ieps es la cantidad
gravada por la cuota (litros, cigarros). Coinciden a veces y difieren a menudo — dos botellas
de 1 L son cantidad 2 y base 2 L; una de medio litro es cantidad 1 y base 0.5 —, así que se declaran
por separado y Winal no las cruza.
La nota de crédito total hereda la cantidad de las líneas de la factura que acredita: si la venta dijo «3 cajas a $45», la devolución dice lo mismo.
Descuentos por línea (promociones, 3+1)
Una promoción de mostrador se documenta con descuento_minor en la línea. Va en
centavos y con impuestos incluidos, igual que importe_minor: es como lo ve una
caja («le quité cincuenta pesos»), y Winal deriva de ahí lo que el CFDI necesita.
En el comprobante, la línea sale con su valor de lista en ValorUnitario e
Importe, el descuento en su propio atributo, y el impuesto se traslada sobre lo que de
verdad se cobra (Base = Importe − Descuento). El total del comprobante sigue siendo lo
que paga el cliente.
46400
con descuento_minor: 11600 — no como una línea aparte con la caja regalada a precio cero.
Una línea sin base gravable no la admite el esquema del SAT, así que Winal rechaza ese caso al
crear la factura (invoice.descuento_exceeds_importe) en vez de dejar que reviente en
el timbrado.
"conceptos": [
{ "importe_minor": 46400, "descuento_minor": 11600, "treatment": "tasa16",
"descripcion": "Antibiótico 500 mg — promoción 3+1 (4 cajas)" },
{ "importe_minor": 11600, "treatment": "tasa16", "descripcion": "Analgésico" }
]
En ese ejemplo el comprobante sale con SubTotal 500.00, Descuento 100.00,
IVA 64.00 y Total 464.00 — que es lo que el cliente paga.
importe_minor − descuento_minor, no la de los importes de lista.
conceptos[] debe igualar el total, al centavoimporte_minor debe ser EXACTAMENTE igual al
monto del cobro (POST /v1/invoices) o a total_minor (rutas
/pue y /ppd) — nunca un redondeo aproximado. Si no cuadra, la API
rechaza el comprobante ANTES de intentar timbrar:
{
"error": {
"type": "invalid_request_error",
"code": "invoice.conceptos_sum_mismatch",
"message": "La suma de los conceptos (27000) debe igualar el total del comprobante (28000), en centavos.",
"doc_url": "https://winal.com.mx/docs/errores.html#err-invoice.conceptos_sum_mismatch",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
treatment que no sea tasa16/tasa8/tasa0/
exento (sin distinguir mayúsculas) se rechaza igual de temprano con
400 invoice.invalid_treatment. Ambos errores llegan antes de tocar al PAC — un
concepto mal formado nunca genera un CFDI a medias.
IEPS por concepto: botanas, bebidas azucaradas, cigarros
El IEPS (Impuesto Especial sobre Producción y Servicios, clave 003 del catálogo del
SAT) es OPCIONAL y va dentro de un concepto (conceptos[],
arriba): agrega el campo ieps a cualquier línea que lo lleve. Pocos productos de
mostrador lo traen, pero en una farmacia SÍ existen y hasta ahora no eran facturables: botanas,
chocolates y dulces, bebidas energizantes, bebidas saborizadas con azúcares añadidos y cigarros. Una
línea sin ieps se comporta EXACTAMENTE como antes — el campo es aditivo.
Dos formas, mutuamente excluyentes, y las dos en ENTEROS
El SAT traslada el IEPS de dos maneras, y un concepto usa una de las dos, nunca las dos ni
ninguna: mandar ambas, o media forma, se rechaza. Igual que el resto de la API, ninguna cifra
viaja como decimal (regla 1) — la convención de la casa es la de millonésimas, los seis
decimales que el Anexo 20 admite en el atributo TasaOCuota.
| Campo | Tipo | Descripción |
|---|---|---|
tasa_bps | integer, opcional | Por TASA — porcentaje sobre el valor del bien, en puntos base (800 = 8.00%). Solo valen las trece tasas del catálogo c_TasaOCuota — tabla abajo. |
cuota_micro | integer (int64), opcional | Por CUOTA — importe fijo por unidad, en millonésimas de peso (3.0818 pesos/litro → 3081800). Rango del catálogo: de 0 a 59144900 (0.000000 a 59.144900 pesos). Exige cantidad_micro. |
cantidad_micro | integer (int64), opcional | Por CUOTA — cantidad gravada (litros, piezas…), en millonésimas de unidad (media botella de 500 ml → 500000). Debe ser positiva: el SAT exige Base > 0 en el traslado. Y hay un piso práctico: el resumen de impuestos del comprobante escribe la cantidad total de cada cuota con los dos decimales de la moneda, así que si la SUMA de las líneas que comparten una misma cuota no pasa de 5000 (0.005 unidades) se redondearía a 0.00 — se rechaza con invoice.cfdi_amounts_inconsistent antes de sellar nada. El redondeo es medio-par, de modo que 5000 exacto cae en el empate y baja al par 0.00 (se rechaza), y 5001 es la primera cantidad que pasa. Exige cuota_micro. |
Nota el nombre: cantidad_micro son litros (o piezas) en millonésimas, no
mililitros — el error clásico. Media botella de 500 ml son 0.5 litros, o sea
500000; mandar 500000000 (interpretando mililitros) declararía 500,000
litros gravados y el IEPS se comería el precio entero.
Las trece tasas del catálogo c_TasaOCuota (impuesto 003)
tasa_bps | Porcentaje |
|---|---|
0 | 0.00% |
300 | 3.00% |
600 | 6.00% |
700 | 7.00% |
800 | 8.00% — alimentos no básicos de alta densidad calórica (botanas, chocolates, dulces). |
900 | 9.00% |
2500 | 25.00% — bebidas energizantes. |
2650 | 26.50% |
3000 | 30.00% |
3040 | 30.40% |
5000 | 50.00% |
5300 | 53.00% |
16000 | 160.00% — tabaco labrado; la única tasa del sistema que pasa del 100%. |
Una tasa "razonable" que no esté en esta lista (16% de IEPS, por ejemplo) se rechaza con
400 invoice.invalid_ieps — nunca se manda al PAC para que la rechace él.
El orden de cálculo: el IVA grava el valor MÁS el IEPS
Es la regla que casi todo el mundo se salta, y no es una preferencia de Winal: la Ley del IVA (arts. 12, 18 y 23) manda que la base del IVA sea la contraprestación más las cantidades que se carguen por otros impuestos — el IEPS es uno de ellos. Un precio de mostrador ya trae todo dentro, así que Winal desglosa hacia atrás en ese mismo orden: primero sale el IVA (sobre valor + IEPS), y de lo que queda sale el IEPS (sobre el valor solo). Hacerlo al revés —IVA sobre el valor pelado, IEPS aparte— da un IVA de menos y un comprobante que no cuadra contra el que emite Winal.
Un ticket real de farmacia, con las tres líneas que motivaron el campo — botana con IEPS de tasa, bebida con IEPS de cuota, y una medicina sin IEPS, $241.00 en total:
curl -s https://api.winal.com.mx/v1/invoices/pue \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"total_minor": 24100,
"forma_pago": "01",
"receptor": {
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
},
"conceptos": [
{ "importe_minor": 10000, "treatment": "tasa16", "descripcion": "Botana",
"ieps": { "tasa_bps": 800 } },
{ "importe_minor": 2500, "treatment": "tasa16", "descripcion": "Bebida saborizada 500 ml",
"ieps": { "cuota_micro": 3081800, "cantidad_micro": 500000 } },
{ "importe_minor": 11600, "treatment": "tasa16", "descripcion": "Medicina de patente" }
]
}'
| Línea | Importe | Valor (base) | IEPS | IVA |
|---|---|---|---|---|
| Botana (IEPS tasa 8%) | $100.00 | $79.82 | $6.39 | $13.79 |
| Bebida saborizada (IEPS cuota, 0.5 L × $3.0818) | $25.00 | $20.01 | $1.54 | $3.45 |
| Medicina de patente (sin IEPS) | $116.00 | $100.00 | $0.00 | $16.00 |
| Comprobante | $241.00 | $199.83 | $7.93 | $33.24 |
Nota la botana: sobre $100.00 con 8% de IEPS y 16% de IVA, el reparto es $79.82 de
valor + $6.39 de IEPS + $13.79 de IVA. El IVA (13.79) es 16% de
$86.21 (valor + IEPS), no de $79.82 — 16% de 79.82 serían apenas
$12.77, un peso de menos. Cuadra tu sistema contra estos números si integras el cálculo por tu
cuenta antes de mandarnos el importe.
base_minor/ieps_minor/iva_minor del comprobante{
"id": "...",
"object": "invoice",
"status": "stamped",
"total_minor": 24100,
"base_minor": 19983,
"ieps_minor": 793,
"iva_minor": 3324,
"currency": "MXN",
"...": "..."
}
ieps_minor es un campo nuevo en la respuesta de cualquier factura —
0, y presente igual, si ningún concepto lo lleva (no se omite como los campos
null). base_minor sigue siendo el valor SIN impuestos —el
SubTotal del CFDI—; no incluye el IEPS.
Límites, sin adornos
| Qué no se admite hoy | Qué pasa si lo intentas |
|---|---|
Facturar con IEPS usando el PAC facturama |
invoice.ieps_pac_unsupported, antes de gastar un timbre: ese PAC arma el comprobante desde su propio JSON y el mapeo del IEPS a ese modelo no está verificado. Usa finkok —donde Winal arma y sella el XML— para cualquier concepto con IEPS. |
| REP, o nota de crédito PARCIAL, sobre una factura con IEPS en cualquier concepto | invoice.ieps_breakdown_unsupported. Los dos tendrían que trasladar el IEPS de la parte pagada o devuelta, y repartir un importe entre líneas exige saber CUÁL se pagó o se devolvió — lo sabe quien lo hizo, no Winal. Qué hacer: para una devolución parcial de un ticket con IEPS, cancela el comprobante y reexpídelo por lo que sí queda vendido. Por el lado del REP no hay forma de quedarse atrapado: una PPD con IEPS se rechaza al crearla (invoice.ppd_ieps_unsupported, ver PPD → Lo que no admite) en vez de timbrarse para después no poder cerrarse. |
| Nota de crédito por el TOTAL de una factura con IEPS | Sí se emite, y devuelve el IEPS al centavo. La nota total no recalcula nada: REEXPIDE las mismas líneas ya timbradas, cada una con su traslado del impuesto 003, así que el impuesto que devuelve es exactamente el que se trasladó. Es el caso del mostrador que devuelve el ticket completo — el refresco y la botana incluidos. Ver Nota de crédito. |
| Una cuota que se come el precio entero de la línea (cantidad mal declarada) | invoice.ieps_exceeds_importe, antes de timbrar. Revisa cantidad_micro: litros/piezas en millonésimas, no mililitros. |
| Un concepto con DOS traslados de IEPS a la vez (p. ej. el cigarro real: ad valorem + cuota por pieza) | No se modela: cada concepto admite un ieps, de una sola forma. Solo se puede declarar tasa o cuota para esa línea, nunca ambas juntas. |
PPD: factura por un monto acordado, cóbralo después en partes
Para servicios/proyectos que se liquidan en parcialidades sin un cobro previo, con un
cliente que sí te dio su RFC: emites un CFDI método de pago PPD (pago en
parcialidades o diferido) por el total acordado, y cada cobro real que llega después genera su
propio complemento de pagos 2.0 (REP) — un CFDI adicional que documenta esa parcialidad
ante el SAT.
Lo que una PPD no admite, y por qué se te dice ANTES de timbrar
Emitir una PPD es firmar una promesa: cuando tu cliente pague, la ley te obliga a emitir el REP
de esa parcialidad. Por eso POST /v1/invoices/ppd rechaza en la puerta las
facturas cuyo REP no podría emitirse — antes de gastar un timbre. El comprobante contrario ya no
tendría salida: timbrado ante el SAT, obligado a cerrarse, e imposible de cerrar.
| Qué rechaza | Error | Qué hacer en su lugar |
|---|---|---|
Un concepto con ieps |
invoice.ppd_ieps_unsupported |
Factúralos en PUE al cobrarlos: el IEPS sí se emite ahí, con su traslado del 003 por línea. Un REP todavía no sabe trasladar el IEPS de la parcialidad. |
| Conceptos con tasas de IVA mezcladas (p. ej. 16% junto a exento) | invoice.ppd_mixed_rate_unsupported |
Una PPD por tasa (una factura para lo gravado y otra para lo exento o a tasa 0), o la canasta completa en PUE al cobrarla. Un REP de tasa mixta exigiría repartir cada parcialidad entre las tasas. |
finkok), Winal genera y sella el CFDI tipo P por Anexo 20 —comprobante en cero
con moneda XXX, y el dinero en el complemento Pagos 2.0— con la misma cadena original de
la hoja XSLT del SAT y el CSD de tu comercio. Antes ese camino timbraba la PPD y después no podía
emitir su REP: la promesa que no se puede cumplir, descubierta al cobrar.
Si algún día conectas un PAC que no emita el complemento, ya no podrás emitir facturas PPD con él: la creación se rechaza con
invoice.ppd_rep_unsupported
antes de timbrar nada, en vez de dejarte descubrirlo con el comprobante ya ante el SAT.
POST /v1/invoices/ppd
curl -s https://api.winal.com.mx/v1/invoices/ppd \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"total_minor": 348000,
"currency": "MXN",
"receptor": {
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
},
"descripcion": "Servicios profesionales - anticipo PPD",
"serie": "PPD-A",
"folio": "500"
}'
total_minor (centavos, incluye IVA) es el único campo nuevo frente a la ruta con
cobro; currency y descripcion son opcionales (MXN y una
descripción genérica por default). No lleva forma_pago: en un PPD el SAT exige
forzosamente 99 "Por definir", y Winal la pone por ti. serie/folio
son opcionales, igual que en el resto de rutas de emisión (ver Serie y
folio); el REP que después liquide esta factura puede llevar SU PROPIA serie/folio, sin
relación con los de aquí (ver abajo).
GET /v1/invoices){
"id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"object": "invoice",
"status": "stamped",
"total_minor": 348000,
"base_minor": 300000,
"iva_minor": 48000,
"currency": "MXN",
"receptor": { "...": "..." },
"pac": "facturama",
"metodo_pago": "PPD",
"saldo_insoluto_minor": 348000,
"parcialidades": 0,
"created_at": "2026-07-07T23:02:30Z",
"updated_at": "2026-07-07T23:02:31Z"
}
saldo_insoluto_minor arranca igual al total_minor (nada pagado
todavía) y baja con cada REP; parcialidades cuenta cuántos REP ya se timbraron
contra esta factura.
POST /v1/invoices/{id}/payments — registra un pago (REP)
Cuando llega un pago que liquida (parte de) el saldo insoluto. Winal calcula la parcialidad, el saldo anterior/nuevo y timbra el REP. Hay dos variantes, y eliges una según quién cobró:
- Cobró Winal — mandas
payment_intent_id(un cobro yasucceeded, tuyo, correlacionado por ti con la factura PPD que corresponde) y nada más: el importe, la forma de pago y la fecha se LEEN del cobro. - Cobraste tú, por fuera — efectivo en el mostrador, tu propia terminal, una
transferencia que llegó directo a tu banco. Omites
payment_intent_idy declarasamount_minoryforma_pago. Ver abajo.
Mandar payment_intent_id y los campos del pago declarado responde
400 invoice.invalid_body. No es
quisquillosidad: declarar dos fuentes del mismo dato solo puede acabar en que discrepen —el cobro
dice $100.00 y el cuerpo $1,000.00— y entonces habría que elegir cuál de
las dos miente. Con un cobro de Winal, el cobro manda.
Cambio de compatibilidad. Hasta la versión anterior, mandar los dos NO era un error: se
usaba el payment_intent_id y los campos del pago declarado se descartaban en
silencio. Si tu integración los mandaba juntos por costumbre, ahora recibirá 400:
quita del cuerpo lo que sobra (con cobro de Winal, todo salvo serie/folio).
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-.../payments \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"payment_intent_id": "a55fc12f-0af3-4858-ba84-52a3e7240f52",
"serie": "REP-A",
"folio": "9001"
}'
InvoicePaymentResponse){
"id": "...",
"object": "invoice_payment",
"invoice_id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"payment_intent_id": "a55fc12f-0af3-4858-ba84-52a3e7240f52",
"parcialidad": 1,
"monto_minor": 55000,
"saldo_anterior_minor": 348000,
"saldo_insoluto_minor": 293000,
"currency": "MXN",
"status": "stamped",
"rep_uuid": "...",
"created_at": "...",
"updated_at": "..."
}
serie/folio del REP: se timbran, pero no vuelven en esta respuestaserie/folio son opcionales (ver Serie y folio)
y son la numeración PROPIA de este REP — no la de la factura PPD que liquida, que ya quedó fija al
timbrarla y viaja dentro del complemento como el documento relacionado. Se escriben en el XML del
complemento tal cual los mandas, pero InvoicePaymentResponse —a diferencia de
invoice— no los expone de vuelta: para verlos, descarga el
GET .../payments/{pid}/xml.
stampederror (p. ej. por credenciales de PAC no
configuradas, como en este servidor de pruebas), registrar un pago falla con
400 invoice.not_stamped — un REP no puede timbrarse contra una factura que en
primer lugar no timbró.
El mismo endpoint, sin cobro de Winal: pago en efectivo o con tu propia terminal
Es el caso del POS que cobra por fuera y solo usa Winal para facturar. La ley te obliga a timbrar
el REP en cuanto recibes el pago de una PPD, así que la puerta no puede exigirte un
payment_intent que en tu flujo no existe. Declaras lo que aquí no se puede deducir de
nada —cuánto, cómo y, si no fue hoy, cuándo— y el resto del trámite fiscal es idéntico:
mismo armador, misma parcialidad, mismos tres saldos absolutos, mismo XML.
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-.../payments \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 55000,
"forma_pago": "01",
"serie": "REP-A",
"folio": "9001"
}'
| campo | qué es |
|---|---|
amount_minor | Obligatorio. Importe recibido en centavos (incluye IVA). Positivo, y no puede exceder el saldo insoluto — excederlo responde invoice.payment_exceeds_saldo con las dos cifras. |
forma_pago | Obligatorio. Clave c_FormaPago del SAT: 01 efectivo, 03 transferencia, 04 tarjeta de crédito, 28 débito… La 99 "Por definir" no vale aquí: es la que lleva la factura PPD que este complemento viene a liquidar, y un pago ya recibido tiene una forma concreta (invoice.invalid_forma_pago). |
fecha_pago | Opcional; por defecto, ahora. No puede ser futura (el SAT rechaza un FechaPago posterior a la expedición del complemento) ni de un día anterior al de expedición de la factura que liquida — la comparación es por día calendario en la zona horaria del lugar de expedición del emisor, no por instante: un pago del mismo día con hora anterior a la del timbrado de la factura SÍ vale (el caso normal del mostrador, que registra el pago un rato después de recibirlo y a veces solo captura la fecha, sin hora). Las dos responden invoice.payment_fecha_invalid. Se admite una holgura de 5 minutos sobre el instante actual para el POS de mostrador sin NTP, no una licencia para fechar mañana: el extremo está medido contra el PAC — un pago fechado 4 min 59 s por delante se timbra sin problema. Tu fecha viaja al comprobante tal cual —nunca se recorta en silencio—; lo que Winal ajusta es la fecha de expedición del complemento, que es suya y que nunca queda antes del pago que documenta. |
currency | Opcional, MXN por defecto; debe coincidir con la de la factura (invoice.payment_currency_mismatch). |
serie/folio | Igual que en la otra variante: la numeración PROPIA de este REP. |
Idempotency-Key no es un extra, es lo único
Con payment_intent_id, el cobro es un ancla SEMÁNTICA: dos peticiones que nombran el
mismo cobro hablan del mismo dinero, y Winal lo impide en la base. Un pago declarado a mano no
tiene nada equivalente — "$550.00 en efectivo hoy" puede ocurrir dos veces el mismo día, y ser
dos parcialidades perfectamente legítimas. Lo único que distingue "otra vez lo mismo" de "un pago
más" es tu intención, y eso es exactamente lo que declara la llave.
Reintentar con la MISMA llave devuelve el REP que ya se timbró, sin timbrar otro ni volver
a mover el saldo. La protección vive en la fila del REP y no solo en la caché de la
respuesta, así que sobrevive a un 5xx y a la caducidad de la llave: la fila la
conserva en todos sus estados, incluido error. Y si aquel intento quedó
fallido, esa misma llave no devuelve el fallo congelado — REANUDA el complemento: reenvía
el MISMO comprobante (misma fecha de expedición, mismos saldos) marcado como reintento, de modo
que un PAC que sabe recuperar el timbre conteste con el folio anterior en vez de emitir otro. Ver
cuando el timbrado del REP falla.
Reintentar con una llave DISTINTA crea una parcialidad nueva: es tu responsabilidad no
mandar el mismo pago dos veces con llaves diferentes. Y reusar una llave con un cuerpo que
describe OTRO pago (otro importe, otra forma, otra fecha, otra serie o folio — o, en la variante
con cobro, OTRO payment_intent_id) responde
409 invoice.idempotency_conflict
nombrando la discrepancia, en vez de devolverte el REP anterior: devolvértelo dejaría ese segundo
pago sin documentar para siempre. Es el mismo trade-off de
POST /v1/invoices/pue, y por el mismo motivo.
Sin cobro del cual leer el livemode, el ambiente lo dicta la llave con la que llamas —
igual que en /pue y /ppd. Una sk_test_ contra un perfil
fiscal productivo responde
invoice.livemode_mismatch en
vez de timbrar el complemento fiscal REAL de tu comercio.
Y se cruza además contra el ambiente en que se timbró la factura PPD que liquidas, que es un
hecho de ese comprobante y no cambia. Es el caso que aparece el día que activas producción: las PPD
que dejaste abiertas mientras probabas siguen siendo de sandbox, así que una
sk_live_ contra una de ellas responde el mismo
invoice.livemode_mismatch — un complemento real relacionado a un UUID que en
producción no existe no se puede corregir ni sustituir. Cierra esas PPD en el mismo ambiente en que
nacieron, o vuelve a facturarlas.
POST /v1/invoices/{id}/payments/{pid}/retry — cuando el timbrado del REP falla
La SALIDA de un complemento que quedó en error, espejo exacto de
POST /v1/invoices/{id}/retry. Sin cuerpo: todo lo que determina el
comprobante ya está guardado en su fila, y reintentar es reenviar exactamente eso. Devuelve
200 con el REP (ya stamped) o explica por qué no se puede
(invoice.payment_not_retryable).
El retry reenvía exactamente el mismo comprobante: mismo importe, misma forma y fecha de pago, misma serie/folio, misma fecha de expedición. Eso es lo que le permite ser inocuo — pero también significa que no puede corregir nada. Si el PAC rechazó por un dato del propio complemento, ese dato ya quedó fijo en la fila y el retry volverá a recibir el mismo rechazo.
La salida de ese caso es la otra: registra el pago corregido con una
Idempotency-Key NUEVA. La llave anterior se queda anclada al REP rechazado, como
rastro de lo que se intentó — no se libera, y no debe liberarse: si se soltara, un reintento
posterior emitiría un complemento distinto y un envío que sí hubiera llegado a timbrarse acabaría
duplicado ante el SAT. El mensaje del rechazo dice cuál de las dos salidas te toca.
Y hay una condición que el retry comprueba igual que la emisión: la factura PPD tiene que seguir
timbrada y viva. Si la cancelaste entre el fallo y el reintento, responde
invoice.not_stamped — una cancelada
conserva su UUID fiscal, pero ya no ampara la operación que el complemento documentaría. Vuelve a
facturar la venta y documenta el pago contra la factura nueva.
Vale para POST /v1/invoices/{id}/retry, para el retry del REP y para el de la nota de
crédito. El ambiente en que un comprobante se timbró es un dato de su propia fila y no cambia
nunca — tampoco el día que tu cuenta pasa a producción. Por eso, reintentar (o cancelar) uno que
nació en pruebas exige una sk_test_ y el perfil fiscal en sandbox; si
no, la respuesta es
invoice.livemode_mismatch. Sin
esta comprobación, un reintento con tu llave productiva timbraría un CFDI fiscalmente real ante
el SAT, con tu sello, por una venta de prueba. Y si lo que te quedó en error es un
comprobante de PRUEBA: no hay nada que cerrar — no tiene valor fiscal ni te obliga ante el SAT, así
que la venta real se factura como un comprobante nuevo.
Solo uno llega al PAC (el otro ni siquiera lo llama). El que pierde la carrera recibe
200 con el REP si el ganador ya lo timbró —el desenlace que pedía— y, si no,
409 invoice.payment_retry_conflict:
"en curso, consulta en un momento". Lo que no debes hacer al verlo es registrar el pago otra
vez, que es justo lo que invitaba a hacer la respuesta anterior.
curl -s -X POST https://api.winal.com.mx/v1/invoices/5cee05bd-.../payments/8f2a1c7e-.../retry \
-H "Authorization: Bearer $SK"
Si la respuesta fue
invoice.pac_unreachable o
invoice.pac_unreachable_unverified,
el resultado es incierto: el PAC pudo haber timbrado y haberse perdido solo la respuesta.
Registrar el pago de nuevo (con una llave nueva) arma un complemento distinto —otra fecha de
expedición, otro XML— que el PAC ya no puede reconocer como el mismo, así que acabarías con
dos complementos ante el SAT por el mismo dinero. No se borran: se cancelan, y los dos
quedan en tu historial fiscal.
Lo correcto es siempre lo mismo: repetir la llamada con la misma
Idempotency-Key, o usar esta ruta. Con
invoice.pac_unreachable_unverified —tu PAC no publica la consulta de recuperación del
timbre— no reintentes automáticamente: comprueba primero en la consola de tu PAC si aquel
comprobante existe, y solo si no, reintenta.
GET /v1/invoices/{id}/payments — lista los REP de una factura
{ "object": "list", "data": [] }
GET /v1/invoices/{id}/payments/{pid}/xml — descarga el XML del REP
Devuelve application/xml con Content-Disposition: attachment;
filename="rep-{pid}.xml". Mismo 404/409 que el resto de
descargas de CFDI si el REP no existe o aún no está timbrado.
Factura global: el CFDI periódico a público en general
POST /v1/invoices/global es el caso principal de un mostrador, no un extra: el
comprobante periódico que agrupa TODAS las ventas del período de las que nadie pidió factura. Es
una obligación fiscal, no una comodidad, para quien vende al público en general — y hasta
ahora Winal no la emitía.
| Pregunta | Respuesta |
|---|---|
| ¿Qué agrupa? | El total de tus ventas del período que no se facturaron individualmente — nunca las que sí se pidieron y ya facturaste con otra ruta (ver el aviso de abajo). |
| ¿Con qué periodicidad? | La que declares: diaria, semanal, quincenal o —la habitual en un mostrador— mensual. Bimestral es exclusiva del Régimen de Incorporación Fiscal (621). |
| ¿Cuándo se emite? | Al cierre del período, con el total ya conocido: el mes de julio se cierra y se factura en agosto, nunca antes de que el período haya terminado. |
| ¿A quién factura? | Siempre al RFC genérico XAXX010101000, PUBLICO EN GENERAL (sin acento, es el literal exacto del catálogo del SAT) — lo arma Winal con el CP de tu perfil fiscal; tú no lo mandas. |
/pue, POST
/v1/invoices, /ppd o
autofactura—, esa venta YA quedó amparada por su propio CFDI y
no entra en la global: meterla otra vez duplicaría el ingreso y el IVA ante el SAT. La
global suma exclusivamente lo que se quedó sin comprobante individual — el resto del mostrador.
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
periodicidad | string, requerido | Clave de dos dígitos de c_Periodicidad: 01 diario, 02 semanal, 03 quincenal, 04 mensual, 05 bimestral. |
meses | string, requerido | Clave de c_Meses: 01–12 los meses naturales. Con periodicidad 05, las claves de BIMESTRE 13–18 (13 ene-feb … 18 nov-dic) — no el número de bimestre. |
anio | integer, requerido | Año de las operaciones. El SAT solo admite el año en curso o el inmediato anterior (la global de diciembre se emite en enero). |
forma_pago | string, requerido | Clave de c_FormaPago con la que se liquidó la operación de mayor monto del período (lo que pide la guía del SAT). 99 no se acepta aquí. |
conceptos | arreglo, requerido, ≥ 1 línea | El importe del período con su tratamiento de IVA — agrupado por tasa como mínimo, o detallado ticket por ticket con no_identificacion. Su SUMA es el total del comprobante: no hay total_minor aparte. |
currency | string, opcional | ISO 4217; por defecto MXN. |
sustituye_uuid | string, opcional | El uuid_fiscal de una global YA TIMBRADA de este comercio que ésta corrige — ver Corregir una global. Omítelo en una emisión normal. |
serie | string, opcional | Serie de ESTA global — ver Serie y folio. Una global suele llevar su PROPIA serie, distinta de la de tus facturas individuales. |
folio | string, opcional | Folio de ESTA global (tu numeración de comprobantes globales) — no confundir con no_identificacion por línea (ver abajo), que numera cada TICKET que la global agrupa, no el comprobante. |
Lo que no mandas: receptor y total_minor. El receptor lo fija el
SAT y lo construye Winal —con el CP de tu perfil fiscal
como domicilio del receptor, por la regla CFDI40149— y el total sale de sumar los
conceptos, así que no puede discrepar del desglose de IVA.
Ejemplo
curl -s https://api.winal.com.mx/v1/invoices/global \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"periodicidad": "04",
"meses": "07",
"anio": 2026,
"forma_pago": "01",
"conceptos": [
{ "importe_minor": 116000, "treatment": "tasa16", "descripcion": "Ventas gravadas del período" },
{ "importe_minor": 96500, "treatment": "exento", "descripcion": "Medicinas de patente" }
],
"serie": "G",
"folio": "0007"
}'
invoice){
"id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"object": "invoice",
"status": "stamped",
"uuid_fiscal": "...",
"serie": "G",
"folio": "0007",
"total_minor": 212500,
"base_minor": 196500,
"iva_minor": 16000,
"currency": "MXN",
"receptor": {
"rfc": "XAXX010101000",
"nombre": "PUBLICO EN GENERAL",
"uso_cfdi": "S01",
"regimen_fiscal": "616",
"cp": "06600"
},
"pac": "finkok",
"metodo_pago": "PUE",
"parcialidades": 0,
"created_at": "...",
"updated_at": "...",
"informacion_global": { "periodicidad": "04", "meses": "07", "anio": 2026 }
}
$2,125.00 del mes: $1,160.00 gravados al 16% ($1,000.00 de
base + $160.00 de IVA) y $965.00 exentos. El cp del receptor
de arriba es ilustrativo — en tu cuenta es SIEMPRE el mismo que el lugar de expedición de tu
perfil fiscal, nunca uno que tú mandes. metodo_pago es siempre PUE
(las operaciones del período ya se cobraron) y no verás payment_intent_id ni
saldo_insoluto_minor: una global no nace de ningún cobro de Winal.
serie/folio son los de ESTE comprobante (ver
Serie y folio); omítelos y se comportan como en cualquier otra ruta de
emisión. informacion_global y sustituye_uuid son justamente lo que
distingue a una global de cualquier otra factura: en un CFDI ordinario ambos viajan null
(por eso se omiten del JSON).
El folio del ticket, por línea
Cada elemento de conceptos[] admite no_identificacion (texto libre, hasta
100 caracteres): el folio del ticket de mostrador que esa línea ampara. Es el hilo que la guía del
SAT pide entre el comprobante global y las operaciones que agrupa. Quien puede detallar ticket por
ticket lo hace; quien no, agrupa por tasa como en el ejemplo de arriba — las dos formas son la
misma ruta.
"conceptos": [
{ "importe_minor": 5000, "treatment": "tasa16", "no_identificacion": "TICKET-00417" },
{ "importe_minor": 5000, "treatment": "tasa16", "no_identificacion": "TICKET-00418" }
]
Corregir una global ya timbrada
Una global mal capturada no se borra ni se retimbra sola: el SAT exige el mismo orden que para
cualquier CFDI con relacionados vivos — primero se emite el comprobante que la
sustituye (relación 04), y solo después se cancela la original con motivo
01 apuntando al nuevo folio fiscal.
- Emite la global correcta con
sustituye_uuid= eluuid_fiscalde la que estás corrigiendo, para el mismo período (periodicidad/meses/año idénticos). - Con el
uuid_fiscalque te devuelve esa emisión, cancela la original —POST /v1/invoices/{id}/cancelconmotive: "01"ysubstitution_uuidapuntando al nuevo folio (contrato completo, con el aviso de la demora del SAT para reconocer el UUID recién timbrado, en Cancelar un CFDI ya timbrado más abajo).
Entre esos dos pasos existen, LEGÍTIMAMENTE, dos globales vivas del mismo período: si el índice
único las prohibiera, la corrección no tendría ninguna primera jugada posible. Mientras no
sustituyas, solo puede haber una global viva por (ambiente + periodicidad mensual o
bimestral + meses + año) — un segundo intento del mismo período responde
invoice.global_already_exists
con el id de la que ya existe.
sk_test_ mientras
integras NO bloquea la global real de ese mismo mes, y no tienes que cancelarla antes de emitir la
que cuenta. Las dos coexisten, cada una con su propio folio fiscal, y solo la de producción entra a
tu consumo de folios. Tampoco se pueden relacionar entre ellas: una global real no puede declarar
que sustituye a una de pruebas (sustituye_uuid tiene que apuntar a una global
timbrada del mismo período y del mismo ambiente) — el CfdiRelacionados saldría
ante el SAT apuntando a un UUID que en producción no existe.
meses — el propio nodo del SAT no distingue el
día — así que ahí la única defensa contra el doble timbrado es tu Idempotency-Key
(obligatoria en todo POST /v1/invoices*).
Errores propios de esta ruta
| code | Cuándo |
|---|---|
invoice.invalid_body | Falta periodicidad o meses. |
invoice.invalid_forma_pago | Falta forma_pago, o la clave no está en el catálogo. |
invoice.forma_pago_requires_ppd | Mandaste "99": una global ampara operaciones YA cobradas. |
invoice.invalid_concepto | conceptos viene vacío, o algún importe_minor no es positivo. Aquí es obligatorio (al menos una línea). |
invoice.invalid_treatment | Un treatment no es tasa16/tasa8/tasa0/exento. |
invoice.invalid_currency | La currency no es un código ISO 4217 válido. Omite el campo para MXN por default. |
invoice.global_periodo_invalido | El período es incoherente: periodicidad fuera de catálogo, meses que no le corresponde (p. ej. "04" con periodicidad bimestral), año fuera del rango que admite el SAT, período que aún no comenzó, o bimestral en un emisor que no es RIF (621). |
invoice.livemode_mismatch | La llave (sk_test_/sk_live_) no corresponde al ambiente del PAC de tu perfil — la única señal de ambiente que existe, igual que en /pue y /ppd. |
invoice.global_already_exists | Ya hay una global viva (mensual o bimestral) para ese período en este ambiente. El mensaje trae el id de la existente. La global de pruebas del mismo mes no cuenta: la unicidad es por ambiente. |
invoice.invalid_sustituye_uuid | sustituye_uuid llegó como cadena vacía — omítelo por completo en una emisión normal. |
invoice.sustituida_not_found | El uuid_fiscal de sustituye_uuid no corresponde a ninguna global TIMBRADA de tu cuenta. |
invoice.sustitucion_periodo_distinto | La global que intentas sustituir es de OTRO período; una sustitución reemplaza el comprobante del MISMO período. |
invoice.cfdi_field_invalid | serie o folio del COMPROBANTE (no confundir con no_identificacion por línea) excede el largo máximo o trae | — ver Serie y folio. |
invoice.pac_error | El PAC rechazó el timbrado. El mensaje trae el detalle del proveedor. |
Las de configuración de cuenta de siempre —
invoice.fiscal_profile_missing,
invoice.pac_credentials_missing—
aplican igual que en el resto de rutas de facturación.
Cancelar un CFDI ya timbrado
POST /v1/invoices/{id}/cancel cancela ante el SAT cualquier CFDI de ingreso que hayas
timbrado por esta API — PUE, PPD o factura global — con los cuatro motivos
del Anexo 20. Es idempotente: cancelar una factura que ya está canceled
devuelve éxito sin volver a llamar al PAC. El uuid_fiscal del timbre no cambia:
es terminal, la cancelación no lo reemplaza (regla 4 — estados terminales inmutables).
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
motive | string, opcional | Motivo de cancelación del SAT: 01 (sustitución), 02, 03 o 04. Si se omite, 02 por defecto. |
substitution_uuid | string, opcional | uuid_fiscal del CFDI que sustituye a éste — obligatorio con motive: "01". |
Authorization y Idempotency-Key son requeridos, igual que en todo
POST bajo /v1/invoices* (regla 8): cancelar mueve el estado FISCAL de un
comprobante ante el SAT tanto como emitirlo.
Ejemplo
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-de0c-4961-98eb-e0cacfc6aae8/cancel \
-X POST \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{}'
Un cuerpo vacío es válido: motive cae al 02 por defecto. Para cancelar
con sustitución (motivo 01 — el caso de
corregir una global):
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-de0c-4961-98eb-e0cacfc6aae8/cancel \
-X POST \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"motive": "01",
"substitution_uuid": "5b21f6a0-7e3c-4a1e-9b3d-8f9c6d2a1234"
}'
InvoiceCancelResponse){
"object": "invoice",
"id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"status": "canceled",
"uuid_fiscal": "..."
}
Nota que esta respuesta es más chica que el invoice completo de las rutas de emisión
(sin total_minor, receptor, etc.): solo lo que confirma la cancelación.
Para el resto de los datos del CFDI, consulta GET /v1/invoices/{id}.
400
invoice.pac_cancel_error con
el detalle que dio el PAC. Qué hacer: reintenta la cancelación con backoff (un par de
minutos entre intentos, hasta unos 10) en vez de tratarlo como un fallo permanente o escalarlo de
inmediato. Lo vimos timbrando y cancelando de verdad contra el SAT: una corrida se propagó en 5
minutos, la siguiente en 2 — no hay forma de saber de antemano cuánto va a tardar ésta.
Errores propios de esta ruta
| code | Cuándo |
|---|---|
invoice.not_found | El id del CFDI no existe (o es de otro tenant). |
invoice.invalid_cancel_motive | motive no es 01, 02, 03 ni 04. |
invoice.cancel_substitution_required | Mandaste motive: "01" sin substitution_uuid. |
invoice.not_stamped | El CFDI no está stamped (nunca timbró, o cayó en error): solo se cancela lo que sí se timbró. |
invoice.cancel_requires_substitution | El CFDI tiene un REP o una nota de crédito VIVOS relacionados, y cancelaste con un motivo distinto de 01 — el SAT exige sustitución en ese caso. |
invoice.no_provider_ref | El CFDI no tiene referencia del PAC para cancelar — estado inconsistente; si lo ves, escala con el request_id. |
invoice.pac_cancel_error | El PAC rechazó la cancelación — incluido el "No Encontrado" por propagación de arriba. El mensaje trae el detalle del PAC. |
Descarga del CFDI (PUE, PPD o GLOBAL) — con tu llave, no confundir con Autofactura
Estas dos rutas descargan cualquier CFDI de tu cuenta ya timbrado — el que timbraste con
POST /v1/invoices, /pue, /ppd o /global — y
exigen tu Authorization, igual que el resto de /v1/*.
| Endpoint | Auth | Content-Type |
|---|---|---|
GET /v1/invoices/{id}/xml | Tu Authorization: Bearer sk_... | application/xml |
GET /v1/invoices/{id}/pdf | Tu Authorization: Bearer sk_... | application/pdf |
Ambos requieren que el CFDI esté stamped — si no, 400 invoice.not_stamped.
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-de0c-4961-98eb-e0cacfc6aae8/xml \
-H "Authorization: Bearer $SK" -o factura.xml
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-de0c-4961-98eb-e0cacfc6aae8/pdf \
-H "Authorization: Bearer $SK" -o factura.pdf
- Las de aquí arriba (
GET /v1/invoices/{id}/xml/pdf) son privadas: exigen tuAuthorizationy sirven cualquier CFDI timbrado de tu cuenta, sin importar por cuál de las cuatro rutas lo emitiste. Las usas tú, desde tu backend, para archivar o reenviar la factura a tu cliente. - Las de Autofactura
(
GET /public/autofactura/{slug}/invoices/{id}/xml/pdf) son públicas: sinAuthorization, llevanreceipt_code+rfccomo llave en el query string, y son las que llama la página/factura/{slug}que Winal sirve — para que tu cliente final descargue su propio comprobante desde su navegador, sin que tú tengas que exponerle tu clave secreta.
Bajar el XML/PDF de tus propias facturas con tu Authorization
sí funciona — es la ruta de arriba, no la pública.
Autofactura: que el cliente facture su propio ticket
Para el mostrador que no captura el RFC en el momento del cobro, pero deja la puerta abierta a
facturar después: cada cobro trae un receipt_code corto (formato W-XXXXX)
desde que se crea el payment_intent — es el dato que le pides al cliente para que
facture su propio ticket sin tu intervención, si al final sí quiere su factura con RFC.
CreateFromIntentAsync,
la de POST /v1/invoices), así que hereda la misma regla:
el receptor necesita un RFC real. Un ticket sin RFC no se pierde — ya está cubierto por la
factura global del período de tu comercio, sin que nadie tenga que hacer
nada. Autofactura es solo para quien SÍ quiere su comprobante nominativo: si alguien captura el
genérico XAXX010101000 en el formulario público, la Api lo rechaza con su propio código
—autofactura.generic_rfc_not_allowed—
antes incluso de resolver el slug o buscar el ticket, con un mensaje pensado para
alguien parado frente al mostrador, no para quien integra la API (ver el ejemplo abajo). La página
pública /factura/{slug} que Winal sirve ya no ofrece esa opción: no hay ninguna casilla
de "público en general" que marcar, el campo es Tu RFC a secas, y trae un enlace
"¿No tienes RFC?" que contesta la pregunta ANTES de que alguien la escriba y se lleve un rechazo. Si
aun así alguien teclea el genérico ahí, la propia página lo detecta al vuelo (sin ni siquiera llamar
a la Api) y muestra la misma explicación en tono neutro, no un error rojo — y si construyes tu propio
formulario en vez de usar el nuestro, el 400 de arriba te da el mismo texto para mostrar.
{
"id": "5cb2d99f-a290-45fc-81c5-acb5aa4b0d79",
"object": "payment_intent",
"amount_minor": 900,
"currency": "MXN",
"status": "requires_payment_method",
"client_secret": "pi_secret_TD3ZDUAfULIGL2N-...",
"receipt_code": "W-EMU7K",
"livemode": false,
"created_at": "2026-07-08T02:19:32.63073+00:00",
"updated_at": "2026-07-08T02:19:32.63073+00:00"
}
Imprímelo en el ticket físico (o mándalo por SMS/WhatsApp) junto con la URL pública de
autofactura de tu comercio: https://winal.com.mx/factura/{slug} — el
slug es el mismo identificador corto de tu tenant que se configura en
portal → Facturación → Autofactura (junto con el interruptor de habilitarla).
receipt_code nace con el payment_intent, así que una venta
facturada por POST /v1/invoices/pue —que por definición no tuvo cobro de Winal— no
tiene código de ticket que darle al cliente. Para esas ventas, la factura la emites tú desde tu
punto de venta.
POST /public/autofactura/{slug}/invoices — sin Authorization
El cliente captura su RFC y datos fiscales en la página pública; ésta llama este endpoint sin
ninguna clave — la combinación de receipt_code + rfc es la única
llave. Los mismos cinco campos de receptor que en PUE/PPD, más el
receipt_code del ticket:
curl -s https://api.winal.com.mx/public/autofactura/antech/invoices \
-H "Content-Type: application/json" \
-d '{
"receipt_code": "W-EMU7K",
"rfc": "EKU9003173C9",
"nombre": "ESCUELA KEMPER URGATE",
"uso_cfdi": "G03",
"regimen_fiscal": "601",
"cp": "45050"
}'
XAXX010101000 ya no es una opción aquí — respuesta real{
"error": {
"type": "invalid_request_error",
"code": "autofactura.generic_rfc_not_allowed",
"message": "Para facturar a nombre de 'público en general' ya no se emite un comprobante individual: el SAT lo prohíbe. No tienes que hacer nada — tu compra queda amparada en la factura global que el comercio emite por todas las ventas del período. Si necesitas la factura a tu nombre, captura tu propio RFC.",
"doc_url": "https://winal.com.mx/docs/errores.html#err-autofactura.generic_rfc_not_allowed",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
invoice.pac_error{
"error": {
"type": "invalid_request_error",
"code": "invoice.pac_error",
"message": "El PAC rechazó el timbrado (CFDI 492400bb-969b-413b-97b8-93789f34ddb4 quedó en 'error'): ",
"doc_url": "https://winal.com.mx/docs/errores.html#err-invoice.pac_error",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
201) trae el mismo shape que un CFDI
normal: status: "stamped", uuid_fiscal, serie/folio, y las URL xml_url/pdf_url de descarga.
Descargas: receipt_code + rfc como llave
Nota la ruta: pública, bajo /public/autofactura/{slug}/..., sin
Authorization — la llave es el par receipt_code+rfc del
cliente final. Es una ruta distinta de GET
/v1/invoices/{id}/xml/pdf (privada, con tu llave, para cualquier CFDI de tu
cuenta) — ver el aviso en Descarga del CFDI arriba si no tienes claro cuál
te toca.
curl -s "https://api.winal.com.mx/public/autofactura/antech/invoices/{id}/xml?receipt_code=W-EMU7K&rfc=EKU9003173C9"
curl -s "https://api.winal.com.mx/public/autofactura/antech/invoices/{id}/pdf?receipt_code=W-EMU7K&rfc=EKU9003173C9"
Ambos parámetros deben coincidir con el CFDI ya timbrado — igual que el resto de descargas de
esta página, 400 invoice.not_stamped si el CFDI aún no timbra.
Anti-enumeración, en dos capas
Igual que el client_secret de checkout público,
autofactura nunca revela cuál dato falló:
| HTTP | code | Causa (todas indistinguibles entre sí) |
|---|---|---|
404 | autofactura.not_available | El slug no existe, o el comercio deshabilitó autofactura. |
404 | autofactura.receipt_not_found | El receipt_code no existe, es de otro tenant, o no coincide con el rfc de un CFDI ya emitido. |
Lo que todavía NO existe
Sin adornos, para que no lo descubras en tu primer cierre de mes:
| Qué falta | Qué pasa hoy si lo intentas |
|---|---|
Otras monedas que no sean MXN |
El campo currency se acepta, pero si tu PAC es de los que reciben el comprobante ya sellado por Winal, el sellador solo emite en pesos y responde invoice.cfdi_currency_unsupported: el Anexo 20 exigiría además el atributo TipoCambio, que aún no se modela. Aplica igual a la factura global. |
| Nómina o retenciones con un PAC de timbrado directo (Winal arma y sella) | El generador propio de Winal arma CFDI de ingreso (factura global incluida), de egreso (nota de crédito — ver Nota de crédito) y de pago (el REP, complemento Pagos 2.0, con el que se cierra una factura PPD). La nómina y las retenciones todavía no: responden con invoice.nomina_not_supported y invoice.retention_not_supported. Con un PAC que arma el comprobante —Facturama— los cinco tipos están disponibles: ingreso, egreso, pago, nómina y retenciones; las dos últimas solo con él, precisamente porque es él quien arma el XML.
Y no confundas eso con los límites de abajo. Cambiar de PAC te da nómina y retenciones, y nada más: los rechazos por |
| REP o nota de crédito sobre una factura con tasas mezcladas | invoice.mixed_rate_unsupported: exigen traslados por tasa que todavía no se modelan. La nota de crédito por el TOTAL de la factura sí se emite (reexpide sus mismas líneas); un REP o una nota PARCIAL, no. Por eso una PPD de tasas mezcladas ya no se timbra: se rechaza al crearla (invoice.ppd_mixed_rate_unsupported). |
| REP, o nota de crédito PARCIAL, sobre una factura con IEPS | invoice.ieps_breakdown_unsupported. La nota por el TOTAL sí se emite —reexpide las mismas líneas, con su traslado del 003—; lo que no se puede es repartir una devolución parcial entre las líneas, porque solo quien devuelve sabe cuál se devolvió. Ver IEPS por concepto → Límites. Una PPD con IEPS se rechaza al crearla (invoice.ppd_ieps_unsupported): nadie se queda con un comprobante que la ley le obliga a cerrar y el sistema no le deja cerrar. |
Facturar con IEPS usando el PAC facturama |
invoice.ieps_pac_unsupported, antes de gastar un timbre. Usa finkok para cualquier concepto con IEPS. |
Lo que YA no está en esta lista: la factura global (arriba) y el cruce CP del receptor genérico contra tu lugar de expedición, que la global satisface por construcción —Winal arma ese receptor, tú no lo mandas—. Las otras tres rutas ya no aceptan en absoluto el RFC genérico nacional (ver CFDI40130), así que ese cruce dejó de ser un caso posible ahí también.
La cancelación de un CFDI SÍ existe como endpoint —
POST /v1/invoices/{id}/cancel, documentado arriba con su
cuerpo, respuesta y la demora real del SAT para reconocer un timbre recién emitido— y las notas de
crédito también, con su ciclo completo (crear —por reembolso de Winal o, sin él, declarando el
monto— listar, consultar, descargar y cancelar): ver Nota de crédito
más abajo.
Nota de crédito: el CFDI de egreso que cierra una devolución
Cuando devuelves dinero de una factura ya timbrada, el CFDI de ingreso original NO se toca —los estados terminales de un CFDI son inmutables (regla 4)— así que el reembolso se documenta con un comprobante nuevo: la nota de crédito, un CFDI 4.0 de tipo Egreso (E). Sin ella, el ingreso original queda vivo ante el SAT sin contraparte: IVA de más acreditado, ingresos inflados.
La relación con la factura de ingreso es OBLIGATORIA, y la construye Winal. Todo CFDI de
egreso lleva un nodo CfdiRelacionados (tipo de relación 01) apuntando
al uuid_fiscal de la factura que corrige. No lo mandas tú: Winal exige que la
factura de la ruta ({id}) esté stamped —con su propio
uuid_fiscal— y arma la relación por ti. Sin factura timbrada no hay a qué
relacionar la nota, así que ni siquiera se intenta.
amount_minor va SIEMPRE en POSITIVO. Igual que en el resto de la API
(Money nunca es negativo, regla 1), una nota de crédito de $300.00 se
manda como 30000, no -30000. El signo lo aporta el tipo de
comprobante (Egreso), no el monto — mandar un número negativo no "hace" el egreso; el campo
directamente exige positivo y el negativo se rechaza como cualquier importe inválido.
POST /v1/invoices/{id}/credit-notes —
cuando la factura cuelga de un payment_intent_id que Winal cobró de verdad (la vía
principal, o una PPD liquidada con REP): el monto fiscal sale del reembolso REAL, nunca del
cuerpo. POST /v1/invoices/{id}/credit-notes/pue
— cuando NO lo hay: una venta de mostrador facturada por POST
/v1/invoices/pue se pagó (y ahora se devuelve) fuera de Winal, así que no existe
ningún refund del cual leer el monto — lo declaras tú, igual que declaraste
forma_pago al facturar.
POST /v1/invoices/{id}/credit-notes — emitir por un reembolso de Winal
Hay un tercer detalle, menos visible pero igual de importante: el monto fiscal sale del
reembolso REAL, no de lo que mandes en el cuerpo. refund_id tiene que apuntar a
un refund tuyo que ya esté succeeded, y amount_minor/
currency son solo una confirmación: si no coinciden EXACTO con el monto y la
moneda de ese reembolso, la API rechaza la nota — así nunca se puede timbrar un egreso por un
monto distinto del que de verdad salió de tu cuenta.
payment_intent no hay refund del cual colgarse — es la misma regla
que ya viste en Autofactura ("solo cubre lo que cobró Winal"). Una
factura emitida por POST /v1/invoices/pue —el mostrador
en efectivo, el ejemplo de arriba— nunca tiene payment_intent_id, así que jamás
va a tener un reembolso real que acreditar por esta ruta: usa
POST /v1/invoices/{id}/credit-notes/pue.
| Campo | Tipo | Descripción |
|---|---|---|
refund_id | uuid, requerido | El reembolso REAL (succeeded) que origina la nota. También es la clave de idempotencia: reintentar con el mismo refund_id devuelve la nota viva ya existente sin volver a timbrar. |
amount_minor | integer, requerido | Monto reembolsado en centavos, positivo (incluye IVA e IEPS si los hubiera). Debe coincidir EXACTO con el monto real del refund_id. |
currency | string, opcional | ISO 4217; por defecto MXN. Debe coincidir con la del reembolso real y con la de la factura. |
motivo | string, opcional | Texto libre del motivo del egreso. Si se omite, uno genérico ("Devolución"). |
forma_pago | string, opcional | Clave c_FormaPago con la que DEVOLVISTE el dinero (01 efectivo, 03 transferencia, 04 tarjeta de crédito, 28 débito…) — mismo catálogo de arriba. Por defecto 03. 99 no se acepta: una nota de crédito es siempre PUE, así que la devolución ya ocurrió. |
serie | string, opcional | Serie de ESTA nota — ver Serie y folio. Muchos comercios llevan una serie PROPIA para sus notas de crédito (p. ej. "NC"), distinta de la de sus facturas. |
folio | string, opcional | Folio de ESTA nota (tu numeración). Sin default. |
payment_intent_id a55fc12f… del ejemplo de arriba, NO la de mostrador — ver el aviso arriba)curl -s https://api.winal.com.mx/v1/invoices/f47e2b91-6a3d-4c8e-9b2a-1d5e8f3c6a97/credit-notes \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"refund_id": "b3f6a1c2-8e4d-4a9b-9c1e-2f6a8b0d5e17",
"amount_minor": 23200,
"forma_pago": "01",
"motivo": "Devolución de mercancía",
"serie": "NC",
"folio": "0007"
}'
200, no 201 — a diferencia de las cuatro rutas que EMITEN un CFDI de ingresostatus: "stamped"), pero el
código de estado exitoso es 200 OK, no 201 Created.
credit_note){
"object": "credit_note",
"id": "7a2e4c1b-9f3d-4b6e-8a2c-1d5f9e3b7c4a",
"invoice_id": "f47e2b91-6a3d-4c8e-9b2a-1d5e8f3c6a97",
"refund_id": "b3f6a1c2-8e4d-4a9b-9c1e-2f6a8b0d5e17",
"status": "stamped",
"uuid_fiscal": "...",
"related_uuid": "...",
"serie": "NC",
"folio": "0007",
"total_minor": 23200,
"base_minor": 20000,
"iva_minor": 3200,
"currency": "MXN",
"motivo": "Devolución de mercancía"
}
related_uuid es el uuid_fiscal de la factura de ingreso que esta nota
acredita — la relación 01 del Anexo 20, la que Winal arma por ti. Nota que este
shape NO trae ieps_minor: hoy una nota de crédito nunca lo lleva —
ver el límite en IEPS por concepto.
pending/stamped) de una factura
más el reembolso que estás emitiendo ahora no puede exceder el total_minor de esa
factura. Excederlo responde
invoice.credit_notes_exceed_total
con las tres cifras en el mensaje. Una nota canceled deja de contar para este tope.
Acreditar el TOTAL exacto de la factura (nada acreditado antes) reexpide sus MISMAS líneas,
con sus mismos impuestos ya timbrados. Por eso la nota total sí se emite sobre una factura
de tasas mezcladas (perfumería al 16%, medicina exenta) y también sobre una con
IEPS (el refresco y la botana del mismo ticket): cada línea conserva su tratamiento y su
traslado del 003, así que el impuesto devuelto es al centavo el que se trasladó, sin
recalcular nada.
Acreditar solo una PARTE es otro problema y sigue sin soportarse en esos dos casos —
invoice.mixed_rate_unsupported
con tasas mezcladas,
invoice.ieps_breakdown_unsupported
con IEPS—. El motivo cabe en una línea: repartir un importe entre tasas (o entre impuestos)
exige saber qué línea se devolvió, y eso solo lo sabe quien recibió la devolución; repartirlo
"a prorrata" inventaría un IVA o un IEPS que nadie trasladó. La salida es cancelar el comprobante
y reexpedirlo por lo que sí queda vendido. Ver Límites en IEPS por concepto.
Errores propios de esta ruta
| code | Cuándo |
|---|---|
invoice.credit_note_invalid_request | Falta refund_id, o amount_minor no es positivo. |
invoice.invalid_currency | currency no es un código ISO 4217. |
invoice.invalid_forma_pago | forma_pago no está en el catálogo (incluye mandar 99). |
invoice.not_found | La factura {id} de la ruta no existe (404). |
invoice.not_stamped | La factura a acreditar no está stamped — solo se acredita lo que sí se timbró. |
invoice.refund_not_found | El refund_id no existe para tu cuenta (404). |
invoice.refund_not_succeeded | El reembolso existe pero aún no está succeeded. |
invoice.refund_amount_mismatch | amount_minor/currency no coinciden EXACTO con el reembolso real. |
invoice.credit_note_currency_mismatch | La moneda del reembolso no coincide con la de la factura. |
invoice.refund_intent_mismatch | El reembolso es de un payment_intent distinto del que documenta la factura. |
invoice.credit_notes_exceed_total | Excede el tope agregado por factura (ver arriba). |
invoice.mixed_rate_unsupported | Nota PARCIAL sobre una factura de tasas de IVA mezcladas. |
invoice.ieps_breakdown_unsupported | Nota PARCIAL sobre una factura con IEPS en algún concepto. La nota por el TOTAL sí se emite: reexpide las mismas líneas con su traslado del 003. |
invoice.cfdi_field_invalid | serie o folio excede el largo máximo o trae | — ver Serie y folio. |
invoice.pac_error | El PAC rechazó el timbrado. La nota queda persistida en error, auditable. |
Todos los 400 salvo dos: invoice.not_found y
invoice.refund_not_found responden 404 — el resto, incluidos los que
documentan un conflicto de estado (invoice.not_stamped), responde 400
(misma regla de mapeo por subcadena que el resto de la Api; ver Errores).
POST /v1/invoices/{id}/credit-notes/pue — emitir SIN reembolso de Winal
El equivalente exacto de POST /v1/invoices/pue pero para
el EGRESO: la devolución de mostrador de una venta que Winal nunca cobró —el caso central de una
farmacia, y el que motivó esta ruta— no tiene refund_id del cual leer el monto, así
que lo declaras tú, junto con forma_pago (con qué DEVOLVISTE el dinero). Por lo demás
es el MISMO trámite fiscal que la ruta de arriba: mismo tope agregado por factura, misma relación
CfdiRelacionados tipoRelacion 01 que Winal arma por ti, mismo importe
SIEMPRE positivo, mismo sellador de egreso — lo único que cambia es de dónde sale el monto.
| Campo | Tipo | Descripción |
|---|---|---|
amount_minor | integer, requerido | Monto devuelto en centavos, positivo (incluye IVA). |
currency | string, opcional | ISO 4217; por defecto MXN. Debe coincidir con la de la factura. |
motivo | string, opcional | Texto libre del motivo del egreso. Si se omite, uno genérico ("Devolución"). |
forma_pago | string, opcional | Clave c_FormaPago con la que DEVOLVISTE el dinero (01 efectivo en el mostrador, 03 transferencia, 04 tarjeta de crédito, 28 débito…) — mismo catálogo de arriba. Por defecto 03. 99 no se acepta: una nota de crédito es siempre PUE. |
serie | string, opcional | Serie de ESTA nota — ver Serie y folio. |
folio | string, opcional | Folio de ESTA nota (tu numeración). Sin default. |
POST /v1/invoices/pue de arriba — sin cobro de Winal, sin refund posible)curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-de0c-4961-98eb-e0cacfc6aae8/credit-notes/pue \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 23200,
"forma_pago": "01",
"motivo": "Devolución de mercancía en el mostrador",
"serie": "NC",
"folio": "0008"
}'
Misma respuesta 200 que la ruta por reembolso, con refund_id: null:
credit_note){
"object": "credit_note",
"id": "3c8a7d1e-5b2f-4e9a-8c6d-0f1a3b7c9e42",
"invoice_id": "5cee05bd-de0c-4961-98eb-e0cacfc6aae8",
"refund_id": null,
"status": "stamped",
"uuid_fiscal": "...",
"related_uuid": "...",
"serie": "NC",
"folio": "0008",
"total_minor": 23200,
"base_minor": 20000,
"iva_minor": 3200,
"currency": "MXN",
"motivo": "Devolución de mercancía en el mostrador"
}
Errores propios de esta ruta
| code | Cuándo |
|---|---|
invoice.credit_note_invalid_request | amount_minor no es positivo (aquí no hay refund_id que exigir). |
invoice.invalid_currency | currency no es un código ISO 4217. |
invoice.invalid_forma_pago | forma_pago no está en el catálogo (incluye mandar 99). |
invoice.not_found | La factura {id} de la ruta no existe (404). |
invoice.not_stamped | La factura a acreditar no está stamped. |
invoice.credit_note_currency_mismatch | La moneda declarada no coincide con la de la factura. |
invoice.credit_notes_exceed_total | Excede el tope agregado por factura (mismo tope de la ruta por reembolso, arriba). |
invoice.mixed_rate_unsupported | Nota PARCIAL sobre una factura de tasas de IVA mezcladas. |
invoice.ieps_breakdown_unsupported | Nota PARCIAL sobre una factura con IEPS en algún concepto. La nota por el TOTAL sí se emite: reexpide las mismas líneas con su traslado del 003. |
invoice.cfdi_field_invalid | serie o folio excede el largo máximo o trae | — ver Serie y folio. |
invoice.pac_error | El PAC rechazó el timbrado. La nota queda persistida en error, auditable. |
Idempotencia: esta ruta NO tiene refund_id del cual deduplicar, así que reintentarla
con una Idempotency-Key DISTINTA crea una nota nueva — es tu responsabilidad no
reenviar la misma devolución dos veces con llaves diferentes. Con la MISMA llave, el protocolo de
idempotencia de la API (header obligatorio en todo POST /v1/invoices*) sí te protege
del reintento por timeout.
GET /v1/invoices/{id}/credit-notes — listar
Todas las notas de crédito (en cualquier estado) de una factura, del más nuevo al más viejo.
{ "object": "list", "data": [] }
GET /v1/invoices/{id}/credit-notes/{creditNoteId} — consultar
Devuelve el mismo shape credit_note de arriba. La nota tiene que pertenecer a la
factura {id} de la ruta: una nota de OTRA factura del mismo tenant se ve como
invoice.credit_note_not_found
(404) — no un 403, para no confirmar que el id existe en otro lado.
GET /v1/invoices/{id}/credit-notes/{creditNoteId}/xml · /pdf — descargar
Mismo patrón que la descarga de una factura: application/xml o
application/pdf, con tu Authorization. La diferencia es que aquí SÍ
puedes descargar una nota ya canceled —el comprobante existió y su XML es justo lo
que hay que conservar cinco años—; lo único que no se descarga es una que nunca llegó a timbrarse
(400 invoice.credit_note_not_stamped).
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-.../credit-notes/7a2e4c1b-.../xml \
-H "Authorization: Bearer $SK" -o nota-credito.xml
POST /v1/invoices/{id}/credit-notes/{creditNoteId}/retry — reintentar
La salida de una nota que quedó en error, espejo de
POST /v1/invoices/{id}/retry. No emite otra nota:
vuelve a mandar EL MISMO comprobante —mismos importes, mismas líneas, misma serie/folio, misma
forma de pago y la misma fecha de expedición—, marcado como reintento, así que un PAC que
sabe recuperar el timbre devuelve el folio anterior en vez de emitir uno nuevo. Sin cuerpo: todo
lo que determina el comprobante ya quedó guardado antes de la primera llamada al PAC.
Es importante que uses ESTA ruta y no emitas una nota nueva cuando el PAC no respondió: dos CFDI de egreso vivos por la misma devolución acreditan el IVA dos veces, y no se borran — se cancelan, dejando rastro fiscal de los dos. (Por eso, además, una nota con desenlace incierto sigue ocupando su parte del tope acreditable de la factura hasta que se aclare.)
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-.../credit-notes/7a2e4c1b-.../retry \
-X POST \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)"
Devuelve el credit_note ya stamped. Si la nota ya estaba timbrada (o
cancelada) el reintento es un no-op exitoso: te la devuelve sin llamar al PAC. Y si no se puede
reintentar —tiene un intento EN CURSO, otro reintento se adelantó, o se emitió antes de que Winal
guardara lo que la hace reproducible— responde
invoice.credit_note_not_retryable
con la salida en el mensaje.
POST /v1/invoices/{id}/credit-notes/{creditNoteId}/cancel — cancelar
Mismo contrato que POST /v1/invoices/{id}/cancel — los
cuatro motivos del SAT, motive: "01" exige substitution_uuid, idempotente
(cancelar una nota ya canceled es éxito sin llamar de nuevo al PAC), y la misma demora
real del SAT para reconocer un timbre recién emitido. La diferencia frente a la cancelación de una
factura: la respuesta aquí es el credit_note completo (no un shape recortado).
curl -s https://api.winal.com.mx/v1/invoices/5cee05bd-.../credit-notes/7a2e4c1b-.../cancel \
-X POST \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{}'
Errores propios: invoice.invalid_cancel_motive,
invoice.cancel_substitution_required,
invoice.credit_note_not_stamped
(solo se cancela lo que sí timbró),
invoice.no_provider_ref y
invoice.pac_cancel_error — el
mismo cuarteto que la cancelación de una factura, sobre la nota de crédito en vez del CFDI de
ingreso.
Siguiente paso
- Configura tu facturación — CSD, PAC y la regla del nombre fiscal, si aún no timbras.
- Referencia de API → Facturas — cada endpoint con sus headers y cuerpos exactos.
- Errores — todos los códigos de facturación, con causa y remedio.