Winal timbra CFDI 4.0 directamente contra un PAC (hoy: Facturama) — base e IVA se calculan
exactos a partir del total_minor en centavos, nunca con redondeo de punto
flotante. Configura el perfil fiscal del emisor y las credenciales del PAC en el portal
(/admin/tenants/{id}/fiscal-profile) antes de timbrar.
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 shape del request y de los
errores de esta página está verificado en vivo contra el servidor real.
PUE: factura un cobro exitoso
El caso simple — "te cobro Y te facturo": un CFDI de ingreso, método de pago
PUE (pago en una sola exhibición), sobre un payment_intent ya
succeeded.
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": "XAXX010101000",
"nombre": "Publico en general",
"uso_cfdi": "S01",
"regimen_fiscal": "616",
"cp": "06600"
}
}'
Los cinco campos de receptor son obligatorios. Para venta a público en general
usa el RFC genérico XAXX010101000. Si el timbrado tiene éxito, la respuesta es
un invoice stamped con uuid_fiscal, serie
y folio (ver Referencia de API →
Facturas para el shape completo).
XAXX010101000 nacional, XEXX010101000
extranjero) el SAT exige las tres a la vez: nombre
PÚBLICO EN GENERAL (sin importar acentos ni mayúsculas),
regimen_fiscal 616 (Sin obligaciones fiscales) y
uso_cfdi S01 (Sin efectos fiscales). Winal valida esa coherencia
ANTES de mandar el CFDI al PAC y responde invoice.receptor_incoherente
diciéndote cuál de las tres falló — así el rechazo te llega en milisegundos y gratis, en vez
de como error del timbrado. Con un RFC real, en cambio, el nombre es el de tu cliente y eliges
el uso_cfdi que te pida (G01, G03, …): ahí la regla
no aplica.
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 POST /v1/invoices y
POST /v1/invoices/ppd — declara el tratamiento de IVA por línea en vez de
forzar una sola tasa a todo el comprobante.
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": "XAXX010101000",
"nombre": "Publico en general",
"uso_cfdi": "S01",
"regimen_fiscal": "616",
"cp": "06600"
},
"conceptos": [
{ "importe_minor": 20000, "treatment": "tasa16", "descripcion": "Ferretería" },
{ "importe_minor": 5000, "treatment": "tasa0", "descripcion": "Alimentos" },
{ "importe_minor": 3000, "treatment": "exento", "descripcion": "Servicio exento" }
]
}'
Cada elemento de conceptos[] es un CfdiConceptoDto:
| Campo | Tipo | Descripción |
|---|---|---|
importe_minor | integer, requerido | Total de la línea en centavos (incluye su IVA). Igual que el resto de la API: entero, jamás decimal. |
treatment | string, requerido | Tratamiento de IVA de la línea: tasa16, tasa8 (frontera), tasa0 (alimentos/medicinas) o exento. |
clave_prod_serv | string, opcional | Clave ProdServ del SAT de la línea. Si se omite, se usa una clave genérica. |
clave_unidad | string, opcional | Clave de unidad del SAT de la línea. Si se omite, se usa una clave genérica. |
descripcion | string, opcional | Descripción de la línea. Si se omite, se usa una descripción genérica. |
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. En POST /v1/invoices/ppd la misma regla aplica contra
total_minor en vez del monto del cobro.
conceptos[] debe igualar el total, al centavoimporte_minor debe ser EXACTAMENTE igual al
monto del cobro (PUE) o a total_minor (PPD) — nunca un redondeo aproximado.
Si no cuadra, la API rechaza el comprobante ANTES de intentar timbrar:
{
"error": {
"type": "invalid_request_error",
"code": "invoice.conceptos_sum_mismatch",
"message": "La suma de los conceptos (27000) debe igualar el total del comprobante (28000), en centavos.",
"doc_url": "https://winal.com.mx/docs/errores.html#err-invoice.conceptos_sum_mismatch",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
treatment que no sea tasa16/tasa8/tasa0/
exento (sin distinguir mayúsculas) se rechaza igual de temprano con
400 invoice.invalid_treatment. Ambos errores llegan antes de tocar al PAC — un
concepto mal formado nunca genera un CFDI a medias.
PPD: factura por un monto acordado, cóbralo después en partes
Para servicios/proyectos que se liquidan en parcialidades sin un cobro previo: 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.
POST /v1/invoices/ppd
curl -s https://api.winal.com.mx/v1/invoices/ppd \
-H "Authorization: Bearer $SK" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"total_minor": 348000,
"currency": "MXN",
"receptor": {
"rfc": "XAXX010101000",
"nombre": "Publico en General",
"uso_cfdi": "S01",
"regimen_fiscal": "616",
"cp": "06600"
},
"descripcion": "Servicios profesionales - anticipo PPD"
}'
total_minor (centavos, incluye IVA) es el único campo nuevo frente a PUE;
currency y descripcion son opcionales (MXN y una
descripción genérica por default). Sin cobro previo que facturar — no manda
payment_intent_id.
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 cobro real que liquida (parte de) el saldo insoluto: un
payment_intent ya succeeded, tuyo, correlacionado por ti con la
factura PPD que corresponde. Winal calcula la parcialidad, el saldo anterior/nuevo y timbra
el REP.
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" }'
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": "..."
}
stampederror (p. ej. por credenciales de PAC no
configuradas, como en este servidor de pruebas), registrar un pago falla con
400 invoice.not_stamped — un REP no puede timbrarse contra una factura que en
primer lugar no timbró.
GET /v1/invoices/{id}/payments — lista los REP de una factura
{ "object": "list", "data": [] }
GET /v1/invoices/{id}/payments/{pid}/xml — descarga el XML del REP
Devuelve application/xml con Content-Disposition: attachment;
filename="rep-{pid}.xml". Mismo 404/409 que el resto de
descargas de CFDI si el REP no existe o aún no está timbrado.
Descarga del CFDI original (PUE o PPD)
| Endpoint | Content-Type |
|---|---|
GET /v1/invoices/{id}/xml | application/xml |
GET /v1/invoices/{id}/pdf | application/pdf |
Ambos requieren que el CFDI esté stamped — si no, 400 invoice.not_stamped.
Autofactura: que el cliente facture su propio ticket
Para el mostrador que no captura el RFC en el momento del cobro (el caso típico de "público
en general" con opción de 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.
{
"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).
POST /public/autofactura/{slug}/invoices — sin Authorization
El cliente captura su RFC y datos fiscales en la página pública; ésta llama este endpoint sin
ninguna clave — la combinación de receipt_code + rfc es la única
llave. Los mismos cinco campos de receptor que en PUE/PPD, más el
receipt_code del ticket:
curl -s https://api.winal.com.mx/public/autofactura/antech/invoices \
-H "Content-Type: application/json" \
-d '{
"receipt_code": "W-EMU7K",
"rfc": "XAXX010101000",
"nombre": "Publico en general",
"uso_cfdi": "S01",
"regimen_fiscal": "616",
"cp": "06600"
}'
invoice.pac_error{
"error": {
"type": "invalid_request_error",
"code": "invoice.pac_error",
"message": "El PAC rechazó el timbrado (CFDI 492400bb-969b-413b-97b8-93789f34ddb4 quedó en 'error'): ",
"doc_url": "https://winal.com.mx/docs/errores.html#err-invoice.pac_error",
"request_id": "0HNMSIOBNVS9K:00000001"
}
}
201) trae el mismo shape que un CFDI
normal: status: "stamped", uuid_fiscal, serie/folio, y las URL xml_url/pdf_url de descarga.
Descargas: receipt_code + rfc como llave
curl -s "https://api.winal.com.mx/public/autofactura/antech/invoices/{id}/xml?receipt_code=W-EMU7K&rfc=XAXX010101000"
curl -s "https://api.winal.com.mx/public/autofactura/antech/invoices/{id}/pdf?receipt_code=W-EMU7K&rfc=XAXX010101000"
Ambos parámetros deben coincidir con el CFDI ya timbrado — igual que el resto de descargas de
esta página, 400 invoice.not_stamped si el CFDI aún no timbra.
Anti-enumeración, en dos capas
Igual que el client_secret de checkout público,
autofactura nunca revela cuál dato falló:
| HTTP | code | Causa (todas indistinguibles entre sí) |
|---|---|---|
404 | autofactura.not_available | El slug no existe, o el comercio deshabilitó autofactura. |
404 | autofactura.receipt_not_found | El receipt_code no existe, es de otro tenant, o no coincide con el rfc de un CFDI ya emitido. |