Facturación CFDI

MODO PRUEBA

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.

Antes de tu primer CFDI: dos pasos de configuración
Facturar exige que tu cuenta tenga (1) perfil fiscal —RFC del emisor, régimen, lugar de expedición— y (2) credenciales del PAC. Mientras falten, 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 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.

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

Público en general: la tercia va junta
Con un RFC genérico (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.

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": "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:

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.

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.

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 (PUE) o a total_minor (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.

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

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": "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.

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

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" }'
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": "..."
}
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ó.

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.

Descarga del CFDI original (PUE o PPD)

EndpointContent-Type
GET /v1/invoices/{id}/xmlapplication/xml
GET /v1/invoices/{id}/pdfapplication/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.

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

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": "XAXX010101000",
    "nombre": "Publico en general",
    "uso_cfdi": "S01",
    "regimen_fiscal": "616",
    "cp": "06600"
  }'
Este servidor de pruebas responde con 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

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

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.