Facturación CFDI

MODO PRUEBA

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.

El timbrado es SÍNCRONO: no hay "en proceso" que consultar
Los cuatro 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.
Antes de tu primer CFDI: hay configuración que hacer
Facturar exige que tu cuenta tenga perfil fiscal (RFC, razón social, régimen, lugar de expedición), credenciales de tu PAC y tu CSD. Todo eso —y la regla del nombre fiscal que hace que el SAT rechace las facturas de medio mundo— está en Configura tu facturación. Mientras falte, los 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.
El tropiezo número uno al subir el CSD: mandar la e.firma (antes FIEL) en su lugar. Son certificados distintos del SAT, con el mismo formato y nombres de archivo parecidos, y con la e.firma no se puede facturar. Winal la rechaza al guardar (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 casoRutaQué 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.
La regla que ata todo: un CFDI suelto NUNCA va a "público en general" (rechazo CFDI40130 del SAT)

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.

Por qué no basta con "usar la PPD" para una venta en efectivo
Sería fiscalmente incorrecto: un CFDI 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.

codeQué 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.
La diferencia práctica, en una línea

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

CampoTipoDescripción
total_minorinteger, requeridoTotal de la venta en centavos, IVA incluido. Entero, jamás decimal. Debe ser positivo.
receptorobjeto, requeridoLos cinco campos de siempre: rfc, nombre, uso_cfdi, regimen_fiscal, cp. Ninguno es opcional.
forma_pagostring, requeridoClave de dos dígitos del catálogo c_FormaPago — la tabla completa abajo. 99 no se acepta aquí.
currencystring, opcionalISO 4217; por defecto MXN. Ver los límites de hoy antes de mandar otra.
descripcionstring, opcionalDescripción del concepto. Si se omite, se usa la genérica (Servicios).
conceptosarreglo, opcionalIVA por línea para un ticket con tasas mezcladas — ver Conceptos. Si se omite, tasa única del 16%.
seriestring, opcionalSerie de ESTE comprobante — ver Serie y folio. Si se omite, la serie_default de tu perfil fiscal.
foliostring, opcionalFolio de ESTE comprobante (tu numeración de ticket) — ver Serie y folio. Sin default.

Ejemplo

bash
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.

201 · shape de la respuesta (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.

ClaveForma de pagoCuándo la usas
01EfectivoLa venta de mostrador de una farmacia o una tienda de abarrotes.
02Cheque nominativoTe pagaron con cheque.
03Transferencia electrónica de fondosSPEI, DiMo o CoDi recibidos directo en tu banco.
04Tarjeta de créditoCobro con tarjeta en una terminal que no es de Winal.
05Monedero electrónico
06Dinero electrónico
08Vales de despensa
28Tarjeta de débitoIgual que 04, cuando sabes que fue débito.
29Tarjeta de servicios
30Aplicación de anticiposAplicas un anticipo que ya habías facturado.
31Intermediario de pagos
99Por definirSolo 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 requerido
Todo POST 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.
Una 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

codeCuándo
invoice.invalid_bodyFalta total_minor o no es positivo.
invoice.invalid_receptorFalta receptor o alguno de sus cinco campos.
invoice.invalid_forma_pagoFalta forma_pago, o la clave no está en el catálogo.
invoice.forma_pago_requires_ppdMandaste "99" en una PUE.
invoice.global_required_for_publico_en_generalEl 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_incoherenteRFC 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_currencycurrency no es un código ISO 4217.
invoice.conceptos_sum_mismatchLos conceptos no suman total_minor al centavo.
invoice.livemode_mismatchLa 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_invalidserie o folio excede el largo máximo, o trae | — ver Serie y folio.
invoice.pac_errorEl 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.

bash
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.

Con el RFC genérico nacional, esta ruta ya no te deja timbrar — y se revisa primero que cualquier otra cosa

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.

CampoTipoDescripción
seriestring, opcional, máx. 25 caracteresSerie 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.
foliostring, opcional, máx. 40 caracteresFolio 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/invoicesSí.
POST /v1/invoices/pueSí.
POST /v1/invoices/ppdSí.
POST /v1/invoices/globalSí — 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/pueSí — muchos comercios llevan una serie PROPIA para sus notas de crédito (p. ej. "NC"), distinta de la de sus facturas.
Un comprobante repetido no bloquea nada — y adrede
Winal no comprueba si el par 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ó.
Lo que NO hacemos: el importe con letra
El "importe con letra" ("SON: DOSCIENTOS TREINTA Y DOS PESOS 00/100 M.N.") que ves en la representación impresa de un CFDI no es un atributo del XML — el Anexo 20 no lo define ni el SAT lo pide. Es una convención de la representación impresa (el PDF), y le toca a quien la arma. El PDF que descargas de 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.

bash · un ticket con RFC de un cliente concreto
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:

CampoTipoDescripción
importe_minorinteger, requeridoTotal de la línea en centavos (incluye su IVA). Igual que el resto de la API: entero, jamás decimal.
treatmentstring, requeridoTratamiento de IVA de la línea: tasa16, tasa8 (frontera), tasa0 (alimentos/medicinas) o exento.
clave_prod_servstring, opcionalClave ProdServ del SAT de la línea. Si se omite, se usa una clave genérica.
clave_unidadstring, opcionalClave de unidad del SAT de la línea. Si se omite, se usa una clave genérica.
descripcionstring, opcionalDescripción de la línea. Si se omite, se usa una descripción genérica.
descuento_minorinteger, opcionalDescuento 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_microinteger, opcionalCuá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_microinteger, opcionalPrecio 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.

3 cajas a $45 + medio kilo a $80 el kilo
"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íneaCantidadValorUnitarioImporte
Caja de analgésico3.00000045.000000135.00
Producto a granel0.50000080.00000040.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.

El valor unitario va SIN impuestos
El 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.
Los tres números los mandas tú; Winal no los corrige
El SAT exige que el importe del concepto sea el resultado de multiplicar la cantidad por el valor unitario. Winal escribe los tres tal como se los mandas y no comprueba esa aritmética: tu punto de venta ya calculó e imprimió los tres en el ticket, y la factura tiene que decir lo mismo que ese papel, no una reconstrucción nuestra. Quien los valida contra la norma es el PAC. Si no cuadran, el timbrado se rechaza sin consumir folio y el mensaje te dice qué revisar; la salida entonces es emitir un comprobante nuevo con los números corregidos, no reintentar el mismo — el reintento reexpide exactamente el XML que ya se envió.
400 · el PAC rechaza el trío (ejemplo ilustrativo)
{
  "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_unidadUnidad
H87Pieza — la más común para producto físico contable (una caja, un frasco, una unidad).
KGMKilogramo — producto a granel por peso.
GRMGramo.
LTRLitro — líquidos a granel, el caso del litro de gasolina de arriba.
MLTMililitro.
XBXCaja.
PRPar.
E48Unidad de servicio — para servicios, no producto físico.
ACTActividad — 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.

El 3+1 va en UNA línea, no en dos
Cuatro cajas de $116.00 con una de regalo se escriben como un solo concepto de 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[] con una promoción 3+1
"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.

La suma se valida sobre el NETO
Si mandas conceptos con descuento, lo que debe igualar el total del cobro es la suma de importe_minor − descuento_minor, no la de los importes de lista.
La suma de conceptos[] debe igualar el total, al centavo
Si se dan conceptos, la suma de sus importe_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:
400 · invoice.conceptos_sum_mismatch
{
  "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"
  }
}
Un 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.

CampoTipoDescripción
tasa_bpsinteger, opcionalPor 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_microinteger (int64), opcionalPor 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_microinteger (int64), opcionalPor 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_bpsPorcentaje
00.00%
3003.00%
6006.00%
7007.00%
8008.00% — alimentos no básicos de alta densidad calórica (botanas, chocolates, dulces).
9009.00%
250025.00% — bebidas energizantes.
265026.50%
300030.00%
304030.40%
500050.00%
530053.00%
16000160.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:

bash
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íneaImporteValor (base)IEPSIVA
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.

201 · 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 hoyQué 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é rechazaErrorQué 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.
El REP funciona con los dos PAC, incluido el de timbrado directo
Si tu perfil fiscal timbra con un PAC que recibe el comprobante ya sellado por Winal (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

bash
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).

201 · CFDI PPD (proyección real de 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ó:

Las dos a la vez se rechazan

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).

bash
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"
  }'
201 · shape esperado (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 respuesta
serie/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.
Requiere que la factura PPD ya esté stamped
Si la factura PPD original quedó en error (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.

bash
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"
  }'
campoqué es
amount_minorObligatorio. 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_pagoObligatorio. 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_pagoOpcional; 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.
currencyOpcional, MXN por defecto; debe coincidir con la de la factura (invoice.payment_currency_mismatch).
serie/folioIgual que en la otra variante: la numeración PROPIA de este REP.
Idempotencia: aquí la 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.

Ambiente: manda tu LLAVE — y tiene que coincidir con el de la factura

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).

Qué arregla el retry, y qué NO

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.

Un comprobante se reintenta en SU ambiente

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.

Si dos reintentos coinciden

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.

bash
curl -s -X POST https://api.winal.com.mx/v1/invoices/5cee05bd-.../payments/8f2a1c7e-.../retry \
  -H "Authorization: Bearer $SK"
Nunca registres el pago otra vez para "arreglar" un REP fallido

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

200 · respuesta real (factura sin pagos aún)
{ "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.

PreguntaRespuesta
¿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.
El error clásico: NO incluyas lo que ya facturaste individualmente
Cada venta va a UN lugar. Si el cliente pidió su factura durante el mes —por /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

CampoTipoDescripción
periodicidadstring, requeridoClave de dos dígitos de c_Periodicidad: 01 diario, 02 semanal, 03 quincenal, 04 mensual, 05 bimestral.
mesesstring, requeridoClave 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.
aniointeger, requeridoAñ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_pagostring, requeridoClave 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í.
conceptosarreglo, requerido, ≥ 1 líneaEl 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.
currencystring, opcionalISO 4217; por defecto MXN.
sustituye_uuidstring, opcionalEl uuid_fiscal de una global YA TIMBRADA de este comercio que ésta corrige — ver Corregir una global. Omítelo en una emisión normal.
seriestring, opcionalSerie de ESTA global — ver Serie y folio. Una global suele llevar su PROPIA serie, distinta de la de tus facturas individuales.
foliostring, opcionalFolio 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

bash · cierre de julio de una farmacia
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"
  }'
201 · shape de la respuesta (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.

bash · detalle por ticket
"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.

  1. Emite la global correcta con sustituye_uuid = el uuid_fiscal de la que estás corrigiendo, para el mismo período (periodicidad/meses/año idénticos).
  2. Con el uuid_fiscal que te devuelve esa emisión, cancela la original — POST /v1/invoices/{id}/cancel con motive: "01" y substitution_uuid apuntando 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.

La global de PRUEBAS no ocupa el período de la real
La unicidad es por ambiente: la global que emitas con tu 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.
La unicidad automática no cubre diario/semanal/quincenal
El índice único de Winal solo distingue períodos mensuales y bimestrales (meses+año identifican el período sin ambigüedad). Con periodicidad diaria, semanal o quincenal, dos globales del mismo mes llevan la MISMA clave de 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

codeCuándo
invoice.invalid_bodyFalta periodicidad o meses.
invoice.invalid_forma_pagoFalta forma_pago, o la clave no está en el catálogo.
invoice.forma_pago_requires_ppdMandaste "99": una global ampara operaciones YA cobradas.
invoice.invalid_conceptoconceptos viene vacío, o algún importe_minor no es positivo. Aquí es obligatorio (al menos una línea).
invoice.invalid_treatmentUn treatment no es tasa16/tasa8/tasa0/exento.
invoice.invalid_currencyLa currency no es un código ISO 4217 válido. Omite el campo para MXN por default.
invoice.global_periodo_invalidoEl 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_mismatchLa 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_existsYa 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_uuidsustituye_uuid llegó como cadena vacía — omítelo por completo en una emisión normal.
invoice.sustituida_not_foundEl uuid_fiscal de sustituye_uuid no corresponde a ninguna global TIMBRADA de tu cuenta.
invoice.sustitucion_periodo_distintoLa global que intentas sustituir es de OTRO período; una sustitución reemplaza el comprobante del MISMO período.
invoice.cfdi_field_invalidserie 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_errorEl 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

CampoTipoDescripción
motivestring, opcionalMotivo de cancelación del SAT: 01 (sustitución), 02, 03 o 04. Si se omite, 02 por defecto.
substitution_uuidstring, opcionaluuid_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

bash · cancelar sin sustitución (motivo 02, el default)
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):

bash · cancelar CON sustitución (motivo 01)
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"
  }'
200 · shape de la respuesta (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}.

Operación real: cancelar justo después de timbrar responde "No Encontrado" — es propagación, no un error tuyo
El SAT tarda entre 2 y 10 minutos en reconocer un UUID recién timbrado. Si cancelas inmediatamente después de timbrar, el PAC puede responder que el comprobante está "No Encontrado" — no significa que el CFDI esté mal, significa que el SAT todavía no lo propagó a su índice de consulta. Winal lo reporta como 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

codeCuándo
invoice.not_foundEl id del CFDI no existe (o es de otro tenant).
invoice.invalid_cancel_motivemotive no es 01, 02, 03 ni 04.
invoice.cancel_substitution_requiredMandaste motive: "01" sin substitution_uuid.
invoice.not_stampedEl CFDI no está stamped (nunca timbró, o cayó en error): solo se cancela lo que sí se timbró.
invoice.cancel_requires_substitutionEl 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_refEl CFDI no tiene referencia del PAC para cancelar — estado inconsistente; si lo ves, escala con el request_id.
invoice.pac_cancel_errorEl 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/*.

EndpointAuthContent-Type
GET /v1/invoices/{id}/xmlTu Authorization: Bearer sk_...application/xml
GET /v1/invoices/{id}/pdfTu Authorization: Bearer sk_...application/pdf

Ambos requieren que el CFDI esté stamped — si no, 400 invoice.not_stamped.

bash
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
No lo confundas con las descargas de Autofactura — son rutas DISTINTAS, para dueños distintos
Esta página tiene dos formas de bajar el XML/PDF de un CFDI, y son intencionalmente diferentes:
  • Las de aquí arriba (GET /v1/invoices/{id}/xml/pdf) son privadas: exigen tu Authorization y 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: sin Authorization, llevan receipt_code+rfc como 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.

Si tu cliente NO tiene o no quiere dar su RFC, no necesita hacer nada aquí
Autofactura emite un CFDI de ingreso nominativo (misma ruta que 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.
201 · POST /v1/payment_intents, receipt_code real
{
  "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).

Autofactura solo cubre lo que cobró Winal
El 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:

bash
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"
  }'
El genérico XAXX010101000 ya no es una opción aquí — respuesta real
400 · rfc: "XAXX010101000"
{
  "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"
  }
}
Un servidor sin PAC configurado responde invoice.pac_error
Mismo motivo que el resto de esta página — sin credenciales de PAC configuradas en el perfil fiscal del tenant, el timbrado no puede completarse. Es exactamente el error real, capturado en vivo:
400 · respuesta real
{
  "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"
  }
}
Con un PAC configurado, la respuesta exitosa (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.

bash
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ó:

HTTPcodeCausa (todas indistinguibles entre sí)
404autofactura.not_availableEl slug no existe, o el comercio deshabilitó autofactura.
404autofactura.receipt_not_foundEl 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é faltaQué 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 invoice.mixed_rate_unsupported, invoice.ieps_breakdown_unsupported y invoice.ppd_*_unsupported son de Winal, no del PAC. Los decide el desglose fiscal de este servicio ANTES de que exista comprobante alguno que mandar a timbrar, así que son transversales a los dos PACs y cambiar de proveedor no los altera en absoluto. (El único límite que SÍ depende del PAC es invoice.ieps_pac_unsupported, que va en la dirección contraria: con finkok el IEPS se emite y con facturama no.)

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.

Los dos detalles que casi todo el mundo se salta

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.

Dos vías, según si Winal cobró la venta

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.

Esta ruta exige un cobro DE WINAL detrás de la factura
Sin 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.
CampoTipoDescripción
refund_iduuid, requeridoEl 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_minorinteger, requeridoMonto reembolsado en centavos, positivo (incluye IVA e IEPS si los hubiera). Debe coincidir EXACTO con el monto real del refund_id.
currencystring, opcionalISO 4217; por defecto MXN. Debe coincidir con la del reembolso real y con la de la factura.
motivostring, opcionalTexto libre del motivo del egreso. Si se omite, uno genérico ("Devolución").
forma_pagostring, opcionalClave 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ó.
seriestring, opcionalSerie 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.
foliostring, opcionalFolio de ESTA nota (tu numeración). Sin default.
bash · acredita el TOTAL de una factura de un COBRO de Winal (la del 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 ingreso
La nota de crédito timbra igual de síncrono que una factura (misma disciplina que el resto de esta página: si el PAC la acepta, esta misma respuesta ya trae status: "stamped"), pero el código de estado exitoso es 200 OK, no 201 Created.
200 · shape de la respuesta (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.

Tope AGREGADO por factura: no puedes acreditar más de lo que facturaste
La SUMA de las notas de crédito VIVAS (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

codeCuándo
invoice.credit_note_invalid_requestFalta refund_id, o amount_minor no es positivo.
invoice.invalid_currencycurrency no es un código ISO 4217.
invoice.invalid_forma_pagoforma_pago no está en el catálogo (incluye mandar 99).
invoice.not_foundLa factura {id} de la ruta no existe (404).
invoice.not_stampedLa factura a acreditar no está stamped — solo se acredita lo que sí se timbró.
invoice.refund_not_foundEl refund_id no existe para tu cuenta (404).
invoice.refund_not_succeededEl reembolso existe pero aún no está succeeded.
invoice.refund_amount_mismatchamount_minor/currency no coinciden EXACTO con el reembolso real.
invoice.credit_note_currency_mismatchLa moneda del reembolso no coincide con la de la factura.
invoice.refund_intent_mismatchEl reembolso es de un payment_intent distinto del que documenta la factura.
invoice.credit_notes_exceed_totalExcede el tope agregado por factura (ver arriba).
invoice.mixed_rate_unsupportedNota PARCIAL sobre una factura de tasas de IVA mezcladas.
invoice.ieps_breakdown_unsupportedNota 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_invalidserie o folio excede el largo máximo o trae | — ver Serie y folio.
invoice.pac_errorEl 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.

CampoTipoDescripción
amount_minorinteger, requeridoMonto devuelto en centavos, positivo (incluye IVA).
currencystring, opcionalISO 4217; por defecto MXN. Debe coincidir con la de la factura.
motivostring, opcionalTexto libre del motivo del egreso. Si se omite, uno genérico ("Devolución").
forma_pagostring, opcionalClave 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.
seriestring, opcionalSerie de ESTA nota — ver Serie y folio.
foliostring, opcionalFolio de ESTA nota (tu numeración). Sin default.
bash · acredita el TOTAL de la factura de MOSTRADOR (la de 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:

200 · shape de la respuesta (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

codeCuándo
invoice.credit_note_invalid_requestamount_minor no es positivo (aquí no hay refund_id que exigir).
invoice.invalid_currencycurrency no es un código ISO 4217.
invoice.invalid_forma_pagoforma_pago no está en el catálogo (incluye mandar 99).
invoice.not_foundLa factura {id} de la ruta no existe (404).
invoice.not_stampedLa factura a acreditar no está stamped.
invoice.credit_note_currency_mismatchLa moneda declarada no coincide con la de la factura.
invoice.credit_notes_exceed_totalExcede el tope agregado por factura (mismo tope de la ruta por reembolso, arriba).
invoice.mixed_rate_unsupportedNota PARCIAL sobre una factura de tasas de IVA mezcladas.
invoice.ieps_breakdown_unsupportedNota 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_invalidserie o folio excede el largo máximo o trae | — ver Serie y folio.
invoice.pac_errorEl 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.

200 · respuesta real (factura sin notas de crédito aún)
{ "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).

bash
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.)

bash
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).

bash
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