Todos los errores de la Api usan el mismo envelope, estilo Stripe. El campo estable para
tu código es error.code — el estado HTTP acompaña, pero no siempre sigue la
convención REST "perfecta" (lo explicamos más abajo).
El envelope
{
"error": {
"type": "invalid_request_error",
"code": "refund.exceeds_refundable",
"message": "El monto excede el saldo devolvible (84900 centavos).",
"doc_url": "https://winal.com.mx/docs/errores.html#err-refund.exceeds_refundable",
"request_id": "0HN7F3K2J4Q1O:00000003"
}
}
request_id es el mismo valor que el header X-Request-Id de la
respuesta — inclúyelo si escribes a soporte. type agrupa la familia del error
(invalid_request_error, authentication_error,
authorization_error, idempotency_error, rate_limit_error,
api_error).
doc_url apunta a esta misma página, con el ancla de la fila exacta del código
(#err-<code>): pega el doc_url de cualquier error en el navegador
y caes directo en su explicación. Un código todavía sin ancla te deja al inicio de la página.
Estados HTTP que puedes recibir
| HTTP | Cuándo |
|---|---|
400 | Solicitud inválida: falta un campo, formato incorrecto, o una regla de negocio que la Api trata como error del llamador. |
401 | Falta Authorization, la API key no existe, está mal formada o fue revocada — siempre el mismo mensaje genérico (no delata cuál de los tres pasó). |
403 | La clave o el client_secret no autorizan la operación sobre ESE recurso, o la API key no trae el scope que el endpoint exige (insufficient_scope — ver abajo). |
404 | El recurso no existe (o, en /public/*, el client_secret no coincide — mismo 404 genérico por diseño). |
409 | Conflicto de estado: reintento en vuelo, o la máquina de estados detectó una modificación concurrente. |
422 | Reusaste un Idempotency-Key con un cuerpo distinto. |
429 | Excediste el límite de solicitudes. |
500 | Error interno no controlado. Reintenta con backoff o escala con el request_id. |
Rutas inexistentes y error interno
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
404 | route.not_found | No existe la ruta que llamaste: ni el método ni el path coinciden con ningún endpoint real bajo /v1, /public, /app/api o /portal-auth — incluye un verbo HTTP equivocado sobre un endpoint que sí existe (responde 404, no 405). El message repite el verbo y la ruta exactos que enviaste. | Es el error más común en tu primer día de integración. Revisa en orden: el verbo HTTP, el prefijo de versión (/v1/payment_intents, no /v1/payment_intent sin la 's'), y compara contra Referencia de API. |
400 | invalid_request_body | El cuerpo de la solicitud no es JSON válido, o algún campo no tiene el tipo que se esperaba (un texto donde va un número, un objeto donde va una lista…) — aplica a CUALQUIER ruta de la Api que reciba un cuerpo, no solo a una en particular. El message trae el detalle exacto del framework: qué campo (Path) y a qué tipo no se pudo convertir. | Corrige el tipo del campo que señala message y reintenta; no es un fallo nuestro, así que reintentar con el mismo cuerpo mal tipado repite el mismo 400. |
500 | internal_error | Una excepción no controlada reventó el request antes de producir una respuesta normal — un fallo nuestro, no de tu llamada. | Reintenta con backoff (con el mismo Idempotency-Key si la operación mueve dinero); si persiste, escríbenos a hola@winal.com.mx con el request_id. |
Idempotencia (POST que mueven dinero)
Aplica a POST /v1/payment_intents, /confirm, /cancel,
/capture y POST /v1/refunds — todos exigen
Idempotency-Key (UUID). POST /v1/webhook_endpoints es la
excepción: no pasa por esta capa, así que el header ahí es opcional y se ignora.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | idempotency_key_required | Falta el header o no es un UUID válido. | Genera un UUID v4 nuevo por operación de negocio (no por request HTTP). |
409 + Retry-After: 2 | idempotency_key_in_progress | Otra solicitud con el mismo key sigue procesándose. | Reintenta en unos segundos con el mismo key. |
422 | idempotency_key_reused | Ya usaste ese key con un cuerpo distinto. | Bug del cliente: nunca reuses un key para una operación distinta. |
Una repetición exitosa del mismo key con el mismo cuerpo devuelve la respuesta original
cacheada, con el header Idempotency-Replayed: true — es seguro reintentar así
tras cualquier timeout de red.
Errores de balance
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | balance.invalid_currency | El ?currency= de GET /v1/balance no es un código ISO 4217 válido. | Usa MXN (default si omites el parámetro). |
Errores de payment_intents
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payment_intent.invalid_amount | amount_minor no es positivo, o falta currency. | Valida antes de enviar; el monto es siempre centavos enteros. |
400 | payment_intent.invalid_currency | El código de moneda no es ISO 4217 válido. | Usa MXN — hoy es la única moneda que soporta el conector Sim. |
400 | payment_intent.invalid_token | payment_token empieza con un prefijo reservado de método guardado (card-on-file, network token o domiciliación) — esos marcadores solo puede generarlos Winal internamente al resolver un payment_method_id ya verificado. | Para cobrar un método guardado usa payment_method_id en confirm; nunca construyas ni copies un payment_token con esos prefijos. |
403 | payment_intent.unauthorized | Ni la API key ni el client_secret autorizan este intent. | Verifica que el id y el client_secret correspondan al mismo intent. |
404 | payment_intent.not_found | El id no existe (visible solo vía /v1; en /public se generaliza). | Confirma el id devuelto al crear el intent. |
409 | payment_intent.not_confirmable | Intentaste confirmar un intent que no está en requires_payment_method/requires_confirmation (p. ej. ya succeeded). | Lee el estado actual antes de reintentar confirmar. |
400 | payment_intent.invalid_transition | Pediste cancelar (o, en un caso de borde, confirmar) un intent en un estado desde el que esa transición no está en la whitelist — el caso típico es cancelar uno que ya está succeeded/failed/expired. Los estados terminales del intent son inmutables por diseño (regla de oro del ledger). | Consulta GET /v1/payment_intents/{id} antes de mutar; si ya está en un estado terminal, la operación ya no aplica. |
400 | payment_intent.no_route | No hay conector dado de alta para ese método en tu cuenta. | En modo prueba el conector Sim viene activo y no hace falta nada. Para producción, el alta de conectores reales la hacemos nosotros: escríbenos a hola@winal.com.mx. |
400 | payment_intent.no_network_token_route | Confirmaste con un método guardado provisionado como network token portable, pero ningún conector configurado para tarjeta soporta network tokens. | Da de alta un conector con esa capability, o guarda el método sin network_token: true (card-on-file por-conector). |
400 | payment_intent.no_card_present_route | Confirmaste con payment_method: "card_present" (terminal/SmartPOS) pero ningún conector configurado para tarjeta soporta cobro con tarjeta presente. | Da de alta un conector con esa capability. Ver Métodos de pago → Card-present. |
400 | payment_intent.no_direct_debit_route | Confirmaste (o intentó renovar una suscripción) con un método de domiciliación, pero ningún conector configurado soporta débito directo. | Da de alta un conector con capability de domiciliación, o usa otro método de pago. |
400 | payment_intent.no_wallet_route | Confirmaste con apple_pay o google_pay pero ningún conector configurado para tarjeta soporta wallets nativas. | Da de alta un conector con esa capability. |
400 | payment_intent.no_store_voucher_route | Confirmaste con payment_method: "paynet" (voucher de efectivo en tienda) pero ningún conector configurado lo soporta. | Da de alta un conector con esa capability, o usa otro método. |
409 | payment_intent.concurrent_modification | Dos operaciones intentaron mutar el mismo intent a la vez. | Vuelve a leer el intent y reintenta la operación. |
400 | payment_intent.receipt_code_generation_failed | No se pudo generar un receipt_code (folio de autofactura) único para tu cuenta tras varios intentos — caso extremo de un tenant de volumen altísimo saturando el espacio de códigos. | Reintenta la creación del intent; si vuelve a pasar, escríbenos con el request_id. |
400 | attempt.not_capturable | Pediste /capture pero no hay un intento authorized pendiente. | La captura solo aplica tras un intento de tarjeta autorizado sin capturar (tok_sim_auth en pruebas). |
400 | attempt.invalid_transition | Se disparó al mover un intento (attempt) a un estado que la máquina de estados no permite desde el actual — los estados terminales del intento (voided, failed, expired, refunded, dispute_won, dispute_lost) son inmutables por diseño. Es una salvaguarda interna de consistencia; en la práctica suele indicar una carrera entre operaciones sobre el mismo intento (p. ej. dos resoluciones de contracargo casi simultáneas). | Vuelve a leer el intento con ?expand=attempts antes de repetir la operación; si persiste, escríbenos con el request_id. |
404 | attempt.not_found | El intento referido no existe. | Usa un attempt_id devuelto por ?expand=attempts. |
404 | checkout_config.no_route | GET /public/payment_intents/{id}/checkout-config pidió el SDK/public_key para un ?method= que no tiene ningún conector configurado en tu cuenta. | Verifica que el método exista en tu ruteo antes de montar el checkout con ese método. |
Errores de payment_links
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payment_link.invalid_amount | Faltan amount_minor positivo o currency ISO 4217 válidos en POST /v1/payment_links. | Manda ambos; el monto del link es fijo y siempre centavos enteros. |
400 | payment_link.invalid_currency | currency no es un código ISO 4217 válido. | Usa MXN. |
400 | payment_link.invalid_max_uses | max_uses no es un entero positivo. | Omite el campo para usos ilimitados, o manda un entero > 0. |
400 | payment_link.slug_generation_failed | No se pudo generar un slug único para el link tras varios intentos (colisión CSPRNG astronómicamente improbable). | Reintenta la creación; si vuelve a pasar, escríbenos con el request_id. |
404 | payment_link.not_found | El id del link (en DELETE /v1/payment_links/{id}) no existe en tu cuenta. | Usa el id devuelto al crear el link, o uno de GET /v1/payment_links. |
404 | link.not_available | El slug del checkout público (POST /public/payment_links/{slug}/intents, GET /pay/{slug}) no existe, el link está desactivado, o ya agotó sus usos — mismo 404 genérico para los tres casos, para no revelarle a quien adivina slugs cuál de ellos aplica. | Verifica con el comercio que el link siga activo y con cupo; genera uno nuevo si hace falta. |
Errores de 3D Secure (three_ds)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | three_ds.eci_required | POST /v1/attempts/{id}/three-ds sin eci — es el dato mínimo indispensable para el traslado de responsabilidad (liability shift) que exige un representment de contracargo. | Manda al menos eci; cavv, ds_trans_id, three_ds_version, trans_status y authentication_flow son opcionales. |
400 | three_ds.invalid_flow | authentication_flow no es frictionless ni challenge. | Usa uno de esos dos valores, o omite el campo. |
400 | three_ds.connector_result_authoritative | Ya existe un objeto 3DS de este intento reportado por el conector (autoritativo: la red lo otorgó tras el authorize); tu POST llegó después y no puede sobrescribirlo. Es una protección deliberada: si se permitiera, cualquiera podría fabricar un traslado de responsabilidad que la red nunca concedió. | No reintentes el POST — el objeto 3DS ya vive con el resultado real del conector; consúltalo con GET /v1/attempts/{id}/three-ds. |
404 | three_ds.not_found | GET /v1/attempts/{id}/three-ds y el intento no tiene objeto 3DS registrado todavía. | Repórtalo primero con POST, o espera a que el conector lo reporte tras el authorize. |
Errores de refunds
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | refund.invalid_amount | amount_minor no es estrictamente positivo. | Omite el campo para devolver todo el saldo restante, o manda un entero positivo. |
404 | attempt.not_found | El attempt_id no existe. | Usa el id de un intento real (no el del payment_intent). |
409 | refund.attempt_not_refundable | El intento no está captured/settled/partially_refunded. | Solo se puede devolver un cargo ya capturado. |
400 | refund.currency_mismatch | El monto solicitado trae una moneda distinta a la del cargo. | Usa la misma moneda del cargo original. |
400 | refund.nothing_refundable | El cargo ya no tiene saldo por devolver. | Consulta el saldo restante antes de reintentar. |
400 | refund.exceeds_refundable | El monto pedido excede lo cobrado menos devoluciones ya vivas. | El mensaje trae el saldo devolvible exacto en centavos. |
404 | refund.not_found | El id de la devolución no existe. | Usa el id devuelto al crear el refund. |
Errores de disputes (contracargos)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
404 | dispute.not_found | El id del contracargo no existe en tu cuenta. | Usa un id de GET /v1/disputes. |
400 | dispute.invalid_status | El ?status= de GET /v1/disputes no es un estado válido. | Usa needs_response, under_review, won, lost o accepted. |
400 | dispute.invalid_evidence | evidence de POST /v1/disputes/{id}/evidence no es un objeto JSON válido. | Manda un objeto JSON (no un array ni un escalar). |
400 | dispute.invalid_transition | La máquina de estados del contracargo no permite la transición pedida desde su estado actual — el caso más común es reenviar evidencia (POST .../evidence) sobre un contracargo que ya está under_review (el envío no es acumulativo por request: solo se admite una vez por vuelta), o aceptar/enviar evidencia de uno ya resuelto (won/lost/accepted, todos terminales). | Consulta GET /v1/disputes/{id} antes de reenviar evidencia; si necesitas agregar más, mándala completa en la misma llamada (el campo evidence se sobrescribe entero). |
409 | dispute.concurrent_modification | Dos operaciones intentaron mutar el mismo contracargo a la vez. | Vuelve a leer el contracargo (GET /v1/disputes/{id}) y reintenta. |
400 | dispute.invalid_filename | filename del documento de evidencia (POST /v1/disputes/{id}/documents) está vacío o excede 256 caracteres. | Manda un nombre de archivo de 1 a 256 caracteres. |
400 | dispute.invalid_content_type | content_type está vacío o excede 128 caracteres. | Manda un tipo MIME válido (p. ej. application/pdf, image/png). |
400 | dispute.invalid_base64 | content_base64 no es base64 válido. | Codifica el archivo correctamente antes de mandarlo. |
400 | dispute.empty_document | El documento decodificado de content_base64 quedó vacío. | Verifica que el base64 codifique contenido real, no una cadena vacía. |
400 | dispute.document_too_large | El documento excede el máximo de 10 MiB por archivo. | Comprime o recorta el archivo antes de subirlo. |
400 | dispute.too_many_documents | El contracargo ya tiene 20 documentos de evidencia adjuntos, el máximo permitido. | No adjuntes más; consolida la evidencia en menos archivos. |
400 | dispute.documents_too_large | La suma de todos los documentos del contracargo excedería 50 MiB — el tope AGREGADO existe porque la transmisión al adquirente carga todos los adjuntos en memoria a la vez. | Reduce el tamaño del documento que subes, o de los ya adjuntos. |
Errores de subscriptions
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | subscription.invalid_amount | Faltan amount_minor positivo o currency ISO 4217 válidos al crear la suscripción. | Manda ambos; el monto recurrente es siempre centavos enteros. |
400 | subscription.invalid_interval | Falta interval.unit (day/week/month) o interval.count no es un entero positivo. | Manda los dos campos de interval. |
400 | subscription.invalid_currency | currency no es un código ISO 4217 válido. | Usa MXN. |
400 | subscription.invalid_method | Falta payment_method y tampoco mandaste payment_method_id (card-on-file). | Manda uno de los dos. |
400 | subscription.invalid_token | Falta payment_token (el token reutilizable del proveedor) y tampoco mandaste payment_method_id. | Manda uno de los dos. |
400 | subscription.invalid_trial | trial_days no es un entero positivo. | Omite el campo para cobrar de inmediato, o manda un entero > 0. |
404 | subscription.not_found | El id de la suscripción no existe en tu cuenta. | Usa un id de GET /v1/subscriptions. |
409 | subscription.state_conflict | Pediste pausar, reanudar o cancelar una suscripción desde un estado que no lo permite (p. ej. reanudar una que no está paused, o mutar una ya canceled — terminal e inmutable). | Lee GET /v1/subscriptions/{id} antes de reintentar; una suscripción canceled no vuelve atrás. |
400 | subscription.no_payment_method | Intentaste reanudar (POST /v1/subscriptions/{id}/resume) una suscripción MIGRADA que nunca tuvo un instrumento de cobro portable (realidad PCI: la tarjeta original no se pudo migrar). Es una protección: sin ella, el scheduler intentaría cobrar un centinela interno como si fuera un token real. | El cliente debe re-inscribirse (pagar el link de re-inscripción, que le pide una tarjeta nueva) antes de que la suscripción vuelva a ser cobrable. |
Errores de coupons
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | coupon.invalid_code | POST /v1/coupons sin code. | Manda un código no vacío; se compara sin distinguir mayúsculas. |
400 | coupon.invalid_discount | No mandaste exactamente uno de percent_off (1-100) o amount_off_minor (positivo) — los dos a la vez, o ninguno, son inválidos. | Manda exactamente uno de los dos campos, dentro de su rango. |
400 | coupon.invalid_duration | duration no es once, repeating ni forever. | Usa uno de esos tres valores. |
400 | coupon.invalid_duration_periods | duration es repeating pero falta duration_periods (o no es un entero positivo). | Manda duration_periods cuando uses repeating. |
400 | coupon.invalid_max_redemptions | max_redemptions no es un entero positivo. | Omite el campo para cupo ilimitado, o manda un entero > 0. |
409 | coupon.code_conflict | Ya existe un cupón con ese code en tu cuenta (la unicidad es por tenant, sin distinguir mayúsculas). | Usa otro código, o reutiliza el cupón existente (GET /v1/coupons). |
404 | coupon.not_found | El coupon_code que mandaste al crear una suscripción (POST /v1/subscriptions) no existe. | Verifica el código contra GET /v1/coupons. |
400 | coupon.inactive | El coupon_code de la suscripción corresponde a un cupón que ya desactivaste. | Usa un cupón activo, u omite coupon_code. |
400 | coupon.exhausted | El cupón alcanzó su max_redemptions: ya no tiene cupo para ligarse a una suscripción nueva. | Usa otro cupón, o crea uno nuevo con un tope mayor (o sin tope). |
Errores de webhook_endpoints
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | webhook_endpoint.invalid_url | url de POST /v1/webhook_endpoints no es una URL http(s) absoluta, ni tampoco la convención especial sink:events (el sumidero de GET /v1/events por polling). | Manda una URL https:// completa, o usa literalmente sink:events si solo quieres consumir eventos por polling — ver Eventos y polling. |
400 | webhook_endpoint.blocked_host | El host de la url apunta LITERALMENTE a una dirección interna/privada (localhost, loopback, rango privado, link-local, IP de metadata de nube). Es una protección contra SSRF: sin ella, cualquiera podría registrar un webhook que hiciera a nuestro Worker atacar redes internas —tuyas o nuestras— desde nuestros servidores. | Usa un host público real al que puedas responder desde internet; no puedes apuntar webhooks a tu propia red interna. |
400 | webhook_endpoint.insecure_url | La url usa un esquema distinto de https:// (salvo la convención sink:events). La entrega lleva el payload FIRMADO: sobre http:// viaja en claro y cualquiera en la ruta lee la firma y el contenido —y podría reusar la firma. | Usa siempre https:// como destino de tus webhooks; no hay excepción para modo prueba. |
404 | webhook_endpoint.not_found | El id de DELETE /v1/webhook_endpoints/{id} no existe en tu cuenta. | Usa un id de GET /v1/webhook_endpoints. |
Errores de propinas, reportes y facturación
Verificados en vivo contra el servidor de pruebas.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payment_intent.invalid_tip | tip_minor es negativo (en POST /v1/payment_intents o en /confirm). | Envía tip_minor ≥ 0, o omítelo (equivale a 0 / "no tocar la propina existente" en confirm). |
400 | reports.invalid_period | from/to no son ISO-8601 válidos, o from no es anterior a to. | En /v1/reports/cash-cut ambos son obligatorios; en el resto de /v1/reports/* son opcionales (default: últimos 30 días). |
400 | reports.invalid_currency | El ?currency= no es un código ISO 4217 válido. | Usa MXN (default si omites el parámetro). |
400 | reports.invalid_format | Falta ?format= en /v1/reports/polizas, o no es contpaqi/aspel_coi. | No hay default: pasa explícitamente uno de los dos valores. |
400 | reports.invalid_cursor | El ?cursor= de /v1/reports/payments es inválido o está corrupto. | Usa el next_cursor devuelto por la página anterior, no lo construyas a mano. |
400 | reports.period_not_elapsed | Pediste cerrar (POST /v1/reports/periods/{year}/{month}/close) un período que todavía no terminó: el mes en curso o uno futuro. | Guarda de negocio: el cierre es irreversible y no tiene ruta de reapertura. Espera a que el mes termine por completo antes de cerrarlo. |
400 | invoice.invalid_body | Falta payment_intent_id (POST /v1/invoices//payments), total_minor positivo (POST /v1/invoices/pue y /ppd), o periodicidad/meses (POST /v1/invoices/global). | Revisa el cuerpo contra Referencia de API → Facturas. |
400 | invoice.invalid_receptor | Falta receptor o alguno de sus campos (rfc, nombre, uso_cfdi, regimen_fiscal, cp). | Los cinco campos del receptor son obligatorios. Manda el RFC real de tu cliente — el genérico XAXX010101000 ya no vale en esta ruta (rechazo CFDI40130, ver invoice.global_required_for_publico_en_general); esas ventas van por POST /v1/invoices/global. |
400 | invoice.invalid_currency | La currency de POST /v1/invoices/pue o /ppd no es ISO 4217 válida. | Omite el campo para MXN por default, o usa un código válido. |
400 | invoice.pac_error | El PAC rechazó el timbrado: credenciales inválidas, o el CFDI no pasó alguna de sus validaciones. El RFC genérico XAXX010101000 en un comprobante individual ya no llega hasta aquí: lo atrapa antes invoice.global_required_for_publico_en_general, con el mismo código sea cual sea tu PAC. | Revisa error_detail en el CFDI (GET /v1/invoices/{id}). Si el problema son las credenciales o el perfil fiscal, corrígelos tú mismo en tu tablero (winal.com.mx/app → Facturación). |
400 | invoice.not_stamped | Pediste el XML/PDF de un CFDI que aún no timbró, o registraste un pago (POST /v1/invoices/{id}/payments) —o reintentaste su REP— contra una factura PPD que no está timbrada y viva: aún sin timbrar, o ya cancelada (una cancelada conserva su UUID, pero ya no ampara la operación que el complemento documentaría). El mensaje trae el estado real. | Consulta status antes de descargar; un REP solo aplica sobre una factura PPD stamped. Si la factura se canceló, vuelve a facturar la venta y documenta el pago contra la factura nueva. |
404 | invoice.not_found | El id del CFDI no existe. | Usa el id devuelto al crear la factura. |
400 | invoice.receptor_incoherente | El RFC genérico de residente extranjero (XEXX010101000) sin la tercia que exige el SAT: regimen_fiscal 616 y uso_cfdi S01 (el nombre, a diferencia del nacional, es el real de tu cliente). El mensaje dice cuál de las dos falló. El genérico nacional (XAXX010101000) ya no llega aquí: lo atrapa antes invoice.global_required_for_publico_en_general, sin mirar si esta tercia está bien o mal. | Con RFC genérico extranjero, manda esa combinación exacta. Con un RFC real, elige el uso_cfdi que tu cliente pida — ver Facturación CFDI. |
400 | invoice.invalid_forma_pago | Falta forma_pago en POST /v1/invoices/pue, /global o /{id}/payments con el pago declarado (sin payment_intent_id), o la clave no existe en el catálogo c_FormaPago del SAT (que tiene huecos: no existen 07, 09–11, 16 ni 18–22). En el REP se emite también con forma_pago: "99", que ahí no vale: la 99 es la de la factura PPD que el complemento viene a liquidar. | Manda la clave de dos dígitos: 01 efectivo, 02 cheque, 03 transferencia, 04 tarjeta de crédito, 28 tarjeta de débito. En estas rutas es obligatoria: sin cobro de Winal no hay método del cual deducirla. |
400 | invoice.forma_pago_requires_ppd | Usaste forma_pago: "99" (Por definir) en POST /v1/invoices/pue o /global. | El 99 es exclusivo de PPD: tanto una PUE como una global declaran una venta YA pagada, así que decir que la forma de pago está por definir es contradictorio y el SAT lo rechaza. Si el cobro está pendiente, la ruta es POST /v1/invoices/ppd. |
404 | invoice.intent_not_found | El payment_intent_id a facturar no existe (o no es de tu cuenta). | Usa el id que devolvió POST /v1/payment_intents. |
400 | invoice.intent_not_succeeded | El cobro todavía no está succeeded (típicamente processing: la Api ya aceptó el cobro pero el proveedor aún no confirma). | Solo se factura un cobro liquidado. Espera el webhook payment_intent.succeeded —o consulta el intent— antes de facturar; nunca asumas el desenlace por tiempo. |
400 | invoice.already_exists | Ese cobro ya tiene un CFDI vivo (pendiente o timbrado). | Un cobro se factura una sola vez. Lee el CFDI existente en vez de crear otro. |
400 | invoice.conceptos_sum_mismatch | La suma de importe_minor de los conceptos no iguala el total del comprobante. | El mensaje trae ambas cifras en centavos: cuadra los conceptos contra el total del cobro. |
400 | invoice.livemode_mismatch | El ambiente de la operación no coincide con el ambiente contra el que se iba a timbrar (p. ej. cobro de prueba contra PAC productivo). Se cruzan dos cosas. (1) El perfil fiscal: cuando hay cobro de por medio (POST /v1/invoices, los REP de /v1/invoices/{id}/payments) se compara el modo del cobro; cuando no lo hay (/v1/invoices/pue, /v1/invoices/ppd, /v1/invoices/global, notas de crédito, cancelación, constancias de retenciones, la factura de tu comisión) se compara el modo de la llave que autenticó la petición; en el CFDI de nómina, el modo de la corrida. (2) El comprobante sobre el que actúas, cuando ya existe: reintentar (/retry), cancelar (/cancel), complementar (REP) o acreditar (nota de crédito) compara además contra el ambiente en que ESE comprobante se timbró — un dato de su propia fila que nunca cambia, tampoco el día que tu cuenta pasa a producción. | Guarda de seguridad: nunca se timbra —ni se cancela— un comprobante fiscal real desde una llave de prueba, ni se emite en sandbox uno que creerías válido. Alinea la llave (sk_test_/sk_live_) con el ambiente del PAC en tu perfil fiscal. Y si el rechazo es por el comprobante: el que nació en pruebas se opera SIEMPRE en pruebas —hace falta una sk_test_ y el perfil en sandbox—; un CFDI de prueba que quedó en error no tiene valor fiscal ni te obliga ante el SAT, así que la venta real se factura como comprobante NUEVO. El mensaje dice qué ambiente trae cada lado. |
400 | invoice.razon_social_con_regimen_capital | La razón social que intentas guardar en tu perfil fiscal termina en el régimen de capital (SA DE CV, S DE RL DE CV, AC…). El SAT NO lo incluye en el nombre que tiene registrado para tu RFC, y rechaza cada CFDI con CFDI40138. | Guárdala sin esa parte final: el mensaje trae el sufijo detectado y el nombre ya corregido, listo para copiar. Es el nombre tal cual aparece en tu Constancia de Situación Fiscal, en mayúsculas y sin comas. Winal no lo recorta por su cuenta: el nombre fiscal lo declaras tú. |
409 | invoice.fiscal_identity_conflict | El RFC del emisor cambiaría en una cuenta que ya timbró comprobantes (facturas, notas de crédito o complementos de pago). Ese historial está atribuido ante el SAT a ese contribuyente y un CFDI no se borra: se cancela, y deja rastro. Ocurre por dos caminos: declarando otro RFC, o subiendo el sello digital de otro contribuyente —el RFC del perfil sale del certificado, así que cargar uno ajeno cambia el emisor—. El mensaje dice cuál de los dos fue; no se guarda nada. | Si el RFC nuevo es otro contribuyente —el de un cliente tuyo, por ejemplo—, dale su propia cuenta y captura ahí su perfil fiscal y su sello: Da de alta a tus clientes → Da de alta tu primer cliente. Si simplemente te equivocaste de archivo, sube el certificado del RFC que aparece en el mensaje. Corregir el nombre, el régimen o el código postal sí se puede, siempre. Mientras la cuenta no haya timbrado nada, el RFC también se corrige libremente: basta con subir el sello correcto. |
400 | invoice.fiscal_profile_missing | Tu cuenta aún no tiene perfil fiscal (RFC del emisor, régimen, lugar de expedición). | Es lo PRIMERO que hay que configurar antes de facturar. Cárgalo tú mismo en la sección Facturación de tu tablero: https://winal.com.mx/app/. No es un error de tu código. |
400 | invoice.account_key_required | Intentaste escribir el perfil fiscal o el sello digital por API (PUT /v1/fiscal-profile, PUT /v1/fiscal-profile/csd) sin la segunda credencial: esas rutas solo configuran una de las cuentas que administras. | Manda la credencial de esa cuenta en la cabecera Winal-Account-Key, junto con la tuya en Authorization. El perfil y el sello de tu propia cuenta se cargan desde tu consola (https://winal.com.mx/app/ → Facturación), que pide contraseña y segundo factor: el sello es el material con el que se firman comprobantes fiscales a tu nombre y no se cambia con la sola credencial de API. |
400 | invoice.pac_credentials_missing | Hay perfil fiscal, pero no hay con qué autenticarse ante el PAC en ese ambiente: ni credenciales tuyas ni cuenta de la plataforma con ese proveedor. También lo devuelve —sin tocar nada— una mudanza de proveedor que dejaría tu perfil así. | Normalmente la cuenta del PAC la pone Winal (somos distribuidores), y el caso típico es un perfil que quedó apuntando a un proveedor con el que no tenemos contrato: muévelo con POST /app/api/fiscal-profile/pac (Facturación → Proveedor de timbrado), que se lleva tu sello digital consigo. Solo si traes contrato propio con el PAC cargas tú pac_username/pac_password en la sección Facturación de tu tablero: https://winal.com.mx/app/. |
400 | invoice.produccion_not_ready | Pediste poner tu perfil fiscal en produccion pero en ese ambiente todavía no se puede timbrar: falta la cuenta del PAC o —si tu PAC lo exige— tu CSD. El material de timbrado se guarda por ambiente, así que el CSD que subiste en pruebas no vale para producción. | Manda csd_cer, csd_key y csd_key_password en el mismo PUT que ambiente: "produccion". Nada se guardó: el rechazo es previo a la escritura. |
400 | production.not_enabled | Pediste poner tu perfil fiscal en produccion sin que tu cuenta tenga la activación de producción aprobada. | Complétala primero en la sección Producción de tu tablero (necesitas una capacidad completa: cobrar o facturar). Ver El camino a producción. |
400 | invoice.pac_credentials_incomplete | Las credenciales del PAC existen pero les falta usuario o contraseña. | Recaptúralas en la sección Facturación de tu tablero: https://winal.com.mx/app/. |
400 | invoice.pac_not_supported | El PAC configurado en tu perfil fiscal no tiene implementación disponible en la plataforma. Con él no puedes emitir nada, por completo que se vea el resto de tu perfil. | Ya no hace falta escribirnos: repáralo desde tu tablero con POST /app/api/fiscal-profile/pac (Facturación → Proveedor de timbrado), que mueve tu perfil al proveedor de la plataforma llevándose tu sello digital. Sin cuerpo, hace justo eso; con pac, te mueve al que nombres, siempre que alguien tenga cuenta con él. El estado del perfil (pac_advertencia) te lo dice antes de tu primera factura. |
400 | invoice.invalid_pac | La clave de PAC enviada al guardar el perfil fiscal no tiene el formato válido (minúsculas, dígitos y _, de 2 a 32 caracteres). | Usa la clave exacta del proveedor (p. ej. facturama), en minúsculas. Omitir el campo conserva el PAC que ya tenías. |
400 | invoice.pac_unreachable | El proveedor de timbrado no respondió (red, mantenimiento o indisponibilidad suya). No es un rechazo de tu comprobante: no hubo veredicto. El CFDI queda guardado en error con error_kind: "unreachable" y retryable: true. | Transitorio: reintenta con backoff usando la misma Idempotency-Key — se REANUDA el mismo comprobante, nunca se emite un segundo. También puedes reintentarlo por su id con POST /v1/invoices/{id}/retry. |
400 | invoice.pac_unreachable_unverified | El proveedor de timbrado no respondió y ese proveedor no permite consultar si llegó a timbrar. El resultado es INCIERTO y nadie lo puede aclarar preguntando. | No reintentes automáticamente: a diferencia de invoice.pac_unreachable, aquí la Idempotency-Key NO se libera, precisamente para que tu bucle de reintentos no emita un segundo CFDI por la misma venta. Comprueba el comprobante en la consola de tu PAC y, solo si NO se timbró, usa POST /v1/invoices/{id}/retry. |
400 | invoice.not_retryable | El CFDI no se puede volver a timbrar ahora: tiene un intento EN CURSO, otro reintento se adelantó, o no está en error (un comprobante ya timbrado no se re-timbra: su folio fiscal es definitivo). | Consulta GET /v1/invoices/{id}: si ya está stamped no hay nada que hacer; si sigue pending, un intento huérfano se libera solo a los 15 minutos y entonces POST /v1/invoices/{id}/retry funciona. |
400 | invoice.sat_status_not_supported | El PAC configurado en tu perfil fiscal no implementa la consulta del estatus del CFDI ante el SAT. No todos la publican: depende del proveedor, no de tu cuenta. | No es un error de tu integración ni afecta al comprobante, que sigue timbrado. Consulta el estatus en el portal del SAT, o cambia a un PAC que sí exponga la consulta (escríbenos a hola@winal.com.mx). |
400 | invoice.sat_status_error | El PAC no pudo responder la consulta de estatus ante el SAT (falló el servicio, o respondió sin informar el estado del comprobante). | Habitualmente transitorio —la consulta depende del servicio del SAT— y NO cambia el estado de tu CFDI: reintenta más tarde. El mensaje trae el detalle que devolvió el PAC. |
400 | invoice.pac_bad_response | El PAC respondió algo que no se pudo interpretar. | Reintenta con la misma clave; si persiste, escala con el request_id. |
400 | invoice.pac_no_uuid | El PAC respondió sin UUID de timbre — el CFDI no se considera timbrado. | Nunca lo des por bueno sin uuid_fiscal. Reintenta con la misma clave y confirma con GET /v1/invoices/{id}. |
400 | invoice.global_periodo_invalido | El período de POST /v1/invoices/global no es una combinación que el SAT acepte. Cubre cinco casos: periodicidad fuera de c_Periodicidad; meses incoherente con ella (los bimestres son 13–18, y solo valen con periodicidad: "05"); anio distinto del año en curso o del inmediato anterior; un período que todavía no ha comenzado; y periodicidad: "05" (bimestral) en un emisor que no está en el Régimen de Incorporación Fiscal (621). | El mensaje dice cuál de los cinco falló. Para el mes de agosto: periodicidad: "04", meses: "08". Para el bimestre julio-agosto: periodicidad: "05", meses: "16" (exclusivo de emisores RIF). Ver Facturación CFDI → Factura global. |
400 | invoice.global_already_exists | Ya existe una factura global viva (pendiente o timbrada) para ese período en el ambiente de tu llave. Timbrar dos del mismo mes duplica ingresos e IVA ante el SAT. La global que emitiste en pruebas NO ocupa el período de la real: la unicidad es por ambiente. | No reintentes: el mensaje trae el id y el folio fiscal de la que ya existe. Si hay que corregirla, emite la que la sustituye mandando sustituye_uuid con ese folio, y después cancela la original con motive: "01" apuntando a la nueva. |
404 | invoice.sustituida_not_found | El sustituye_uuid no corresponde a ninguna factura global timbrada de tu cuenta. | Usa el uuid_fiscal exacto que devolvió la global que vas a reemplazar (GET /v1/invoices). Solo se sustituye un comprobante que existe y ya tiene timbre del SAT. |
400 | invoice.sustitucion_periodo_distinto | La global que mandas sustituir ampara un período distinto del que declara la nueva. | Una sustitución reemplaza el comprobante del mismo período: si sustituyes la global de julio, la nueva también debe declarar julio. El mensaje trae ambos períodos. |
400 | invoice.invalid_sustituye_uuid | Mandaste sustituye_uuid vacío. | Omite el campo por completo para emitir la global del período por primera vez; solo mándalo cuando estés corrigiendo una ya timbrada. |
400 | invoice.global_required_for_publico_en_general | Mandaste el RFC genérico XAXX010101000 a una ruta de factura ordinaria (POST /v1/invoices, /pue o /ppd). El SAT no admite un CFDI de ingreso suelto a ese receptor (rechazo CFDI40130): esas ventas se amparan con la factura global del período.Llega con este code propio y antes de tocar al PAC: la ruta lo rechaza en su puerta de entrada, sin consumir folio ni sellar nada. El sellador conserva la misma comprobación como última puerta, así que el rechazo no depende de por dónde entres.No confíes en que “pasó en pruebas”: el ambiente de demo de un PAC puede timbrar este comprobante sin protestar — quien lo rechaza es el SAT, y sin esta guarda el fallo aparecería por primera vez en producción, con el CFDI ya sellado con tu CSD. | Usa POST /v1/invoices/global para las ventas de las que nadie pidió comprobante, o el RFC real del cliente si sí lo pidió. |
400 | invoice.global_requires_publico_en_general | La inversa de la anterior: un comprobante con nodo global cuyo receptor NO es el RFC genérico. Una factura global es, por definición, la de quienes no dieron su RFC; ponerle uno concreto declararía que un solo contribuyente hizo todas las operaciones del período. | No es alcanzable desde POST /v1/invoices/global —esa ruta construye ella misma el receptor genérico— así que si lo ves, escala con el request_id. Para facturar a un cliente concreto usa /v1/invoices/pue o /ppd con su RFC. |
invoice.fiscal_profile_missing (configura el perfil fiscal) →
invoice.pac_credentials_missing (carga las credenciales del PAC) →
invoice.receptor_incoherente o invoice.conceptos_sum_mismatch
(ajusta el cuerpo) → CFDI timbrado. Los dos primeros son configuración de la cuenta, no
bugs de tu código.
Facturas PPD y complementos de pago (REP)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.invalid_total | El total_minor de POST /v1/invoices/pue o /ppd es cero o negativo. | Envía un total_minor positivo, en centavos, que incluya el IVA. |
400 | invoice.not_ppd | Llamaste a POST /v1/invoices/{id}/payments —o a su /retry— sobre una factura PUE; los REP (complementos de pago) solo aplican a facturas PPD. | Una factura PUE ya quedó liquidada en el mismo timbrado — no necesita REP. Revisa metodo_pago en la factura antes de registrar el pago. |
400 | invoice.payment_currency_mismatch | La moneda del payment_intent no coincide con la de la factura PPD que estás liquidando. | Usa un cobro en la misma moneda que la factura, o crea la factura PPD en la moneda del cobro. |
400 | invoice.payment_fecha_invalid | La fecha_pago que declaraste al registrar un pago cobrado FUERA de Winal está en el futuro, o es de un DÍA anterior al de expedición de la factura PPD que liquida (comparado por día calendario en la zona horaria del lugar de expedición, no por instante). | Omite fecha_pago para usar el instante actual, o manda la fecha real del pago. Una fecha futura la rechaza el SAT (un complemento no documenta un pago que no ha ocurrido); una de un día anterior al de la factura describiría el pago de una venta que ese día todavía no tenía comprobante — el mismo día, con hora anterior a la del timbrado, sí vale. |
400 | invoice.payment_intent_already_applied | Ese payment_intent_id ya respalda otro REP o CFDI vivo. Convención de fase 0: un cobro liquida una sola parcialidad de una sola factura. | Usa un cobro que no se haya aplicado antes; si necesitas liquidar más saldo, crea un payment_intent nuevo por cada parcialidad. |
400 | invoice.payment_exceeds_saldo | El monto del cobro excede el saldo insoluto pendiente de la factura PPD. | El mensaje trae ambas cifras en centavos. Ajusta el monto del cobro para que no exceda el saldo, o repártelo en varios cobros/parcialidades. |
400 | invoice.payment_already_exists | Ya existe un REP vivo para ese cobro o esa parcialidad —perdiste una carrera de concurrencia— o esa misma Idempotency-Key ya documentó un pago (el ancla de los pagos registrados a mano, que no tienen payment_intent_id del cual colgar la unicidad). | Reintenta la lectura del REP con GET /v1/invoices/{id}/payments: probablemente ya existe. Si de verdad es OTRO pago, mándalo con una Idempotency-Key nueva — repetir la anterior es, por contrato, pedir el mismo pago otra vez. |
400 | invoice.payment_not_retryable | El complemento de pago (REP) no se puede volver a timbrar ahora: no está en error (uno ya timbrado no se re-timbra: su folio fiscal es definitivo), o se emitió antes de que Winal persistiera lo que lo hace reproducible (fecha de expedición, fecha y forma del pago). Perder una carrera contra otro reintento simultáneo ya no responde este código, sino invoice.payment_retry_conflict. | Consulta GET /v1/invoices/{id}/payments: si ya está stamped no hay nada que hacer. Si el mensaje dice que ese REP no es reproducible, comprueba en la consola de tu PAC si llegó a timbrarse y, solo si no, registra el pago de nuevo con una Idempotency-Key nueva — nunca lo registres otra vez sin comprobarlo: serían dos complementos ante el SAT por el mismo dinero. |
409 | invoice.payment_retry_conflict | Otro reintento del MISMO complemento de pago se adelantó a éste: está en curso, o terminó y también falló. No es que el REP no se pueda reintentar — es que perdiste la carrera. | No registres el pago otra vez: sería un SEGUNDO complemento ante el SAT por el mismo dinero. Consulta GET /v1/invoices/{id}/payments en unos segundos; si quedó stamped ya está documentado (y ese mismo reintento te lo habría devuelto con 200), y si volvió a error llama otra vez a POST /v1/invoices/{id}/payments/{pid}/retry. |
409 | invoice.idempotency_conflict | Reusaste una Idempotency-Key que ya documentó un pago de esta factura, pero el cuerpo describe un pago DISTINTO (otro importe, otra forma de pago, otra fecha, otra serie o folio). El mensaje dice exactamente qué discrepa. | Si de verdad es otro pago, mándalo con una llave nueva: devolverte el REP anterior dejaría este pago sin documentar. Si querías reintentar el mismo, manda el MISMO cuerpo. |
404 | invoice.payment_not_found | El {pid} de GET /v1/invoices/{id}/payments/{pid}/xml no existe, o el REP pertenece a OTRA factura distinta de {id}. | Usa el id devuelto al registrar el pago, y pasa la factura correcta en la ruta — un REP de otra factura del mismo tenant se ve como inexistente. |
400 | invoice.payment_not_stamped | Pediste el XML de un REP que todavía está pending o cayó en error: no llegó a timbrarse. | Consulta status con GET /v1/invoices/{id}/payments antes de descargar; solo un REP stamped tiene XML. |
Cancelación de CFDI
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.invalid_cancel_motive | El motive de POST /v1/invoices/{id}/cancel no es uno de los cuatro motivos del SAT. | Usa 01 (con sustitución), 02, 03 o 04. |
400 | invoice.cancel_substitution_required | Cancelaste con motivo 01 (sustitución) sin mandar substitution_uuid. | El motivo 01 exige el uuid_fiscal del CFDI que sustituye a éste. |
400 | invoice.cancel_requires_substitution | El CFDI tiene un REP o una nota de crédito VIVOS que lo relacionan, y lo cancelaste con un motivo distinto de 01. | Guarda que impone el SAT, no un capricho de Winal: no acepta cancelar sin sustitución un comprobante con relacionados vivos. Cancela con motivo 01 y el uuid_fiscal de sustitución, o cancela primero el REP/nota de crédito relacionados. |
400 | invoice.no_provider_ref | El CFDI no tiene provider_id (referencia del PAC) para cancelarlo o descargar su archivo — no debería ocurrir en un CFDI stamped normal. | Si lo ves, escala con el request_id: es un estado inconsistente del CFDI, no algo que corrijas desde tu request. |
Notas de crédito (CFDI de Egreso por reembolso)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.credit_note_invalid_request | Falta refund_id, o amount_minor no es positivo, en POST /v1/invoices/{id}/credit-notes. | Manda ambos campos: refund_id del reembolso real y amount_minor positivo en centavos. |
400 | invoice.credit_note_invalid_amount | El monto de la nota de crédito no es positivo. | Envía un monto positivo; normalmente ya lo atajó invoice.credit_note_invalid_request al validar el cuerpo. |
404 | invoice.refund_not_found | El refund_id no existe para tu cuenta (o es de otro tenant). | Usa el id de un reembolso real devuelto por POST /v1/refunds. |
400 | invoice.refund_not_succeeded | El reembolso existe pero todavía no está succeeded: el dinero no ha salido de verdad. | Espera el webhook refund.succeeded —o consulta el refund— antes de emitir la nota de crédito. |
400 | invoice.refund_amount_mismatch | amount_minor/currency del cuerpo no coinciden EXACTO con el monto y moneda reales del reembolso. | El monto fiscal sale siempre del reembolso real, nunca del cuerpo de tu request; el cuerpo es solo una confirmación — mándalo idéntico al refund. |
400 | invoice.credit_note_currency_mismatch | La moneda del reembolso no coincide con la de la factura que se acredita. | Verifica que factura y reembolso compartan la misma moneda antes de emitir la nota de crédito. |
400 | invoice.refund_intent_mismatch | El reembolso corresponde a un payment_intent distinto del que documenta la factura. | Usa el reembolso del MISMO cobro que generó la factura que quieres acreditar. |
400 | invoice.credit_notes_exceed_total | La suma de las notas de crédito VIVAS más este reembolso excede el total de la factura de ingreso original. | El mensaje trae las tres cifras en centavos. No se puede acreditar más de lo que se facturó; revisa si ya emitiste otra nota de crédito para esta factura. |
404 | invoice.credit_note_not_found | El id de la nota de crédito no existe. | Usa el id devuelto al emitirla con POST /v1/invoices/{id}/credit-notes. |
400 | invoice.credit_note_already_exists | Perdiste una carrera de concurrencia: otra petición simultánea ya insertó la nota de crédito — con el mismo refund_id, o con el mismo Idempotency-Key. | Reintenta la llamada con el MISMO Idempotency-Key: la nota que ganó la carrera se devuelve tal cual, sin emitir una segunda. Si el choque fue por refund_id, lista las notas de la factura: ya existe una para ese reembolso. |
400 | invoice.credit_note_not_retryable | La nota de crédito no se puede volver a timbrar ahora: tiene un intento EN CURSO sin resolver por 15 minutos, no está en error (una nota timbrada no se re-timbra: su folio fiscal es definitivo), o se emitió antes de que Winal persistiera lo que la hace reproducible. Perder una carrera contra otro reintento simultáneo ya no responde este código, sino invoice.credit_note_retry_conflict. | Consulta GET /v1/invoices/{id}/credit-notes/{note_id}: si ya está stamped no hay nada que hacer; si sigue pending, un intento huérfano se libera solo a los 15 minutos y entonces POST /v1/invoices/{id}/credit-notes/{note_id}/retry funciona. Si el mensaje dice que la nota no es reproducible, comprueba en la consola de tu PAC si llegó a timbrarse y, si no, emite una nota nueva. |
409 | invoice.credit_note_retry_conflict | Otro reintento de la MISMA nota de crédito se adelantó a éste: está en curso, o terminó y también falló. No es que la nota no se pueda reintentar — es que perdiste la carrera. | No emitas otra nota por la misma devolución: sería un SEGUNDO CFDI de egreso ante el SAT por el mismo dinero. Consulta GET /v1/invoices/{id}/credit-notes/{note_id} en unos segundos; si quedó stamped ya está documentada (y ese mismo reintento te la habría devuelto con 200), y si volvió a error llama otra vez a POST /v1/invoices/{id}/credit-notes/{note_id}/retry. |
400 | invoice.credit_note_not_stamped | La nota de crédito no está timbrada: se intentó cancelarla o descargar su XML/PDF estando en pending o en error. | Solo se cancela y se descarga un comprobante que llegó a existir ante el SAT. Si quedó en error, revisa error_detail y vuelve a emitirla; una nota ya canceled sí se puede descargar. |
400 | invoice.egreso_sin_cfdi_relacionado | La nota de crédito no declara el folio fiscal de la factura que acredita (nodo CfdiRelacionados con tipo de relación 01). | Emítela sobre una factura TIMBRADA (POST /v1/invoices/{id}/credit-notes): el UUID de la relación sale de esa factura. Sin él, el CFDI de ingreso original quedaría vivo y sin contraparte ante el SAT. |
Conceptos e IVA por línea (RETAIL con tasas mezcladas)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.invalid_concepto | Un elemento de conceptos trae importe_minor cero o negativo. En POST /v1/invoices/global, donde el arreglo es obligatorio, también dispara si conceptos viene vacío u omitido. | Cada concepto necesita un importe_minor positivo, en centavos — ver Conceptos con IVA mezclado. Una factura global necesita al menos una línea: no hay total_minor del cual derivarla. |
400 | invoice.too_many_conceptos | El comprobante trae más de 10 000 conceptos. | Es una cota de TAMAÑO, no fiscal: el XML se arma entero en memoria. Agrupa las líneas (una factura global admite un concepto por ticket, no por artículo) o parte la emisión en varios comprobantes. |
400 | invoice.importe_out_of_range | El importe de un concepto —o la suma de todos— excede 999,999,999,999.99, el máximo que el tipo t_Importe del Anexo 20 puede representar. | Un comprobante con ese importe no se puede timbrar por definición del esquema del SAT. Revisa que el monto vaya en centavos (es el error típico: mandar pesos en un campo _minor). |
400 | invoice.invalid_treatment | El treatment de un concepto no es uno de los cuatro valores válidos. | Usa 'tasa16', 'tasa8', 'tasa0' o 'exento'. |
400 | invoice.mixed_rate_unsupported | Intentaste timbrar un REP o una nota de crédito sobre una factura cuyos conceptos mezclan tasas de IVA (p. ej. 16% y 0% en el mismo comprobante). | El REP/nota de crédito de una factura de tasa MEZCLADA todavía no está soportado (exige traslados por tasa); úsalo solo sobre facturas de tasa única. En el camino del REP ya casi no lo verás: una PPD de tasas mezcladas se rechaza al crearla (invoice.ppd_mixed_rate_unsupported), así que este error queda para las que se emitieron antes de esa guarda y para las notas de crédito PARCIALES. |
400 | invoice.ppd_mixed_rate_unsupported | Intentaste emitir una factura PPD cuyos conceptos mezclan tasas de IVA. Se rechaza antes de timbrar: su complemento de pago (REP) tendría que repartir cada parcialidad entre las tasas, así que la factura quedaría timbrada ante el SAT sin forma de cerrarse — y timbrada ya no hay marcha atrás. | Emite una PPD por tasa (una para los conceptos gravados y otra para los exentos o a tasa 0), o factura esa canasta en PUE (POST /v1/invoices/pue) al cobrarla, que sí admite tasas mezcladas. |
400 | invoice.unknown_treatment | La factura relacionada tiene un tratamiento de IVA que el sistema no reconoce — dato inconsistente, no algo que dependa de tu request. | No debería ocurrir en operación normal; si lo ves, escala con el request_id. |
400 | invoice.invalid_ieps | El ieps de un concepto viene mal formado: una tasa que no está en el catálogo c_TasaOCuota del SAT para el impuesto 003, una cuota fuera del rango 0–59.144900, una cantidad gravada no positiva, o las dos formas (tasa y cuota) a la vez. | Manda el IEPS de una de las dos formas: tasa_bps con una de las tasas del catálogo (0, 300, 600, 700, 800, 900, 2500, 2650, 3000, 3040, 5000, 5300, 16000), o cuota_micro + cantidad_micro, ambas en millonésimas. |
400 | invoice.descuento_exceeds_importe | El descuento_minor de un concepto es igual o mayor que su importe_minor. Esa línea se quedaría sin base gravable y el esquema del SAT exige una base mayor que cero en el traslado: el comprobante no se podría timbrar. | Un 3+1 —o cualquier «lleve N pague M»— no se documenta con una línea regalada aparte: se pone un solo concepto con las cuatro piezas en importe_minor y el valor de una en descuento_minor. Así el impuesto se traslada sobre lo que de verdad se cobra y la línea conserva base. |
400 | invoice.invalid_descuento | El descuento_minor de un concepto es negativo. | Un descuento negativo sería un recargo, y eso no se documenta así: si el importe sube, sube el importe_minor de la línea. |
400 | invoice.invalid_cantidad | La cantidad_micro o el valor_unitario_micro de un concepto están fuera de lo que el Anexo 20 puede representar: cantidad cero o negativa (el esquema del SAT exige un mínimo de 0.000001), o cualquiera de los dos por encima del techo de importe del comprobante. | Las dos van en millonésimas: 3 piezas son 3000000, medio kilo es 500000, y $45.00 son 45000000. La causa habitual es mandar el precio en centavos. |
400 | invoice.cantidad_incompleta | El concepto declara cantidad_micro sin valor_unitario_micro, o al revés. Winal no deriva el que falta: dividir el importe daría un número redondeado que ya no es el del ticket, y esta capacidad existe justamente para que la factura diga lo mismo que el ticket. | Manda los dos —o ninguno—. Sin ninguno, el concepto sale como hasta ahora: Cantidad="1" y valor unitario igual al importe de la línea. |
400 | invoice.ieps_exceeds_importe | El IEPS por cuota del concepto se come el importe entero y no deja valor gravable. Suele ser una cantidad mal declarada (mililitros en vez de litros, piezas de más). | Revisa cantidad_micro: va en millonésimas de la unidad fiscal (media botella de 500 ml son 0.5 litros, o sea 500000), no en mililitros. |
400 | invoice.ieps_breakdown_unsupported | Intentaste timbrar un REP, o una nota de crédito PARCIAL, sobre una factura con IEPS. Ese comprobante tendría 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ó — algo que solo sabe quien lo hizo. La nota de crédito por el TOTAL sí se emite: reexpide las MISMAS líneas de la factura, con su mismo traslado del 003. | Para devolver todo, emite la nota por el total exacto de la factura. Para una devolución parcial de un ticket con IEPS, cancela el comprobante y reexpídelo por lo que sí queda vendido. En el camino del REP ya casi no lo verás: una PPD con IEPS se rechaza al crearla (invoice.ppd_ieps_unsupported), así que ahí queda para las PPD emitidas antes de esa guarda. |
400 | invoice.ppd_ieps_unsupported | Intentaste emitir una factura PPD con un concepto que traslada IEPS. Se rechaza antes de timbrar: su complemento de pago (REP) —el que la ley te obliga a emitir en cuanto el cliente pague— todavía no puede trasladar el IEPS de la parcialidad, así que la factura quedaría timbrada ante el SAT sin forma de cerrarse, y timbrada ya no hay marcha atrás. | Factura esos conceptos en PUE (POST /v1/invoices/pue) cuando cobres: ahí el IEPS sí se emite, con su traslado del impuesto 003 por línea. |
400 | invoice.ppd_rep_unsupported | La misma guarda, por un motivo distinto del IEPS y de las tasas mezcladas. Hoy tiene una causa concreta: el PAC configurado en tu cuenta no timbra el complemento de pago, así que una factura PPD quedaría ante el SAT sin forma de cerrarse. También es el comodín de cualquier motivo futuro; el detalle exacto va siempre en el message. | Factura al cobrar con POST /v1/invoices/pue, que no necesita REP, o configura un PAC que lo emita (facturama y finkok lo hacen). Si el message describe otro motivo y no es accionable, escala con el request_id. |
400 | invoice.ieps_pac_unsupported | El PAC configurado en tu perfil fiscal no tiene soporte verificado para el traslado de IEPS. | Emite el comprobante con un PAC que timbre el CFDI que Winal sella (p. ej. finkok): ése sí escribe el traslado del impuesto 003. |
Comunicación con el PAC: descarga y cancelación
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.pac_cancel_error | El PAC rechazó la CANCELACIÓN del CFDI (distinto de rechazar el timbrado). | Revisa el detalle del mensaje; causas comunes son un CFDI ya cancelado en el PAC, o el plazo vencido para cancelar sin aceptación del receptor. |
400 | invoice.cancel_pending | La solicitud de cancelación se envió y el SAT la ACEPTÓ, pero el comprobante todavía no está cancelado: sigue VIGENTE. Un CFDI "cancelable con aceptación" no se cancela hasta que el receptor acepta o vence el plazo — y si lo rechaza, no se cancela nunca. También se devuelve cuando no se pudo consultar el estado ante el SAT (indeterminado, que no es lo mismo que "no cancelado"). | No lo des por cancelado ni reexpidas la venta. Winal deja el comprobante en stamped a propósito: es lo cierto ante el SAT. Repite la misma llamada —es idempotente ante el PAC— hasta que responda 200; el mensaje trae el estatus que reportó el SAT. |
400 | invoice.pac_download_error | El PAC rechazó la descarga del XML/PDF de un CFDI ya timbrado. El mensaje trae el motivo textual del proveedor. Caso frecuente: Permission Denied al pedir el PDF significa que la cuenta ante el PAC no tiene contratada la generación de la representación impresa; no es un fallo pasajero. | Lee el motivo del mensaje. Si es Permission Denied sobre el PDF, reintentar no lo resuelve: el XML timbrado —que es el comprobante con validez fiscal— sí se descarga con normalidad, y para entregar la impresión hay que habilitar el PDF en la cuenta del PAC. En los demás casos reintenta; si persiste, escala con el request_id. |
400 | invoice.pac_empty_file | El PAC respondió con éxito pero sin contenido: el archivo (XML/PDF) llegó vacío. | Reintenta la descarga; si persiste, escala con el request_id. |
Comprobantes especiales: nómina y retenciones
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | invoice.no_perceptions | El recibo de nómina a timbrar no ampara ninguna percepción. | Asegura que la corrida incluya al menos una percepción por empleado antes de que el pago se confirme y dispare el timbrado del CFDI de nómina. |
400 | invoice.invalid_nomina | Los datos del recibo no arman un complemento de Nómina 1.2 válido (fechas o campos requeridos). | El mensaje trae el detalle exacto del campo inválido; corrígelo en el empleado o en la corrida antes de reintentar. |
400 | invoice.no_retention | La constancia de retenciones no ampara retención alguna: ISR e IVA retenidos en cero para el período. | No se timbra una constancia vacía; solo aplica cuando hubo retención real (ISR y/o IVA) en el período agregado. |
400 | invoice.invalid_period | El período (ejercicio, mes inicial, mes final) de la constancia de retenciones es inválido. | Verifica que el ejercicio y los meses (1-12, inicial ≤ final) del período agregado sean coherentes. |
400 | invoice.retention_not_supported | El PAC configurado no implementa el timbrado de CFDI de Retenciones. | Con Facturama no deberías verlo (implementa los cinco tipos de comprobante). Con un PAC que recibe el comprobante ya sellado —como Finkok— sí aparece: por ahora Winal solo arma comprobantes de ingreso. Cambia el PAC de esa cuenta para emitir este tipo. |
400 | invoice.nomina_not_supported | El PAC configurado no implementa el timbrado de CFDI de Nómina. | Igual que con retenciones: con Facturama no aparece; con un PAC de timbrado directo, sí. |
400 | invoice.egreso_not_supported | El PAC configurado no implementa el timbrado de CFDI de Egreso (nota de crédito). | Hoy no deberías verlo: los dos PAC integrados emiten el egreso —facturama lo arma con su propio JSON y finkok sella el CFDI que genera Winal—. Queda como el rechazo por defecto de un PAC futuro que no lo implemente. |
400 | invoice.rep_not_supported | El PAC configurado no implementa el timbrado del Complemento de Pagos 2.0 (REP, tipo P). | Hoy no deberías verlo: los dos PAC integrados emiten el REP —facturama lo arma con su propio JSON y finkok sella el CFDI tipo P que genera Winal—. Queda como el rechazo por defecto de un PAC futuro que no lo implemente; y ese PAC ya no te deja emitir facturas PPD en primer lugar (invoice.ppd_rep_unsupported), para que nunca descubras la limitación con la factura ya timbrada. |
400 | invoice.csd_missing | El PAC configurado recibe el CFDI ya sellado por Winal, pero la cuenta no tiene guardado el CSD del comercio. | Hay que guardar en las credenciales del PAC el certificado (csd_cer, el .cer en base64), la llave (csd_key, el .key en base64) y su contraseña (csd_key_password). Son los archivos que entrega el SAT, sin conversión previa; hoy los carga Winal en tu cuenta — ver Configura tu facturación → Tu CSD. |
400 | invoice.pac_registration_error | El PAC no pudo dar de alta el RFC del emisor en la cuenta. No es la respuesta de ninguna llamada: el alta del RFC ante el PAC se intenta de lado al guardar el perfil y al timbrar, y su fallo se GUARDA en el estado de registro del perfil fiscal (lo ves en GET /v1/fiscal-profile y en la consola), nunca se devuelve como error de la petición. Por eso ninguna ruta lo declara. | El mensaje trae el detalle del proveedor. Registrar dos veces el mismo RFC NO es un error (se resuelve como éxito): si aparece, el rechazo es real — normalmente un RFC mal escrito o una cuenta suspendida. Corrige el dato y vuelve a guardar el perfil, que reintenta el alta. |
400 | invoice.csd_certificate_invalid | El certificado (csd_cer) no se pudo leer como CSD del SAT: no es el DER que entrega el SAT, no trae llave pública RSA, o su número de serie no son los 20 dígitos del NoCertificado del Anexo 20. | Sube el .cer tal cual lo entrega el SAT, codificado en base64 y sin convertir a PEM. Si lo que subiste fue tu e.firma o una solicitud .req/.ren, el código que recibes es otro y más preciso: invoice.csd_is_efirma o invoice.csd_is_certificate_request. |
400 | invoice.csd_is_efirma | El certificado que subiste no es un CSD: declara usos de cifrado (dataEncipherment, keyAgreement) que un Certificado de Sello Digital nunca trae y que son los de la e.firma (antes FIEL). Son dos certificados distintos del SAT y se confunden constantemente. | Con la e.firma no se puede facturar: el SAT rechaza el timbrado. Tramita tu CSD —gratis y en línea— en CertiSAT Web, donde te identificas precisamente con tu e.firma, y sube el .cer y el .key del CSD con la contraseña que elegiste al generarlo (no es la de tu e.firma). Ver Configura tu facturación → Tu CSD. |
400 | invoice.csd_is_certificate_request | El archivo no es un certificado sino una solicitud de certificado (los .req y .ren que genera la aplicación Certifica del SAT): es lo que se envía al SAT, no lo que el SAT devuelve. | Descarga tu .cer en CertiSAT Web con el número de operación de esa solicitud, y súbelo junto con el .key que Certifica generó al crearla. Ojo con el .ren: es una solicitud de renovación de la e.firma, y la e.firma no sirve para facturar (invoice.csd_is_efirma). |
400 | invoice.csd_key_invalid | La llave privada (csd_key) no se pudo abrir: el archivo no es la llave cifrada que entrega el SAT o la contraseña (csd_key_password) es incorrecta. | Sube el .key tal cual, en base64, y verifica la contraseña de la clave privada —que NO es la contraseña de tu cuenta del PAC ni la de tu e.firma—. |
400 | invoice.csd_key_mismatch | La llave privada (.key) no corresponde al certificado (.cer) cargado: son de CSD distintos. | Error típico de quien administra varios CSD. Vuelve a subir el par completo del mismo certificado; si se sellara con una llave ajena, el SAT rechazaría el comprobante. |
400 | invoice.csd_rfc_missing | El certificado no declara el RFC de su titular (atributo x500UniqueIdentifier), así que no es un CSD emitido por el SAT. | Verifica que el archivo sea el CSD descargado de CertiSAT y no otro certificado cualquiera. |
400 | invoice.csd_rfc_mismatch | Al sellar, el CSD con el que se iba a firmar pertenece a un RFC distinto del emisor del comprobante. | Guarda de seguridad de la plataforma: jamás se sella el comprobante de un comercio con el certificado de otro. Guardar el perfil fiscal ya no puede producir este error: el RFC del perfil se deriva del certificado que subes, así que no existe desacuerdo posible entre los dos. Si aun así lo ves, el perfil quedó desalineado antes de esa regla: sube el sello del contribuyente que factura desde esa cuenta y el RFC se corrige solo (Configura tu facturación → Tu CSD). |
400 | invoice.csd_not_valid_at_date | El CSD no está vigente en la fecha del comprobante; el mensaje trae el rango de vigencia del certificado. | Los CSD del SAT duran cuatro años. Tramita uno nuevo en CertiSAT y súbelo; se atrapa aquí para no gastar un timbre en un comprobante que el SAT rechazaría. Antes de tramitarlo, confirma que el archivo sea tu CSD y no tu e.firma: quien ve este error suele estar mirando el certificado equivocado, y las vigencias de los dos no tienen por qué coincidir. |
400 | invoice.cfdi_field_invalid | Un campo de texto del comprobante no cumple el Anexo 20: viene vacío, excede el largo máximo, o trae una barra vertical | o un carácter de control. Incluye serie (máx. 25) y folio (máx. 40) cuando los mandas en el cuerpo de una ruta de facturación — se rechazan EN LA PUERTA, antes de intentar timbrar. | El mensaje nombra el campo. La | es el separador de la cadena original que se sella, así que no puede aparecer en ningún texto (descripción, razón social, serie, folio…). Limpia el dato en origen. |
400 | invoice.cfdi_amounts_inconsistent | La aritmética del comprobante no cierra: los conceptos, el desglose de impuestos y el total no cuadran al centavo. | No debería ocurrir con importes armados por la Api. Si lo ves, escala con el request_id: emitir un CFDI descuadrado es una contingencia fiscal, así que se detiene antes de timbrar. |
400 | invoice.cfdi_currency_unsupported | La factura viene en una moneda distinta de MXN y el PAC configurado exige que Winal arme el comprobante. | El sellador propio solo emite en pesos: el Anexo 20 exigiría además TipoCambio. Factura en MXN. |
400 | invoice.cfdi_fecha_invalid | La fecha de expedición del comprobante no es utilizable: viene en UTC en vez de la hora local del lugar de expedición, o el año cae fuera del rango que admite el esquema del SAT. | Interno del sellado. El SAT compara la fecha del comprobante con la del timbrado, y una fecha en UTC quedaría en el futuro. Reintenta; si persiste, escala con el request_id. |
400 | invoice.cadena_original_failed | No se pudo calcular la cadena original del comprobante (el texto que se firma) a partir de su XML. | Interno del sellado; el CFDI no se emitió. Reintenta con la misma Idempotency-Key y escala con el request_id si persiste. |
400 | invoice.cfdi_seal_failed | El sello generado no verifica contra el certificado del propio comprobante, así que no se emite. | Última red de seguridad antes de enviar al PAC: se prefiere no emitir a emitir un comprobante mal sellado. Reintenta y escala con el request_id. |
Errores de antifraude
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
409 | payment_intent.blocked_by_risk | Una regla de riesgo con action: "block" disparó sobre este confirm. No se creó ningún attempt. | El mensaje trae la razón exacta (p. ej. el tope de monto excedido). Ver Antifraude. |
Cuando la regla es action: "review" en vez de block, no hay
error: el confirm responde 200 con un objeto review
nuevo (ver Antifraude) — el intent queda a la espera de que un
operador la apruebe o la rechace. Hoy no existe un endpoint de /v1 para resolverla: si activas reglas con acción review, avísanos a hola@winal.com.mx para acordar cómo se atienden.
Errores de customers / payment_methods
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
404 | customer.not_found | El id del cliente no existe. | Usa el id devuelto al crear el cliente. |
409 | customer.concurrent_modification | Dos operaciones intentaron mutar el mismo cliente a la vez. | Vuelve a leer el cliente (GET /v1/customers/{id}) y reintenta. Hasta 2026-09 este choque se devolvía llamándose payment_method.concurrent_modification aunque la petición no tocara ningún método de pago: el nombre mentía sobre el recurso. |
400 | payment_method.invalid_token | Falta payment_token al guardar un método. | Es requerido: token de un solo uso de la tokenización (tok_sim_* en pruebas). |
404 | payment_method.not_found | El id del método no existe. | Usa el id devuelto al guardarlo. |
400 | payment_method.not_chargeable | El método referido por payment_method_id en confirm no está active. | El mensaje trae el estado real del método; solo un método active puede cobrar. |
400 | payment_method.no_route | No hay conector dado de alta que pueda resolver el guardado del método. | Configura el ruteo en el portal antes de guardar métodos. |
409 | payment_method.concurrent_modification | Dos operaciones intentaron mutar el mismo método a la vez. | Vuelve a leer el método y reintenta. |
Errores de receivables
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | receivable.missing_fields | Falta customer_name, customer_email o concepto. | Los tres son siempre requeridos. |
400 | receivable.invalid_amount | amount_minor no es positivo. | Manda un entero > 0 en centavos. |
400 | receivable.invalid_currency | currency no es ISO 4217 válida. | Usa MXN. |
400 | receivable.invalid_date | due_date inválida. | Manda un ISO-8601 válido. |
400 | receivable.incomplete_fiscal_receptor | Se mandó solo alguno de los cuatro datos fiscales del receptor. | customer_rfc, customer_uso_cfdi, customer_regimen_fiscal y customer_cp van juntos o ninguno. |
404 | receivable.not_found | El id no existe. | Usa el id devuelto al crear la cuenta. |
400 | receivable.no_phone | Se pidió whatsapp_link sin customer_phone capturado. | Captura customer_phone al crear la cuenta. |
400 | receivable.invalid_customer_email | Falta el query customer_email en GET /v1/receivables/statement. | Es obligatorio: el estado de cuenta siempre se consulta por el correo del cliente. |
Errores de conciliación bancaria
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | bank_statement.bank_required | Falta bank. | Manda el nombre del banco emisor. |
400 | bank_statement.invalid_period | Faltan/son inválidos period_start/period_end, o el rango está invertido. | Formato yyyy-MM-dd, con period_start ≤ period_end. |
400 | bank_statement.invalid_tolerance | tolerance_days negativo. | Omite el campo o manda un entero ≥ 0. |
400 | bank_statement.unknown_preset | preset no es bbva/banorte/santander. | Usa uno de los tres, o el mapeo explícito de columnas. |
400 | bank_statement.mapping_required | No se dio preset ni un mapeo explícito completo. | Manda date_column/description_column/credit_column/debit_column. |
400 | bank_statement.invalid_mapping | El mapeo explícito de columnas es inconsistente. | Revisa que las columnas no se traslapen y sean válidas (0-based). |
400 | bank_statement.invalid_match_status | ?match_status= no es matched/unmatched/partial. | Usa uno de esos tres valores, o ninguno. |
400 | bank_statement.too_large | El archivo excede 5 MiB. | Parte el estado de cuenta por período más corto. |
400 | bank_statement.parse_error | Una fila no parsea (fecha/monto inválidos, o trae abono Y cargo — o ninguno — a la vez). | El mensaje trae el número de fila exacto; revisa el mapeo de columnas contra tu CSV real. |
400 | bank_statement.invalid_request | Backstop genérico de POST /v1/reconciliation/bank-statements: envuelve cualquier ArgumentException de validación del importador que no capturan ya los checks explícitos de bank/período/tolerancia/mapeo. | El mensaje trae el detalle exacto del argumento rechazado; en la práctica no deberías verlo si esos checks previos ya pasaron. |
Errores de autofactura
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | autofactura.invalid_body | Falta alguno de los campos requeridos del cuerpo. | Revisa contra Referencia de API → Autofactura pública. |
400 | autofactura.invalid_rfc | El rfc no cumple el formato del SAT (regex, no catálogo). Distinto del genérico bien formado pero no admitido — ver autofactura.generic_rfc_not_allowed abajo. | Usa el RFC real de tu cliente, con el formato del SAT (3-4 letras + 6 dígitos + homoclave de 3). |
400 | autofactura.generic_rfc_not_allowed | El cliente final capturó el RFC genérico XAXX010101000 (“público en general”). El SAT no admite un CFDI de ingreso individual a ese receptor (rechazo CFDI40130), así que la autofactura dejó de ofrecer esa opción y exige RFC. Se rechaza antes de resolver el slug y antes de buscar el receipt_code. | Nada que arreglar del lado del comercio: esas ventas ya quedan amparadas por tu factura global del período. La página /factura/{slug} se lo explica al cliente con esas palabras — que su compra ya está facturada y que no tiene que hacer nada. |
404 | autofactura.not_available | El slug no existe, o el comercio deshabilitó autofactura — mismo 404 genérico para ambos casos. | Confirma con el comercio que la autofactura esté habilitada. |
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 — mismo 404 genérico en los tres casos (anti-enumeración). | Verifica el receipt_code del ticket y que el RFC sea el correcto. |
Errores de onboarding_application
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | onboarding_application.invalid_legal_name | Falta legal_name al crear. | Es obligatorio desde el alta mínima. |
400 | onboarding_application.invalid_person_type | person_type ausente o distinto de fisica/moral. | Usa uno de esos dos valores. |
400 | onboarding_application.invalid_contact_email | Falta contact_email al crear. | Es obligatorio desde el alta mínima. |
400 | onboarding_application.invalid_document | Un documento trae document_type inválido, o falta reference/reference_hash. | Usa uno de los 4 tipos válidos: ine, comprobante_domicilio, constancia_fiscal, caratula_estado_cuenta. |
404 | onboarding_application.not_found | El id no existe. | Usa el id devuelto al crear la solicitud. |
409 | onboarding_application.transition_conflict | PUT/submit sobre una solicitud que ya no es editable, o carrera entre dos requests. | Lee el estado actual antes de reintentar. |
400 | onboarding_application.missing_fields | submit sin todos los campos obligatorios. | El mensaje lista exactamente cuáles faltan — revisa Onboarding. |
400 | onboarding_application.missing_documents | submit sin los 4 documentos requeridos. | Adjunta los 4 tipos antes de enviar a revisión. |
400 | onboarding_application.prohibited_giro | El giro declarado coincide con la lista de giros/MCC prohibidos por política de riesgo (armas, apuestas/casino, narcóticos, esquemas piramidales, contenido para adultos, criptoactivos no regulados, entre otros) al enviar la solicitud a revisión con POST /v1/onboarding/applications/{id}/submit. Es un rechazo de política, no un error de formato: ese giro no es onboardeable, punto. | Winal no da de alta ese giro; no reintentes con los mismos datos. |
400 | onboarding_application.invalid_rfc | Al aprobar (POST /v1/onboarding/applications/{id}/approve) el RFC no está capturado o su formato no corresponde al person_type declarado (12 posiciones para moral, 13 para física). | Corrige el RFC con PUT /v1/onboarding/applications/{id} y vuelve a aprobar. |
400 | onboarding_application.invalid_clabe | Al aprobar, la CLABE de liquidación no está capturada o no pasa la validación de formato. Sin custodia (ADR-0001): el dinero se liquida a la CLABE del comercio, así que jamás se aprueba una solicitud sin un destino válido. | Corrige la CLABE con PUT /v1/onboarding/applications/{id} y vuelve a aprobar. |
400 | onboarding_application.missing_reason | reject o request-info sin reason. La razón queda en el expediente: una solicitud rechazada o devuelta sin motivo es incontestable para el comercio. | Manda reason con el motivo legible. |
Errores de connect
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | connect.invalid_application_id | onboarding_application_id ausente o no es un uuid. | Manda el id de una solicitud de Onboarding real. |
404 | connect.application_not_found | La solicitud referida no existe. | Verifica el id. |
400 | connect.application_not_approved | La solicitud existe pero no está approved. | Espera a que Onboarding la resuelva como aprobada. |
400 | connect.missing_clabe | El sub-comercio de POST /v1/connect/accounts está approved en Onboarding pero su CLABE de liquidación quedó ausente o mal formada. Es una defensa en profundidad — Connect jamás liga un destino de dinero sin una CLABE válida, sin importar qué diga Onboarding. | Corrige la CLABE de la solicitud en Onboarding (PUT /v1/onboarding/applications/{id}) antes de reintentar el alta. |
409 | connect.account_conflict | Esa solicitud ya está ligada a una cuenta Connect. | Usa GET /v1/connect/accounts para encontrar la cuenta existente. |
404 | connect.account_not_found | El id de la cuenta (o un connect_account_id en splits) no existe. | Verifica el id. |
400 | connect.account_suspended | La cuenta Connect no está activa. | Solo cuentas activas pueden recibir splits. |
400 | connect.invalid_payment_intent | payment_intent_id ausente o no es un uuid. | Usa el id de un cobro real, ya exitoso. |
400 | connect.invalid_charge | Falta charge_amount_minor positivo o currency. | Ambos son requeridos en POST /v1/connect/transfers. |
400 | connect.invalid_fee | application_fee_minor es negativo en POST /v1/connect/transfers. | La comisión de la plataforma debe ser cero o positiva. |
400 | connect.currency_mismatch | En POST /v1/connect/transfers, application_fee_minor o alguna porción de splits está en una moneda distinta a la del cargo (currency). | Usa la MISMA moneda en el cargo, la comisión y cada porción. |
400 | connect.invalid_currency | Moneda ISO 4217 inválida. | Usa MXN. |
400 | connect.missing_allocations | splits vacío o ausente. | Manda al menos una porción. |
400 | connect.invalid_allocation | Una porción trae connect_account_id inválido o amount_minor no positivo. | Revisa cada elemento de splits. |
400 | connect.split_mismatch | application_fee_minor + Σ splits no cuadra exactamente con charge_amount_minor. | El mensaje explica la regla; ajusta los montos para que sumen exacto. |
404 | connect.charge_not_found | El payment_intent_id de POST /v1/connect/transfers no existe para esta plataforma — mismo 404 genérico si es de otro tenant, para no filtrar su existencia. | Usa el id de un cobro real de tu propia plataforma. |
400 | connect.charge_not_succeeded | El payment_intent que quieres partir en POST /v1/connect/transfers no está succeeded. | Solo un cobro exitoso se reparte; espera a que confirme antes de partirlo. |
400 | connect.charge_amount_mismatch | El charge_amount_minor/currency de POST /v1/connect/transfers no coincide con el monto real del payment_intent que quieres partir. | Usa el monto exacto del cobro (GET /v1/payment_intents/{id}) al declarar el split. |
404 | connect.transfer_not_found | El id del transfer no existe. | Verifica el id. |
400 | connect.invalid_reserve | Cuerpo ausente en POST /v1/connect/accounts/{id}/reserve: se requieren reserve_bps (0–10000) y reserve_days (>= 0). | Manda ambos campos dentro de esos rangos; reserve_bps: 0 desactiva la reserva rodante. |
400 | connect.invalid_reserve_bps | reserve_bps fuera de 0–10000 en POST /v1/connect/accounts/{id}/reserve. | Usa un valor entre 0 (desactiva la reserva) y 10000 (100%). |
400 | connect.invalid_reserve_days | reserve_days negativo en POST /v1/connect/accounts/{id}/reserve. | Usa un entero mayor o igual a 0. |
400 | connect.invalid_person_type | person_type ausente o distinto de fisica/moral/desconocido en POST /v1/connect/accounts/{id}/tax-profile. | Usa uno de esos tres valores; solo fisica dispara la retención del régimen de plataformas. |
400 | connect.invalid_retention_settings | Cuerpo ausente en PUT /v1/connect/retention-settings. | Manda al menos enabled; las tasas en puntos base (isr_bps, iva_retention_bps, …) son opcionales y usan su default si se omiten. |
400 | connect.invalid_retention_rate | Alguna tasa (isr_bps, iva_retention_bps, isr_bps_sin_rfc, iva_retention_bps_sin_rfc) de PUT /v1/connect/retention-settings está fuera de 0–10000 puntos base. | Usa puntos base entre 0 y 10000 (0–100%) en cada tasa. |
400 | connect.invalid_account | Falta connect_account_id (uuid) o no es válido — en GET /v1/connect/retentions (query) o POST /v1/connect/retentions/certificates (cuerpo). | Usa el id de una cuenta Connect real (GET /v1/connect/accounts). |
400 | connect.invalid_period | period no tiene el formato YYYY-MM en GET /v1/connect/retentions o POST /v1/connect/retentions/certificates. | Usa el formato YYYY-MM (p. ej. 2026-07); si lo omites, se usa el mes en curso. |
400 | connect.no_retentions | POST /v1/connect/retentions/certificates para un sub-comercio sin ninguna retención calculada en ese period. | Confirma que hubo porciones con retención en ese período (GET /v1/connect/retentions); si el régimen se activó a mitad de mes, no hay nada que timbrar para meses anteriores. |
409 | connect.certificate_issuing | Otra solicitud ya está timbrando esta misma constancia de retenciones (mismo sub-comercio + período) en este instante. | Reintenta en unos segundos; no dispares dos POST /v1/connect/retentions/certificates en paralelo para el mismo sub/período. |
400 | connect.certificate_stamp_failed | El PAC rechazó el timbrado de la constancia de retenciones (CFDI de Retenciones 2.0, c_CveRetenc=26). | El mensaje trae el detalle del PAC. La constancia queda en error y es reintentable con el mismo POST /v1/connect/retentions/certificates. |
404 | connect.certificate_not_found | El id de la constancia (GET .../retentions/certificates/{id} o .../xml) no existe. | Verifica el id devuelto al preparar la constancia con POST /v1/connect/retentions/certificates. |
400 | connect.certificate_not_issued | Pediste el XML sellado (GET /v1/connect/retentions/certificates/{id}/xml) de una constancia que aún no está timbrada (status distinto de issued, o sin XML persistido). | Espera a que POST /v1/connect/retentions/certificates la timbre y vuelve a pedir el XML. |
Errores de payouts
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payout.missing_fields | Falta clabe, beneficiary_name o concepto. | Los tres son siempre requeridos. |
400 | payout.invalid_amount | Falta amount_minor positivo o currency. | Ambos son requeridos. |
400 | payout.invalid_currency | currency no es un código ISO 4217 parseable. | Usa un código válido. |
400 | payout.unsupported_currency | Moneda ISO 4217 válida pero distinta de MXN. | Las dispersiones SPEI solo operan en pesos. |
400 | payout.invalid_clabe | La CLABE no son 18 dígitos, o el dígito de control es incorrecto. | Verifica la CLABE con el algoritmo Banxico 3-7-1 antes de enviarla. |
400 | payout.connector_not_livemode | Ordenaste una dispersión de producción por un conector que no puede dispersar de verdad (hoy, el simulador sim) o que no está registrado. No se crea el payout ni se asienta nada en el ledger. | Configura un conector de dispersión real (stp) para el ambiente de producción y vuelve a ordenarla. Es una salvaguarda deliberada: el simulador acepta la orden sin mover un peso, así que el payout habría quedado processing y el ledger habría asentado dinero como dispersado sin que saliera nunca. |
400 | payout.missing_clabe | La dispersión llegó sin CLABE destino. Lo emite el núcleo de payouts, así que también lo ves en las dispersiones que originan Connect, nómina y financiamiento. | Registra la CLABE del beneficiario (del sub-comercio, del empleado o del financiador) antes de ordenar la dispersión. |
400 | payout.missing_beneficiary | Falta el nombre del beneficiario. Como el anterior, lo emite el núcleo y lo comparten las cuatro superficies que dispersan. | Envía beneficiary_name, o completa el nombre en el expediente del sub-comercio/empleado. |
400 | payout.missing_concepto | Falta el concepto de la dispersión, que es el que viaja en el SPEI y ve el beneficiario en su estado de cuenta. | Envía un concepto no vacío. |
400 | payout.missing_connector | No se indicó el conector de dispersión (connector_key). Por la API directa no suele verse —se usa stp por omisión—; aparece cuando quien ordena es Connect, nómina o financiamiento sin conector resuelto. | Indica el connector_key de dispersión, o configura el conector por defecto de tu cuenta. |
404 | payout.not_found | El id no existe o es de otro tenant. | Usa el id devuelto al crear el payout. |
Errores de billers / service_payments
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | biller.missing_reference | Falta reference en el inquiry. | Es siempre requerido. |
400 | biller.confirmation_rejected | El prestador del servicio rechazó la confirmación del pago. La referencia y el monto pasaron las validaciones; quien rechaza es el biller. | El mensaje trae su motivo. Verifica la referencia y el monto contra el recibo del cliente antes de reintentar. |
400 | biller.invalid_code | El código del biller llega vacío. | Envía siempre code/biller_code con un valor del catálogo (GET /v1/billers). |
404 | biller.not_found | El code no existe o está inactivo. | Usa un code de GET /v1/billers. |
400 | biller.invalid_reference | reference no cumple el formato del biller. | Revisa el reference_label del biller. |
404 | biller.reference_not_found | Formato válido pero la cuenta no existe para ese biller. | Verifica la referencia con el pagador. |
400 | biller.livemode_unsupported | Consultaste el adeudo con una llave de producción (sk_live_) y el proveedor que atiende a tu cuenta solo simula el adeudo: la cifra que devolvería está fabricada a partir de la referencia, y tú se la cobrarías a tu cliente fuera de Winal. | Gemelo de service_payment.livemode_unsupported, y se rechaza igual: antes de llamar a nadie. Consulta con una llave sk_test_; el pago de servicios REAL se cobra hoy por el camino de recargas (POST /v1/recharges con un producto de monto libre). |
400 | service_payment.missing_fields | Falta biller_code o reference. | Ambos son requeridos. |
400 | idempotency_key_required | Falta el header Idempotency-Key o no es un UUID válido. | Este endpoint lo valida él mismo (mismo mensaje que el resto de la API). |
400 | service_payment.idempotency_key_required | Falta el header Idempotency-Key o no es un UUID válido — guarda interna de BillersService, redundante con la que ya hace el endpoint. | Manda siempre Idempotency-Key con un UUID; en la práctica el endpoint ya te lo exige antes de llegar aquí, así que no deberías verlo. |
409 | service_payment.idempotency_conflict | Misma llave, cuerpo distinto. | Usa una llave nueva para una operación distinta. |
400 | service_payment.amount_mismatch | amount_minor enviado no coincide con el adeudo vigente. | Omite el campo para cobrar el adeudo tal cual, o consulta primero con inquiry. |
404 | service_payment.payment_intent_not_found | El payment_intent_id enviado en POST /v1/service-payments no existe, o no es de tu cuenta. | Usa el id devuelto por POST /v1/payment_intents, del mismo tenant. |
400 | service_payment.payment_intent_not_succeeded | El payment_intent_id que enlazas no está en estado succeeded. | Solo se enlaza un cobro ya liquidado. Espera el webhook payment_intent.succeeded antes de pagar el servicio. |
400 | service_payment.payment_intent_amount_mismatch | El monto del payment_intent enlazado no coincide con el adeudo resuelto del servicio. | El mensaje trae ambas cifras en centavos; consulta el inquiry antes de cobrar para cuadrarlas. |
409 | service_payment.live_payment_conflict | Ya existe un pago vivo (pending o paid) para ese biller_code + reference; se considera un duplicado aunque uses una Idempotency-Key distinta. | Usa una referencia/folio distinto para un recibo nuevo, o espera a que el intento anterior llegue a failed. |
409 | service_payment.retry_conflict | Otra solicitud ya está confirmando este mismo pago de servicio ante el biller en este instante. | Reintenta en unos segundos con la misma Idempotency-Key; no dispares confirmaciones en paralelo sobre el mismo pago. |
400 | service_payment.invalid_period | El período de GET /v1/service-payments?from=&to= no es válido: alguna fecha no es ISO-8601, o from no es anterior a to. | Manda fechas ISO-8601 (2026-08-01T00:00:00Z). El rango es semiabierto: incluye from y excluye to, así que dos meses seguidos nunca comparten una operación. Sin zona horaria se interpretan en UTC. |
400 | service_payment.invalid_cursor | El ?cursor= está corrupto o no lo emitió Winal. | El cursor es opaco: reenvía tal cual el next_cursor de la página anterior y no lo construyas a mano. Se rechaza en vez de devolver la primera página, porque una continuación silenciosa te haría sumar dos veces las mismas operaciones. |
404 | service_payment.not_found | El id no existe o es de otro tenant. | Usa el id devuelto al crear el pago. |
Errores de recharges
Guía completa (incluida la activación de operadoras y el catálogo de productos) en Recargas de tiempo aire. Catálogo de códigos:
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | recharge.invalid_product | Falta el código del producto de recarga. | Usa un product_code de GET /v1/recharge-products. |
400 | recharge.missing_fields | Falta product_code o phone_number en POST /v1/recharges. | Ambos son requeridos. |
400 | recharge.invalid_phone | La referencia del destino (reference, o el histórico phone_number) no es almacenable: trae espacios, caracteres raros o pasa de 64 caracteres. | Manda solo letras, dígitos o @ . _ -, sin espacios. El formato EXACTO que pide cada operadora —cuántos dígitos, si admite cero inicial— lo publica GET /v1/recharge-carriers en reference; incumplirlo devuelve recharge.invalid_reference, que es la validación de verdad. |
400 | recharge.amount_required | El producto es de monto libre (un recibo: luz, agua, telefonía, TV de paga) y no llegó amount_minor, o llegó con cero, negativo o en otra moneda. | Manda amount_minor en centavos con el importe del recibo. Los productos de monto libre se reconocen por open_amount: true en GET /v1/recharge-products. No se pidió nada al agregador. |
400 | recharge.amount_not_allowed | El producto tiene denominación fija (tiempo aire de $50, un paquete) y aun así llegó amount_minor. | Quita amount_minor: el importe sale del catálogo. Se rechaza a propósito en vez de ignorarlo —el agregador SÍ lo ignora en silencio (medido), y eso dejaría al comercio creyendo que cobró un importe distinto del que entregó, sin vuelta atrás. |
400 | recharge.carrier_rejected | El operador rechazó la recarga. El formato era válido: la declinación viene del carrier, no de una validación nuestra. | El mensaje trae el motivo que devolvió el operador. No es reintentable tal cual: corrige lo que indique (número dado de baja, producto no disponible para esa línea) antes de volver a mandarla. |
400 | recharge.invalid_carrier | Falta el código del carrier en POST /v1/recharge-carriers/{code}/enable. | Usa un código del catálogo (GET /v1/recharge-carriers). |
404 | recharge.carrier_not_found | El carrier referido (al activarlo, o el dueño del producto elegido) no existe o está inactivo. | Consulta GET /v1/recharge-carriers para ver los activos. |
400 | recharge.allowance_exhausted | A esta cuenta no le queda saldo asignado para esa recarga. Es distinto de que la bolsa esté vacía: hay dinero en el agregador, pero no es de esta cuenta — y frenarla es justo lo que impide que se lleve el de las demás. El mensaje trae el importe que no cupo. | Quien administra la cuenta le asigna más saldo desde su consola (Recargas → la cuenta → Asignar depósito), acreditándole un depósito nuevo. El saldo de las otras cuentas no se toca. Si la cuenta no debía tener cupo, se apaga desde la misma pantalla y vuelve a consumir la bolsa sin límite. |
400 | recharge.bag_balance_insufficient | El bolsillo de la bolsa contra el que consume ese producto no da para esa recarga. Es el otro lado de recharge.allowance_exhausted: ahí el dinero existe y no es de esa cuenta; aquí el dinero no existe. Winal lo estima con la última lectura del agregador menos todo lo comprometido que el agregador todavía no ha cobrado de esa cifra —entregado, en vuelo y también lo aceptado y todavía no pedido, que es el estado por el que pasa toda recarga porque quien habla con el agregador es el Worker; una venta deja de restar cuando llega una lectura posterior a su desenlace, no cuando llega cualquier lectura nueva— y más los depósitos registrados después; el mensaje trae las cifras y la hora de la lectura. Se compara por bolsillo (tiempo aire, pago de servicios, timbres): el agregador no los mezcla. Se rechaza antes de pedir nada: no hubo cargo. | Deposita saldo en el agregador —y regístralo en Recargas → Depósitos si repartes la bolsa entre varias cuentas—. El propio rechazo pide una relectura del saldo, así que si acabas de fondear basta reintentar en unos segundos con la misma Idempotency-Key. Si la hora de la lectura es muy vieja, revisa que las credenciales de la bolsa sigan siendo válidas: un saldo que nadie puede leer se queda congelado. |
400 | recharge.bag_balance_unknown | En producción, Winal no ha podido leer el saldo de la bolsa de la que consume esa cuenta: o no está dada de alta como bolsa (son credenciales sueltas, cuyo saldo nadie lee) o todavía no hay ninguna lectura de ese bolsillo. Sin cifra no hay techo, y una recarga entregada no se devuelve, así que no se vende. En modo prueba no aplica. | Da de alta la bolsa en Recargas → Bolsas con esas mismas credenciales: se verifican al guardarlas y su saldo aparece en segundos. El propio rechazo vuelve a pedir la lectura, así que reintenta con la misma Idempotency-Key. Si no aparece, las credenciales de la bolsa ya no son válidas. |
400 | recharge.allowance_cap_required | Se intentó apagar el cupo de una cuenta que consume una bolsa que no es suya. No se puede: el cupo es lo que reparte una bolsa entre varias cuentas, y sin él una sola se lleva el saldo de todas las demás. Lo impone la base de datos, no una comprobación de la aplicación. | Si esa cuenta necesita gastar más, acredítale un depósito (Recargas → la cuenta → Asignar depósito): es la única forma que deja rastro de qué dinero respalda ese permiso. Si ya no debe consumir tu bolsa, quítale el vínculo y volverá a usar sus propias credenciales. |
400 | recharge.deposit_invalid_amount | El depósito que se quiere acreditar no es un importe positivo. | Manda en centavos lo que dice el comprobante. |
400 | recharge.deposit_evidence_required | Falta la referencia del comprobante. | Pon el folio de la transferencia o el número de ficha. Es lo que ata ese saldo a un movimiento real, y lo único que impide acreditar el mismo depósito dos veces. |
400 | recharge.deposit_account_not_bound | Esa cuenta no consume de ninguna bolsa tuya (o de ninguna tuya en el agregador que declaraste con provider_key), así que no hay a qué acreditarle saldo. | Apúntala primero a la bolsa desde la que va a vender (Recargas → De qué bolsa consume). Si consume bolsas tuyas en varios agregadores, el mensaje los lista: declara el correcto. |
400 | recharge.provider_key_required | Esa cuenta consume bolsas tuyas en más de un agregador (el mensaje los lista) y no dijiste a cuál va el abono, el cupo o el vínculo que retiras. | Manda provider_key con la clave del agregador (la de connector_key). Qué bolsa recibe el saldo es una decisión tuya, no algo que Winal pueda suponer. |
400 | recharge.deposit_exceeds_bag | Acreditar eso dejaría repartido más saldo del que la bolsa tiene según el agregador. El mensaje trae las tres cifras: lo que hay (con la hora de esa lectura), lo que ya repartiste y sin gastar, y lo que pides. | Si acabas de depositar en el agregador, el propio rechazo pide una relectura de su saldo: reintenta en unos segundos y la cifra ya será la nueva. Si no, acredita un importe menor. Es el control que impide repartir saldo que no existe — sin él, el problema aparecería en el mostrador de una farmacia con un cliente enfrente. |
409 | recharge.provider_account_ref_conflict | Ese número de cuenta en el agregador ya está en otra bolsa viva de tu red (una tuya o la propia de una de tus cuentas administradas). | Una misma cuenta del agregador en dos bolsas haría que un traspaso a una llegara a la otra. Revisa el número en el portal del agregador; si la otra bolsa es la que lo tiene mal, corrígela o archívala primero. Se comparan sin ceros a la izquierda (0336 y 336 son la misma cuenta). |
400 | recharge.invalid_provider_account_ref | El identificador de la cuenta de una bolsa en el agregador no es numérico o pasa de 20 dígitos. | Cópialo del portal del agregador, tal cual. Es el dato que decide a qué cuenta llega el saldo de un traspaso, y un traspaso aplicado no se puede deshacer. |
400 | recharge_transfer.missing_fields | Falta destination_account, amount_minor o pouch_id en POST /v1/recharge-transfers. | Los tres son requeridos. El bolsillo se exige y no se supone: el agregador no mezcla el saldo de sus bolsillos, y un traspaso al equivocado deja el dinero donde no se puede gastar y no se puede deshacer. |
400 | recharge_transfer.idempotency_key_required | Falta el header Idempotency-Key. | Manda un UUID. El agregador no reversa un traspaso aplicado, así que sin llave un reintento lo ordenaría otra vez. |
400 | recharge_transfer.invalid_amount | El importe no es positivo o no es en pesos mexicanos. | Manda amount_minor en centavos, mayor que cero. |
400 | recharge_transfer.invalid_note | La nota no tiene entre 3 y 200 caracteres. | El agregador la exige y la muestra en su portal: escribe para qué es el traspaso («Fondeo semanal farmacia Centro»). Es lo que permite reconocer el movimiento sin cruzar identificadores. |
400 | recharge_transfer.invalid_pouch | pouch_id no es 1 (Tiempo Aire) ni 2 (Pago de Servicios). | Son los dos únicos bolsillos entre los que el agregador documenta el traspaso. Los de timbres y SMS no lo admiten, y averiguarlo costaría una operación irreversible. |
400 | recharge_transfer.livemode_unsupported | Se pidió un traspaso con una llave de producción y el proveedor conectado solo lo simula. | Da de alta las credenciales del agregador, o usa una llave de prueba. Un traspaso simulado dejaría registrado un movimiento que nunca ocurrió: la cuenta destino creyéndose fondeada y el cuadre de la origen acusando un faltante inexistente. |
409 | recharge_transfer.idempotency_conflict | Esa Idempotency-Key ya se usó para un traspaso con otra cuenta destino, otra bolsa origen, otro bolsillo u otro importe. | Usa una llave nueva para un traspaso distinto. Se comparan también el destino y el origen a propósito: sin el destino, reutilizar la llave con otra cuenta devolvería con un 201 el traspaso de otra y te dejaría convencido de haber fondeado a la tuya; sin el origen, te devolvería uno que salió de OTRA bolsa — de dónde sale el dinero decide tanto como a dónde llega. |
404 | recharge_transfer.source_not_found | La bolsa origen no existe, está archivada o no es tuya. También sale si omitiste source_id y tu propia cuenta no consume de ninguna bolsa, así que no hay una por omisión. | Manda source_id con la bolsa desde la que quieres traspasar (la ves en tu consola, en Recargas → Bolsas), o apunta tu cuenta a una. |
400 | recharge_transfer.destination_not_managed | Esa cuenta no consume de ninguna bolsa tuya. Solo se puede traspasar hacia una cuenta que administras. | Apúntala primero a su bolsa desde tu consola (Cuentas de clientes → Recargas). |
404 | recharge_transfer.destination_not_found | La bolsa de esa cuenta no existe o ya está archivada. | Vuelve a apuntarla a una bolsa vigente. |
400 | recharge_transfer.same_bag | Esa cuenta no tiene cuenta propia en el agregador y consume de la misma bolsa desde la que traspasarías: su saldo ya está ahí, no hay nada que mover. | Son los dos modelos de reparto y no se mezclan. Lo que reparte una bolsa compartida es el cupo: acredítale un depósito (Cuentas de clientes → Asignar depósito). Si quieres que tenga su propio saldo, dale su cuenta en el agregador: desde ese momento los traspasos le llegan ahí, aunque todavía venda de tu bolsa (así se fondea antes de cambiarla). |
400 | recharge_transfer.destination_account_ref_missing | La bolsa destino no tiene registrado su identificador de cuenta en el agregador, y sin él no hay a quién mandarle el saldo. | Ponlo en tu consola (Recargas → Bolsas → «Cuenta en el agregador»): es el número que el agregador le asignó, el mismo que aparece en su portal. |
400 | recharge_transfer.insufficient_bag_balance | La última lectura del saldo de ese bolsillo de la bolsa origen no alcanza. El mensaje trae la cifra y la hora en que se leyó. | Deposita saldo al agregador antes de repartirlo. Ojo: esta comprobación mira la última foto del saldo, no el saldo en vivo — el techo firme lo pone el propio agregador, que rechaza por fondos insuficientes sin mover nada. |
400 | recharge_transfer.provider_unavailable | El traspaso se reservó y todavía no se puede pedir: casi siempre porque la bolsa origen no tiene credenciales del agregador capturadas en ese ambiente. No se pidió nada y no se movió un peso; el traspaso queda pending. | Captura las credenciales en tu consola (Recargas → Bolsas) y reintenta con la MISMA Idempotency-Key: ese reintento reanuda el mismo traspaso, no crea otro (una sola fila, un solo folio). Este rechazo es de los que la capa de idempotencia trata como transitorio, así que la llave no queda quemada — si se cacheara, la salida que este mensaje anuncia sería falsa. Mientras nadie reintente, el conciliador cuenta el traspaso como reservado y te avisa. |
400 | recharge_transfer.provider_rejected | El agregador rechazó el traspaso en línea y sin mover nada (fondos insuficientes, cuenta destino desconocida). El mensaje trae su motivo. | Corrige y vuelve a ordenarlo con una llave nueva. Este rechazo sí es definitivo: no dejó ningún movimiento que conciliar. |
404 | recharge_transfer.not_found | Ese traspaso no existe en el ambiente de la llave que pregunta. | Una llave de prueba no ve un traspaso real y al revés. |
400 | recharge.deposit_already_credited | Ese comprobante ya se acreditó en esa bolsa. Se compara sin distinguir mayúsculas ni espacios, así que FICHA-1 y ficha-1 son el mismo. | Si de verdad hubo dos depósitos, usa la referencia propia de cada uno. Acreditar el mismo dos veces repartiría saldo que nunca entró. |
400 | recharge.commission_out_of_range | La comisión está fuera de rango. | Va de 0 a 10 000 puntos base, o sea de 0% a 100%. Es un bono sobre lo depositado, no un cobro. |
400 | recharge.carrier_not_enabled | Tu comercio tiene APAGADA esa operadora. El default es venderla, así que este error solo le ocurre a quien la apagó a propósito. | Enciéndela en el tablero (Recargas → «Operadoras que vendes») o con POST /v1/recharge-carriers/{code}/enable. |
400 | recharge.test_credentials_not_allowed | Se intentó guardar credenciales del agregador en el ambiente de PRUEBA. En pruebas responde el simulador de Winal: no hay credenciales que configurar. | Registra tu bolsa en producción, que es donde vive tu saldo real. |
404 | recharge.binding_not_found | Tu cuenta no consume de ninguna bolsa de saldo, así que no hay nada que cuadrar ni de dónde vender. | Apunta tu cuenta a una bolsa en el tablero (Recargas → «De qué bolsa consume tu cuenta»). |
404 | recharge.product_not_found | El product_code no existe en el catálogo, o está inactivo. | Consulta GET /v1/recharge-products por los productos disponibles para tu tenant. |
400 | recharge.idempotency_key_required | Falta el header Idempotency-Key o no es un UUID válido — guarda interna de RechargeService, redundante con la que ya hace el endpoint. | Manda siempre Idempotency-Key con un UUID. |
409 | recharge.idempotency_conflict | Misma Idempotency-Key, pero producto/número/payment_intent no coinciden con la recarga original. | Usa una llave nueva para una recarga distinta. |
404 | recharge.payment_intent_not_found | El payment_intent_id enviado en POST /v1/recharges no existe, o no es de tu cuenta. | Usa el id devuelto por POST /v1/payment_intents, del mismo tenant. |
400 | recharge.payment_intent_not_succeeded | El payment_intent_id que enlazas no está en estado succeeded. | Solo se enlaza un cobro ya liquidado. Espera payment_intent.succeeded antes de recargar. |
400 | recharge.payment_intent_amount_mismatch | El monto del payment_intent enlazado no coincide con el nominal (face_amount) del producto de recarga. | El mensaje trae ambas cifras en centavos; cuadra el cobro contra el producto elegido. |
400 | recharge.product_not_supported_by_provider | El producto existe en el catálogo de Winal pero no está dado de alta con el agregador que atiende a tu cuenta, así que no se puede entregar. | Elige otro producto, o pide que se sincronice el catálogo del agregador. Se rechaza antes de pedir nada: no hubo cargo. |
400 | recharge.catalog_not_synced | El carrier del producto elegido todavía no tiene sincronizada la regla de validación de su proveedor (longitud, formato, si admite cero inicial), así que no se puede aceptar la referencia con seguridad. | Transitorio: vuelve a intentar en unos minutos con la misma Idempotency-Key (el sync corre cada pocas horas y al arrancar el Worker), o pide que se dispare una sincronización manual. Se rechaza antes de pedir nada —no hubo cargo— y la llave no queda congelada con este error, así que el reintento sí vuelve a ejecutarse. |
400 | recharge.invalid_reference | La referencia (celular) no cumple la regla del carrier —longitud, formato o cero inicial— tal como la declara el propio proveedor. Es MÁS estricta que recharge.invalid_phone: TAECEL declara pero no siempre aplica estas reglas al recibir, así que esta validación es lo único que protege una recarga irreversible. | El mensaje trae el motivo exacto (p. ej. «no puede empezar con cero»). Corrige el dato; no se pidió nada al agregador. |
400 | recharge.provider_unavailable | La recarga se reservó para un agregador que el proceso que intentó ejecutarla no tiene conectado. | Es un problema de despliegue, no tuyo: la recarga sigue reservada y se ejecutará cuando el proceso correcto la tome. No la vuelvas a mandar con otra llave. |
400 | recharge.reference_mismatch | Se intentó ejecutar una recarga reservada con un destino distinto del suyo. | Guarda interna: el destino de una recarga no se puede cambiar después de reservarla. No se pidió nada al agregador. |
404 | recharge.source_not_found | La bolsa de saldo (el juego de credenciales del agregador con su saldo prefondeado) no existe o no es de tu cuenta. | Consulta tus bolsas en GET /app/api/recharge-sources. Las bolsas de otras cuentas no son visibles ni por identificador. |
409 | recharge.source_label_conflict | Ya tienes una bolsa vigente con ese nombre para ese agregador. | Elige otro nombre o edita la que ya existe. Dos bolsas llamadas igual acaban con alguien fondeando la equivocada. |
409 | recharge.source_in_use_conflict | No se puede dar de baja la bolsa: hay cuentas que consumen de ella y se quedarían sin poder recargar. | El mensaje dice cuántas son. Apúntalas primero a otra bolsa (o quítales el vínculo para que usen la suya) y vuelve a intentarlo. |
400 | recharge.source_archived | La bolsa está dada de baja, así que ninguna cuenta puede apuntar a ella. | Apunta la cuenta a una bolsa vigente, o registra una nueva con sus credenciales. |
400 | recharge.source_not_allowed | Una cuenta solo puede consumir de una bolsa suya o de la de quien la administra. Consumir la de un tercero sería gastar su dinero, y una recarga entregada no se devuelve. | Usa una bolsa de la propia cuenta o una tuya, si eres quien la administra. Lo impone la base de datos, no una comprobación de la aplicación. |
400 | recharge.invalid_source_label | El nombre de la bolsa falta o pasa de 120 caracteres. | Ponle un nombre que diga de quién es el saldo («Central», «Zona Norte»): es como la va a reconocer quien la fondee. |
400 | recharge.invalid_credentials | Las credenciales del agregador vienen vacías, con un campo vacío, con demasiados campos o con un valor absurdamente largo. | Manda los pares que pide tu agregador (para TAECEL, taecel_key y taecel_nip). Que además sirvan lo dice el propio agregador: la bolsa queda en pendiente y pasa a lista o rechazada en segundos. |
400 | recharge.invalid_threshold | El umbral de aviso de saldo bajo es negativo. | Usa centavos, cero o positivo. Cero significa «no me avises». |
400 | recharge.provisioning_unavailable | Mandaste mode: "provision" al registrar una bolsa tuya. Dar de alta una cuenta en el agregador crea una cuenta para otro comercio dentro de tu red de distribuidor: sobre tu propia bolsa no aplica (tu cuenta en el agregador es la que ya tienes). | Tu bolsa se registra con las credenciales que ya tienes del agregador (mode: "link", o sin mode). El alta de la cuenta de un cliente vive en su ficha: Cuentas de clientes → la cuenta → Su cuenta en el agregador (ver la cuenta propia en el agregador). |
400 | recharge.distributor_credentials_missing | Ya no se emite (desde la migración 0155). Lo devolvía el alta automática de una bolsa propia, que nunca tuvo con qué ejecutarse. | Su sucesor es recharge_subaccount.distributor_credentials_missing, al pedir la cuenta propia de un cliente. La fila se conserva para que un enlace viejo no caiga en el tope de la página. |
400 | recharge.invalid_period | El período de GET /v1/recharges, /v1/recharges/statement o /v1/recharges/statement.csv no es válido: alguna fecha no es ISO-8601, o from no es anterior a to. | Manda fechas ISO-8601 (2026-08-01T00:00:00Z). El rango es semiabierto: incluye from y excluye to — es lo que hace que enero y febrero no cuenten dos veces la venta del instante del corte. Sin zona horaria se interpretan en UTC. |
400 | recharge.invalid_cursor | El ?cursor= está corrupto o no lo emitió Winal. | El cursor es opaco: reenvía tal cual el next_cursor de la página anterior y no lo construyas a mano. Se rechaza en vez de devolver la primera página, porque una continuación silenciosa te haría sumar dos veces las mismas ventas. |
400 | recharge.pouch_id_not_applicable | Mandaste ?pouch_id= a GET /v1/recharges/statement.csv. Ese parámetro elige la bolsa del agregador cuyo cuadre de saldo se reporta, y el .csv no lleva cuadre: es una línea por movimiento, y un movimiento no guarda de qué bolsa del agregador salió. | Quítalo de la petición del .csv y úsalo en GET /v1/recharges/statement, que sí publica el bloque bag. Se rechaza en vez de ignorarse porque un 200 con TODOS los movimientos te dejaría en la hoja de cálculo un archivo que crees acotado. |
400 | recharge.connector_key_unknown | Mandaste ?connector_key= con un agregador que esta plataforma no conoce, en GET /v1/recharges, /v1/recharges/statement o /v1/recharges/statement.csv. El mensaje lista los conocidos. | Usa la clave tal como aparece en connector_key de tus recargas (taecel, o sim en pruebas). Se rechaza en vez de devolver ceros: un estado de cuenta vacío con 200 te haría creer que ese agregador no vendió nada. Sin el parámetro, el estado de cuenta abarca a todos los agregadores con su desglose en by_connector. |
400 | recharge.deposit_reference_taken | Esa referencia de depósito ya está dada de alta en esa bolsa para otra cuenta (migración 0152). | Una referencia solo puede ser de una cuenta: si fuera de dos, cada depósito que llegara con ella quedaría ambiguo y nadie cobraría su saldo. Archiva la otra antes de reasignarla — archivar no pide segundo factor, es la marcha atrás. (Es un conflicto de dominio y responde 400, no 409: el envelope mapea el estado por el NOMBRE del código, igual que recharge.deposit_already_credited.) |
400 | recharge.deposit_reference_collides | La referencia que se quiere dar de alta y otra vigente de la misma bolsa se pisan: una está contenida en la otra (por ejemplo 7001 y 70012345). El mensaje nombra la otra. | Usa una referencia que no sea parte de la otra, o archiva la que ya no va. No es una precaución vaga: el banco escribe estas referencias con guiones o espacios, y Winal admite esa forma a propósito («7001-2345» es «70012345»), así que un depósito de 70012345 casa con la referencia 7001 y con ninguna más — una sola candidata, ningún aviso de ambigüedad, y con la acreditación automática encendida el cupo sube para el cliente equivocado. |
400 | recharge.deposit_reference_too_short | La referencia tiene menos de 4 letras o dígitos una vez quitados espacios y guiones. | Usa la referencia completa que te dio el agregador. Una más corta casaría con demasiados depósitos, y casar de más es acreditarle a un cliente el dinero de otro. |
400 | recharge.deposit_reference_too_long | La referencia pasa de 64 letras o dígitos. | Manda el código de la ficha, no el concepto entero del depósito. |
400 | recharge.deposit_reference_bag_mismatch | La referencia (o el depósito que se quiere acreditar) es de una bolsa distinta de la que consume esa cuenta. | Si tienes dos bolsas en el mismo agregador, un depósito que cae en una no puede dar saldo gastable en la otra: la cuenta vendería contra dinero que su bolsa no tiene y el de la primera se quedaría sin dueño. Apunta la cuenta a la bolsa en la que deposita, o da de alta la referencia en la bolsa de la que consume. |
404 | recharge.deposit_reference_not_found | No existe esa referencia, o ya estaba archivada. | Recarga la tabla de Recargas → Depósitos de tus clientes: archivar dos veces no es un error que haya que deshacer. |
400 | recharge.balance_mode_invalid | El modo de saldo no es uno de los tres. | Usa global_manual, global_by_reference o per_account. |
400 | recharge.auto_credit_requires_reference_mode | Se intentó encender la acreditación automática en una bolsa que no está en modo «por referencia de depósito». | Cambia primero el modo. Se rechaza en vez de guardarlo callando: un interruptor con un 200 de confirmación que no hace nada es un control que solo existe en la pantalla. |
404 | recharge.attribution_not_found | No existe ese depósito atribuible. | Recarga la bandeja. |
400 | recharge.attribution_not_open | Ese depósito ya se resolvió: se acreditó, se rechazó, o alguien lo confirmó mientras lo mirabas. | Recarga la bandeja. Un depósito acreditado no se rechaza —el cupo ya subió—: se corrige acreditando un ajuste. Es lo que impide que dos personas con la bandeja abierta acrediten el mismo abono dos veces. |
400 | recharge.attribution_without_account | Se intentó acreditar un depósito que no tiene cuenta atribuida (está sin dueño o ambiguo). | Asígnalo a una cuenta desde la bandeja. El sistema no elige a propósito: acreditarle a la equivocada le da saldo que se gasta en recargas que no se devuelven. |
400 | recharge.attribution_amount_unreadable | El agregador reportó el importe de ese abono de una forma que Winal no pudo leer como dinero, y se intentó acreditarlo sin declarar el importe. El mensaje trae el texto crudo tal como lo escribió el agregador. | Asígnalo mandando amount_minor con el importe que dice tu comprobante (en la consola, el formulario de Asignar pide el importe solo para estas filas). No se convierte en cero ni se descarta: un abono real que desaparece de la bandeja es dinero que nadie reclama. |
400 | recharge.attribution_amount_not_editable | Se mandó amount_minor al asignar un depósito cuyo importe el agregador sí reportó de forma legible, y distinto del suyo. | No lo mandes: Winal transcribe el abono del agregador, no lo recalcula. Lo que se elige al asignar es de quién es el depósito, no cuánto entró. El campo existe solo para el abono cuyo importe no se pudo leer. |
400 | recharge.attribution_amount_invalid | El amount_minor declarado al asignar no es un número positivo de centavos. | Manda centavos enteros y positivos: 150000 son $1,500.00. |
400 | recharge.deposit_test_movement | Se intentó acreditar un abono leído en modo prueba. El cupo de recargas no distingue ambiente —no hay columna que lo separe en el vínculo, en el depósito ni en la atribución—, así que acreditarlo daría saldo REAL, gastable en recargas que no se devuelven. | Nada: es el comportamiento correcto. Un depósito de pruebas aparece en la bandeja marcado como tal para que compruebes que el casador reconoce la referencia sin estrenarlo con dinero; el saldo lo sube un depósito real. Si te sobra en la bandeja, recházalo. |
400 | recharge.deposit_bag_balance_unknown | Winal nunca ha leído el saldo de esa bolsa en el agregador (para el ambiente del abono), así que no hay contra qué comprobar que el dinero exista. No es lo mismo que recharge.deposit_exceeds_bag, que sí tiene una lectura y no alcanza. | Da de alta la bolsa con sus credenciales: se verifican al guardarlas y su saldo aparece en segundos. El propio rechazo vuelve a pedir esa lectura, así que reintenta. Si no llega, las credenciales de la bolsa ya no son válidas. Antes esto se dejaba pasar «porque sin cifra no hay techo» — y así es como se repartieron $200,000 de cupo contra una bolsa que solo existía en modo prueba. |
400 | recharge.attribution_reference_required | Se asignó un depósito a una cuenta que no tiene ninguna referencia vigente en esa bolsa. | Da de alta su referencia al asignar (es el mismo formulario) o acredita el depósito por el camino manual, donde la evidencia es el comprobante y no una etiqueta. |
400 | recharge.attribution_account_required | Falta decir a qué cuenta se le asigna el depósito. | Elige el cliente en la bandeja. |
404 | recharge.not_found | El id de la recarga no existe, o es de otro tenant. | Usa el id devuelto al crear la recarga. |
400 | recharge.livemode_unsupported | La API key es sk_live_ (modo producción) y el proveedor que atiende a tu cuenta solo simula la entrega: responder «entregada» con un folio inventado y asentar la contabilidad sería cobrarte un saldo que nunca llega al teléfono. | Da de alta las credenciales del agregador para el ambiente de producción; hasta entonces, integra con una llave sk_test_. Se rechaza antes de reservar nada. |
400 | recharge_routing.product_required | Pediste la vista previa del ruteo (GET /app/api/recharges/routing/preview, tu tablero → Recargas → Agregadores) sin product. | Manda el product_code de Winal cuya decisión quieres ver. La vista previa no reserva nada: puedes pedirla las veces que quieras. |
400 | recharge_routing.invalid_carrier | Fijaste una prioridad de agregadores (PUT /app/api/recharges/routing/preferences) para una operadora que no existe en el catálogo. | Usa un código de GET /v1/recharge-carriers, o deja la operadora vacía para fijar la prioridad de todas. |
400 | recharge_routing.invalid_providers | La lista de prioridad trae un agregador repetido o más de ocho. | Es una lista ordenada de agregadores distintos. Vacía = quitar la prioridad (Winal vuelve a decidir por salud, saldo y costo). |
400 | recharge_routing.provider_not_available | Diste prioridad a un agregador que Winal no conoce o para el que tu cuenta no tiene credenciales dadas de alta en ningún ambiente. Se rechaza AL GUARDAR: una prioridad sobre quien no puede atenderla sería un ajuste que se guarda y jamás surte efecto. | Registra la bolsa de ese agregador en Recargas → Bolsas (o apunta tu cuenta a una) y vuelve a fijar la prioridad. Ver Cómo elige Winal el agregador. |
400 | service_payment.livemode_unsupported | Intentaste pagar un servicio con una llave de producción (sk_live_) y el proveedor que atiende a tu cuenta solo simula la entrega. | Gemelo de recharge.livemode_unsupported: se rechaza ANTES de cobrar o asentar nada, para no cobrarte por un servicio que jamás se pagaría. Úsalo con sk_test_ hasta que se conecte un agregador real. |
400 | service_debt.missing_fields | Faltó product_code o reference al consultar el adeudo de un recibo. | Manda los dos: el producto con el que pagarías ese recibo (el mismo de POST /v1/recharges) y el número de servicio del cliente. |
400 | service_debt.invalid_reference | La referencia del servicio no cumple la regla que declara el prestador (longitud, formato, cero inicial), o trae caracteres que no se pueden almacenar. | El mensaje trae el motivo exacto. La regla vigente de cada prestador viaja en reference dentro de GET /v1/recharge-carriers: úsala para validar en tu formulario. No se consultó nada. |
400 | service_debt.inquiry_not_supported | Ese prestador no publica saldos: el catálogo del proveedor lo declara así (supports_balance_inquiry: false). Hoy lo admiten 8 de las 172 operadoras, CFE entre ellas. | No es un fallo transitorio y no se preguntó nada. Cobra igual con POST /v1/recharges usando el importe del recibo que trae el cliente, y ofrece el botón de «consultar» solo donde supports_balance_inquiry sea true. |
400 | service_debt.catalog_not_synced | Ese prestador todavía no se ha sincronizado con su proveedor, así que aún no se sabe si admite la consulta ni con qué formato de referencia. | Transitorio, y distinto de inquiry_not_supported: aquí la respuesta es «todavía no lo hemos preguntado». Reintenta en unos minutos o pide una sincronización del catálogo. |
400 | service_debt.livemode_unsupported | Consultaste con una llave sk_live_ y el proveedor que atiende a tu cuenta solo simula la respuesta. | Un adeudo inventado en un mostrador se le cobra al cliente como si fuera su recibo, así que se rechaza antes de consultar nada. Da de alta las credenciales del agregador para producción; hasta entonces, integra con sk_test_. |
400 | service_debt.carrier_not_enabled | Tu comercio tiene apagado ese prestador en «Operadoras que vendes». | Enciéndelo desde la consola si quieres consultar y cobrar sus recibos. |
400 | service_debt.product_not_supported_by_provider | El producto existe en Winal pero no está dado de alta con el agregador que atiende a tu cuenta, así que no hay a quién preguntarle. | Elige otro producto o pide una sincronización del catálogo del proveedor. |
400 | service_debt.provider_rejected | El prestador rechazó la consulta: normalmente una referencia que no reconoce. Llega dentro de la consulta (status: "failed"), no como error HTTP: la consulta se hizo y ésta fue su respuesta. | failure_reason trae el motivo redactado para leérselo al cliente. Verifica el número de servicio con él. |
400 | service_debt.provider_unreachable | El prestador no respondió a tiempo. También llega dentro de la consulta (status: "failed"). | Aquí un timeout SÍ es un fallo, al revés que en una recarga: una consulta no deja nada en vuelo, así que reintentarla es seguro y no duplica nada. Si el cliente trae su recibo, puedes cobrarlo igual con el importe impreso. |
404 | service_debt.product_not_found | El product_code no existe en el catálogo o ya no está activo. | Toma el código de GET /v1/recharge-products. |
404 | service_debt.carrier_not_found | El prestador del producto ya no está activo en el catálogo. | Elige otro producto; el catálogo nunca borra filas, así que un prestador inactivo sigue apareciendo en tus consultas históricas. |
404 | service_debt.not_found | Esa consulta de adeudo no existe en el ambiente de tu llave. | Una consulta hecha con sk_test_ no se lee con sk_live_ ni al revés: es el mismo criterio con el que se separan las recargas de prueba de las reales. |
La cuenta propia de un cliente en el agregador (recharge_subaccount)
Los devuelve la ficha de cada cliente en tu consola (Cuentas de clientes → la cuenta → Su cuenta en el
agregador, rutas /app/api/accounts/{id}/recharge-subaccount*) y la lectura por
GET /v1/recharge-subaccounts. Guía completa en
Recargas → La cuenta propia de cada farmacia.
Un alta sin desenlace NO es un error y no tiene código: el agregador no contestó y la cuenta
pudo crearse (regla 5). Queda en status: "indeterminate" con sus dos salidas —registrar
las credenciales que le lleguen al titular por correo, o reintentar con los mismos datos— y nunca
se pide otra sola. Los tres códigos marcados como desenlace no son respuestas HTTP: llegan en
failure_code de la cuenta cuando el agregador rechazó el alta sin crear nada.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | recharge_subaccount.provider_unsupported | Winal no puede dar de alta cuentas en ese agregador desde este despliegue (su conector no ofrece el alta). | Si tu cliente ya tiene su propia cuenta en ese agregador, registra sus credenciales en su ficha: el resultado es el mismo. Registrar a mano no exige que el agregador ofrezca el alta. |
400 | recharge_subaccount.distributor_credentials_missing | Para crear la cuenta de tu cliente hacen falta tus credenciales de distribuidor de producción: la cuenta nueva nace dentro de tu red. No hay ninguna alcanzable. También llega como desenlace (failure_code) si tus credenciales desaparecieron entre pedir el alta y ejecutarla — en ese caso no se llamó al agregador. | Registra tu bolsa de producción en Recargas → Bolsas y vuelve a pedir el alta. |
400 | recharge_subaccount.invalid_name | Falta el nombre o los apellidos del titular, o alguno es demasiado largo (100 caracteres; nombre comercial, 150). | Corrige y vuelve a pedirla. No se llamó a nadie. |
400 | recharge_subaccount.invalid_email | El correo del titular no tiene forma de correo (o pasa de 100 caracteres). | Es a donde el agregador le manda sus credenciales, y es también la salida si el alta queda sin desenlace: un correo mal escrito las pierde. |
400 | recharge_subaccount.invalid_phone | El teléfono no es de 10 dígitos (se admite +52 delante, espacios y guiones). | En el agregador el teléfono es el número de la cuenta y no se puede repetir. |
400 | recharge_subaccount.invalid_provider_account | El número de cuenta en el agregador no es numérico o pasa de 20 dígitos. | Cópialo tal cual de su portal (su cuentaID). Es la cuenta a la que llega un traspaso de saldo, y un traspaso no se deshace. |
409 | recharge_subaccount.exists_conflict | Tu cliente ya tiene su cuenta propia en ese agregador (o el agregador ya dijo que la creó y faltan sus credenciales). | No hace falta —ni se puede— crear otra. Si perdió sus credenciales, regístralas de nuevo: se reemplazan. |
409 | recharge_subaccount.in_progress_conflict | Intentaste registrar credenciales a mano mientras el alta está en curso (el agregador todavía no contesta). | Espera unos segundos a su desenlace: si el agregador contesta, Winal guarda las credenciales solo. |
409 | recharge_subaccount.state_conflict | La cuenta no está en un estado que admita lo que pediste: reintentar un alta que no quedó sin desenlace, pedir otra mientras la anterior quedó sin desenlace, dar por no creada una cuenta que sí existe (o que está en curso), fijar el número de cuenta o venderle de una cuenta que todavía no está dada de alta, o un cambio de estado entre leer y escribir. | Recarga la ficha: el mensaje dice cuál es la salida del estado en que está. Si el alta anterior quedó sin desenlace, no pidas otra: registra las credenciales que le llegaron al titular, reintenta con los mismos datos, o —si el agregador te confirma que no se creó ninguna cuenta— dala por no creada. |
409 | recharge_subaccount.phone_mismatch_conflict | Pediste la cuenta (o registraste credenciales) con un teléfono distinto del de un alta que pudo haber creado ya su cuenta. Mientras cualquier intento de la cadena pudo crearla, la base no deja pedirla con otro teléfono. | Registra las credenciales de esa cuenta (las que le llegaron al titular por correo, o pídeselas al agregador para ese teléfono). Pedirla con otro teléfono le daría a la farmacia dos cuentas. Si el agregador te confirma que no se creó ninguna, dala por no creada desde su ficha: es la única forma de liberar el teléfono. |
409 | recharge_subaccount.provider_account_mismatch_conflict | Registraste credenciales con un número de cuenta distinto del que el agregador ya dijo haber creado para esta farmacia. | Registra las de la cuenta que dio el agregador (las que le llegaron al titular), o deja el número en blanco para conservar el suyo. Unas credenciales de otra cuenta dejarían el teléfono de una y el número de otra — y un traspaso a la que no es. |
409 | recharge_subaccount.provider_account_conflict | Ese número de cuenta en el agregador ya está en otra bolsa viva de tu red (otra de tus cuentas, o una bolsa tuya). | Un traspaso a esta farmacia llegaría a la otra, y un traspaso no se deshace. Revisa el número en el portal del agregador; si la otra bolsa lo tiene mal, corrígela o archívala primero. Si el choque vino del número que devolvió el agregador al crear la cuenta, la cuenta queda dada de alta sin número hasta que lo resuelvas. |
429 | recharge_subaccount.rate_limited | Llegaste al tope de 30 altas de cuenta por hora (incluye reintentos). Cada alta crea una cuenta en un tercero y le manda un correo a una persona. | Vuelve a intentarlo en un rato: la ventana es la última hora, así que se libera sola. Registrar credenciales a mano no cuenta para el tope. |
404 | recharge_subaccount.not_found | Esa cuenta no tiene una cuenta propia en ese agregador. | Pide su alta, o registra las credenciales de la que ya tenga. |
400 | recharge_subaccount.phone_taken | Desenlace (failure_code): el agregador no creó la cuenta porque ese teléfono ya está registrado en su red. | En el primer intento, no se creó nada: si es la cuenta de esta farmacia, registra sus credenciales; si el teléfono estaba mal, pide otra. En un reintento de un alta sin desenlace la cuenta queda sin desenlace (casi seguro la creó el primer intento): registra las credenciales que le llegaron al titular, y solo si el agregador confirma que esa cuenta NO es de esta farmacia, dala por no creada. |
400 | recharge_subaccount.no_default_commission | Desenlace: el agregador no creó la cuenta porque tu cuenta de distribuidor no tiene una comisión por defecto asignada. | Pídesela al agregador (es de su lado) y vuelve a pedir el alta. |
400 | recharge_subaccount.provider_rejected | Desenlace: el agregador rechazó el alta en explícito y sin crear nada (el mensaje trae su motivo), o los datos guardados del titular no se pudieron leer y no se llamó a nadie. Solo los códigos que el agregador usa para rechazar antes de procesar son firmes; su error genérico o un código desconocido dejan el alta sin desenlace, diga lo que diga su texto. | En el primer intento, corrige lo que diga el motivo y vuelve a pedirla. En un reintento, la cuenta sigue sin desenlace (el primero pudo crearla): corrige el motivo y reintenta, o registra sus credenciales. |
400 | recharge_subaccount.cancelled | Desenlace: se canceló un alta que seguía en cola sin que nadie hubiera llamado al agregador. No se creó nada. | Pídela de nuevo con los datos correctos, o registra las credenciales de la cuenta que ya tuviera. |
400 | recharge_subaccount.confirmed_not_created | Desenlace: el dueño de la cuenta del integrador afirmó, con evidencia del agregador, que un alta sin desenlace NO creó ninguna cuenta. Es lo único que libera la cadena para pedirla de nuevo con otro teléfono, y queda en la bitácora quién lo afirmó. | Pide la cuenta de nuevo con el teléfono correcto. Si resulta que la cuenta sí existía, la farmacia tendrá dos: por eso se afirma solo tras comprobarlo en el portal o con el soporte del agregador. |
Depósitos: enlace de reporte y saldo al momento
Los devuelven GET/POST /v1/recharge-report-links y POST /v1/recharge-balance/refresh
— contrato completo en Referencia de API → Depósitos.
Que el agregador no conteste NO es un error y no tiene código: la lectura queda con
refresh.status: "unreachable" y lo último que se sabía (la foto del saldo, o el enlace) sigue
valiendo, con su hora. Tampoco lo es el freno: dentro de él la respuesta es 200 con la
última lectura y refresh.next_available_at — 10 minutos por bolsa para el saldo; para
el enlace, 24 horas mientras la última lectura EXITOSA siga fresca, o 5 minutos desde el último
intento (para poder reintentar tras un fallo sin esperar el día completo).
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | recharge.balance_refresh_unavailable | Pediste el saldo al momento y la cuenta no vende de ninguna bolsa que se pueda leer: la llave es de prueba (responde el simulador y no hay saldo real), la cuenta usa credenciales sueltas que no son una bolsa, o la bolsa no tiene credenciales de producción. No se le preguntó nada al agregador. | Con tu llave sk_live_, registra la bolsa en Recargas → Bolsas (o apunta la cuenta a la de quien la administra). Mientras tanto GET /v1/recharges/balance dice lo que se sabe. |
400 | recharge.report_link_unavailable | Pediste el enlace de reporte de depósitos y la cuenta no tiene ninguna bolsa propia en un agregador que publique ese formulario. Una farmacia que vende de la bolsa de su integrador no tiene formulario propio: sus depósitos se reportan en la cuenta de él. | Si la farmacia debe tener su propia cuenta en el agregador, dala de alta desde tu consola (Cuentas de clientes → Su cuenta en el agregador). Si vende de tu bolsa, pide tu enlace con tu llave sola. |
400 | recharge.report_link_requires_live_key | Pediste volver a leer el enlace (POST /v1/recharge-report-links/refresh) con una llave de prueba. El formulario es el del depósito REAL en el agregador y se lee con las credenciales de PRODUCCIÓN de la bolsa: pedirlo con sk_test_ llamaría al agregador con material real, contra la regla de la casa. No se tocó la tabla ni se encoló nada. | Pídelo con tu llave sk_live_. Mientras tanto, GET /v1/recharge-report-links sigue sirviendo el último enlace guardado con cualquier llave. |
404 | recharge.report_link_source_not_found | El source_id no es una bolsa viva de la cuenta efectiva (es de otra cuenta, está dada de baja, o su agregador no publica formulario de reporte). | Toma el source_id de GET /v1/recharge-report-links con la MISMA credencial, u omítelo para pedir todas las bolsas propias. El enlace de una bolsa solo lo pide su dueño. |
Errores de billing (comisión propia de Winal)
La comisión que Winal te cobra por usar la plataforma — devengo y facturación de tu estado de
cuenta mensual, no la de tus propios clientes. Ver /v1/billing/*.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | billing.usage_invalid_concept | El concepto de un hecho medido no cumple el formato: minúsculas, dígitos y _, empezando por letra, de 2 a 32 caracteres. | Es un error interno de medición, no de tu integración: el concepto lo pone la plataforma (charge, cfdi…). Repórtalo si lo ves. |
400 | billing.usage_negative_amount | El monto de un hecho facturable llegó negativo. | Igual que el anterior: lo mide la plataforma, no tú. Repórtalo si lo ves. |
400 | billing.usage_missing_source | Se intentó medir un consumo sin el id del hecho de origen, que es la llave de idempotencia (regla 8). | Error interno de medición. Sin esa llave no se puede garantizar que un reintento no cobre dos veces, así que se rechaza en vez de arriesgar un doble cobro. |
400 | billing.catalog_currency_mismatch | Tu catálogo de precios efectivo tiene filas en monedas distintas, así que no existe un total único para el período (GET /v1/billing/prices, /usage, /accrue). | No es de tu integración: lo corrige la plataforma dejando todo tu catálogo en una sola moneda. Contáctanos. |
400 | billing.statement_currency_mismatch | El devengo del período encontró una moneda distinta a la del estado de cuenta ya abierto. | Igual que el anterior: lo resuelve la plataforma. Tu consumo medido no se pierde. |
400 | billing.price_mode_unknown | Una fila de tu catálogo declara una modalidad de cobro que este despliegue no sabe evaluar (free, per_unit, percentage, flat_plus_percentage, monthly_included). | No es de tu integración: es un precio mal capturado o un despliegue viejo leyendo una modalidad nueva. Contáctanos. |
400 | billing.price_invalid_concept | El concept de un precio no cumple el formato (minúsculas, dígitos y _, de 2 a 32 caracteres). | Solo lo ve la plataforma al capturar precios: los precios se escriben desde /admin, nunca con tu llave. |
400 | billing.price_invalid_method | El method de un precio no es * ni una clave con formato válido. | Solo lo ve la plataforma al capturar precios. |
400 | billing.price_missing_label | Un precio se capturó sin label, que es el nombre con que el concepto sale en tu estado de cuenta y en el CFDI. | Solo lo ve la plataforma al capturar precios. |
400 | billing.price_currency_mismatch | Los montos de un mismo precio (unitario, tope, renta, excedente, mínimo) no comparten moneda. | Solo lo ve la plataforma al capturar precios: un precio es de UNA moneda. |
400 | billing.price_negative_amount | Algún monto de un precio es negativo. | Solo lo ve la plataforma al capturar precios: usa cero para «sin tope»/«sin piso». |
400 | billing.price_invalid_bps | La fracción de un precio está fuera de rango: debe ser un entero entre 0 y 10000 puntos base (0%–100%). | Solo lo ve la plataforma al capturar precios; 100 puntos base = 1%. |
400 | billing.price_invalid_included_units | Las unidades incluidas de una renta mensual son negativas. | Solo lo ve la plataforma al capturar precios. |
400 | billing.price_invalid_currency | La currency de un precio no es un código ISO 4217 válido. | Solo lo ve la plataforma al capturar precios: usa MXN u omite el campo. |
400 | billing.price_missing_body | El PUT del catálogo llegó sin prices, o con la lista vacía. | Solo lo ve la plataforma al capturar precios. |
400 | billing.receivable_already_issued | El período ya tiene una cuenta por cobrar emitida; emitir otra sería cobrar dos veces el mismo estado de cuenta. | Guarda de negocio (el vínculo es de una sola vez). Usa la cuenta por cobrar que ya existe. Responde 400, no 409: el estado HTTP se deriva del texto del código. |
400 | billing.receivable_missing_customer | Falta customer_name o customer_email al emitir el estado de cuenta como cuenta por cobrar. | Solo lo ve la plataforma: es a quién se le manda el link de pago y los recordatorios. |
400 | billing.nothing_to_collect | Se pidió emitir la cuenta por cobrar de un período cuyo total es cero. | Guarda, no fallo: sin importe no hay nada que cobrar. |
400 | billing.platform_tenant_not_configured | Cobrar el estado de cuenta exige el tenant de plataforma (Billing:PlatformTenantId), que no está configurado. | Trámite de la plataforma, no tuyo: el link de pago debe vivir en la cuenta de Winal, jamás en la del comercio al que se le cobra (si no, se cobraría a sí mismo). |
400 | billing.invalid_period | Falta ?period= (en /v1/billing/usage y /usage.csv) o el {period} de la ruta no tiene el formato YYYY-MM (en /statements/{period}, /accrue, /invoice). | Usa el formato exacto YYYY-MM (p. ej. 2026-08). |
404 | billing.statement_not_found | El período de GET /v1/billing/statements/{period} o POST /v1/billing/statements/{period}/invoice todavía no tiene estado de cuenta devengado. | Devenga primero el período con POST /v1/billing/statements/{period}/accrue (solo aplica a un mes ya cerrado) y después consúltalo o factúralo. |
400 | billing.period_not_closed | Pediste devengar (POST /v1/billing/statements/{period}/accrue) un período que sigue abierto: el mes en curso o uno futuro. | Guarda de negocio: espera a que el mes cierre. Devengar un período abierto dejaría sin facturar los cobros posteriores del mismo mes. |
400 | billing.child_account_not_individually_billable | Esta cuenta es ADMINISTRADA por otra (cuentas vinculadas): su consumo se cobra en el estado de cuenta AGRUPADO de quien la administra, nunca en el suyo propio — no tiene con qué pagarlo por su cuenta. | Si eres el manager de esta cuenta, devenga el COBRO AGRUPADO con la cuenta MATRIZ, no con esta. |
400 | billing.statement_is_group | Este período ya se devengó de forma AGRUPADA (incluye el consumo de cuentas administradas) y estás pidiendo el devengo individual sobre él. | Usa el devengo AGRUPADO para este período: el individual reescribiría el estado de cuenta con solo tu consumo directo y borraría en silencio el de tus cuentas administradas. |
400 | billing.missing_receptor | Falta rfc, nombre, regimen_fiscal o codigo_postal del receptor en POST /v1/billing/statements/{period}/invoice. | Los cuatro campos del receptor son obligatorios para timbrar el CFDI de la comisión de Winal. |
400 | billing.nothing_to_invoice | Pediste timbrar el CFDI de la comisión (POST /v1/billing/statements/{period}/invoice) de un período cuyo fee total es cero. | Es una guarda, no un fallo: sin fee que cobrar no hay nada que facturar. Revisa GET /v1/billing/usage?period= si esperabas actividad medida ese mes. |
400 | billing.invoicer_not_configured | Pediste timbrar el CFDI de la comisión (POST /v1/billing/statements/{period}/invoice) pero la cuenta de PAC de Winal (el EMISOR, no la tuya) todavía no está configurada. | No es un error de tu integración: el timbrado de la comisión de Winal está gated por un trámite del dueño de la plataforma (cuenta Facturama de Winal). Tu estado de cuenta sigue devengado; reintenta más adelante. |
Errores de financing
El "financiamiento como riel de datos": Winal es el riel y el originador, JAMÁS el prestamista.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | financing.invalid_currency | currency en POST /v1/financing/profile no es un código ISO 4217 parseable. | Usa un código válido; si lo omites, se usa MXN. |
400 | financing.missing_consent_actor | El opt-in (POST /v1/financing/profile con consent: true) llegó sin identificar QUIÉN lo otorga. El consentimiento es la frontera de privacidad del financiamiento: se guarda con actor y fecha para poder probar quién autorizó compartir tus agregados verificados. | Manda consent_actor con el correo o identificador de la persona que autoriza. |
404 | financing.profile_not_found | Pediste el perfil (GET /v1/financing/profile) o intentaste revocar el consentimiento (POST /v1/financing/profile con consent: false) antes de hacer opt-in. | Haz opt-in primero con POST /v1/financing/profile. |
400 | financing.consent_required | Pediste el Winal Score (GET /v1/financing/score) o solicitaste ofertas (POST /v1/financing/offers) sin consentimiento activo. Es la frontera de privacidad: sin tu opt-in, Winal no calcula el score ni comparte tus agregados verificados con ningún prestamista. | Haz opt-in con POST /v1/financing/profile antes de pedir el score o las ofertas. |
404 | financing.offer_not_found | El id de GET /v1/financing/offers/{id}, o el offer_id de POST /v1/financing/advances, no existe. | Usa un id de GET /v1/financing/offers. |
400 | financing.invalid_offer_id | Falta offer_id (o no es un uuid) en POST /v1/financing/advances. | Usa el id de una oferta obtenida con POST /v1/financing/offers. |
400 | financing.offer_not_acceptable | La oferta de POST /v1/financing/advances ya no está offered (venció o ya fue aceptada) — o dos solicitudes la aceptaron a la vez y perdiste la carrera. | Pide ofertas nuevas con POST /v1/financing/offers; una oferta se acepta UNA sola vez. |
404 | financing.advance_not_found | El id de GET /v1/financing/advances/{id} (o .../repayments) no existe. | Usa un id de GET /v1/financing/advances. |
Errores de analytics (benchmarking y pronóstico de flujo)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | analytics.invalid_category | category de PUT /v1/analytics/category no es uno de los giros reconocidos. | Usa uno de: general, retail, restaurante, servicios, ecommerce, salud, educacion, otro. |
400 | analytics.invalid_month | ?month= de GET /v1/analytics/benchmarks no tiene el formato yyyy-MM. | Usa el formato exacto yyyy-MM (p. ej. 2026-08), u omítelo para comparar el mes en curso. |
400 | analytics.invalid_period | ?from= y/o ?to= de GET /v1/analytics/cash-forecast no son fechas ISO-8601 válidas (yyyy-MM-dd), o no cumplen from <= to con un horizonte de a lo sumo 180 días. | Usa fechas yyyy-MM-dd con from anterior o igual a to; omite ambos para los próximos 30 días por defecto. |
400 | analytics.invalid_currency | ?currency= de /v1/analytics/benchmarks o /cash-forecast no es un código ISO 4217 válido. | Usa MXN, u omite el parámetro (default MXN). |
Errores de nómina: catálogo de empleados (/v1/payroll/employees)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payroll.invalid_currency | La moneda no es un código ISO 4217 parseable — en POST /v1/payroll/employees/PUT .../{id} (campo currency) o al declarar una línea de una corrida en POST /v1/payroll/runs. | Usa MXN, o simplemente omite el campo (default MXN). |
400 | payroll.missing_name | Falta full_name al dar de alta o actualizar un empleado. | Es siempre requerido: es el nombre que recibirá el CFDI de nómina del empleado. |
400 | payroll.missing_rfc | Falta rfc del empleado. | Es siempre requerido; se cifra en reposo, igual que la CURP, la CLABE y el NSS. |
400 | payroll.missing_curp | Falta curp del empleado. | Es siempre requerida para el complemento de Nómina 1.2. |
400 | payroll.invalid_clabe | La CLABE de depósito del salario no son 18 dígitos, o el dígito de control es incorrecto. | Verifica la CLABE con el algoritmo Banxico 3-7-1 antes de enviarla: es a donde SPEI dispersa el neto de cada corrida cuando la ejecutas. |
400 | payroll.missing_cp | Falta codigo_postal del domicilio fiscal del empleado. | Es el DomicilioFiscalReceptor que exige el CFDI de nómina; siempre requerido. |
400 | payroll.missing_regimen | Falta regimen_fiscal del empleado. | Omite el campo para el default (605, Sueldos y Salarios), o manda el régimen correcto del receptor. |
400 | payroll.unsupported_currency | Moneda ISO 4217 válida pero distinta de MXN — en el salario de un empleado, o en las percepciones/deducciones/neto de una línea de corrida. | La nómina fase 0 solo opera en pesos; usa MXN. |
400 | payroll.invalid_salary | salario_base_cot_apor_minor o salario_diario_minor es negativo. | Ambos deben ser cero o positivos, en centavos. |
404 | payroll.employee_not_found | El id del empleado no existe — en GET/PUT /v1/payroll/employees/{id}, o referido por employee_id al crear una corrida. | Usa el id devuelto al dar de alta al empleado. |
Errores de nómina: corridas y dispersión (/v1/payroll/runs)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | payroll.empty_run | lines viene vacío o ausente en POST /v1/payroll/runs. | Incluye al menos un empleado por corrida. |
400 | payroll.missing_dates | Falta fecha_pago, fecha_inicial_pago o fecha_final_pago. | Las tres son siempre requeridas al crear una corrida. |
400 | payroll.invalid_period | period no tiene el formato YYYY-MM. | Usa el formato exacto (p. ej. 2026-08). |
400 | payroll.invalid_tipo_nomina | tipo_nomina no es O (ordinaria) ni E (extraordinaria). | Usa uno de esos dos valores, o simplemente omite el campo (default O). |
400 | payroll.invalid_dias_pagados | dias_pagados es cero o negativo. | Manda un entero positivo. |
400 | payroll.invalid_dates | fecha_final_pago es anterior a fecha_inicial_pago. | Corrige el rango del período pagado. |
400 | payroll.duplicate_employee | El mismo employee_id aparece más de una vez dentro de lines. | Una corrida trae a lo más un recibo por empleado; si necesitas corregir un monto, ajusta esa línea antes de enviar, no agregues una segunda. |
400 | payroll.employee_inactive | Un employee_id de lines corresponde a un empleado dado de baja (status: "inactive"). | Solo empleados activos entran a una corrida; reactívalo con PUT /v1/payroll/employees/{id} si sigue en nómina. |
400 | payroll.missing_perceptions | La línea de un empleado no trae ninguna percepción. | perceptions exige al menos un elemento por empleado. |
400 | payroll.invalid_perception | Una percepción trae gravado_minor o exento_minor negativo. | Ambos deben ser cero o positivos. |
400 | payroll.invalid_deduction | Una deducción trae importe_minor negativo. | Debe ser cero o positivo. |
400 | payroll.line_does_not_balance | El cuadre exacto al centavo falló: Σ percepciones − Σ deducciones no es igual al neto_minor declarado para ese empleado. | Es una garantía deliberada, no una molestia: Winal no calcula ISR ni IMSS por ti, así que valida al centavo lo que tú provees y rechaza cualquier recibo que no cuadre — nunca dispersa (ni timbra) un neto que no corresponde exacto a sus propias percepciones y deducciones. El mensaje trae las tres cifras en centavos; ajusta tu cálculo hasta que sumen exacto. |
400 | payroll.non_positive_net | El neto_minor declarado (ya cuadrado contra percepciones y deducciones) es cero o negativo. | No hay nada que dispersar por SPEI si el neto no es positivo; revisa las deducciones de esa línea — probablemente exceden a las percepciones. |
404 | payroll.run_not_found | El id de la corrida no existe — en GET, POST .../execute o GET .../payments. | Usa el id devuelto al crear la corrida con POST /v1/payroll/runs. |
Errores de audit (bitácora de auditoría)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | audit.invalid_from | ?from= de GET /v1/audit o /v1/audit.csv no es una fecha ISO-8601 válida. | Usa una fecha/hora ISO-8601 (p. ej. 2026-08-01 o 2026-08-01T00:00:00Z). |
400 | audit.invalid_to | ?to= de GET /v1/audit o /v1/audit.csv no es una fecha ISO-8601 válida. | Usa una fecha/hora ISO-8601 (p. ej. 2026-08-01 o 2026-08-01T00:00:00Z). |
Errores de cumplimiento: aviso de privacidad y mandatos (/v1/compliance/privacy-notices, /v1/compliance/mandates)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | compliance.invalid_notice | Falta title o body en POST /v1/compliance/privacy-notices. | Ambos son siempre requeridos al publicar una versión nueva del aviso de privacidad. |
404 | compliance.notice_not_found | No hay ningún aviso de privacidad vigente publicado para tu cuenta. | Publica uno con POST /v1/compliance/privacy-notices antes de consultar GET .../current. |
400 | compliance.invalid_frequency | Falta frequency, o no es on_file/weekly/monthly/yearly, al registrar un mandato. | Usa uno de esos cuatro valores. |
400 | compliance.invalid_currency | La currency del mandato no es un código ISO 4217 válido. | Usa MXN, u omite el campo (default MXN). |
400 | compliance.invalid_mandate | El mandato no viene ligado a ninguna suscripción ni método guardado, falta text_version, o amount_cap_minor es negativo. | El mensaje trae cuál de las tres reglas falló. Un mandato siempre necesita al menos una referencia (subscription_id o payment_method_id) y la versión del texto que aceptó el pagador. |
400 | compliance.unknown_subscription | El subscription_id del mandato no existe, o no pertenece a tu cuenta. | Guarda anti-forja: un mandato solo puede autorizar cobros sobre tus PROPIAS suscripciones. Usa el id real devuelto por POST /v1/subscriptions. |
400 | compliance.unknown_saved_payment_method | El payment_method_id del mandato no existe, o no pertenece a tu cuenta. | Mismo guardia que con las suscripciones, ahora para métodos guardados: usa el id real de un método de pago de tu cuenta. |
404 | compliance.mandate_not_found | El id del mandato no existe, o ya no está vigente (revocado) — en GET/POST .../revoke. | Usa el id devuelto al registrar el mandato; uno ya revocado no vuelve a aparecer aquí. |
Errores de cumplimiento: aceptaciones, autorización de dispersión y ARCO/DSR (/v1/compliance/agreements, /v1/compliance/dsr)
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | compliance.invalid_agreement | Falta document_version, document_hash o actor; o, con agreement_type: "dispersal_authorization", falta connect_application_id. | Los tres primeros son siempre requeridos; una autorización de dispersión además exige el sub-comercio Connect al que se liga. |
400 | compliance.invalid_agreement_type | Falta agreement_type, o no es merchant_agreement/dispersal_authorization. | Usa uno de esos dos valores en POST /v1/compliance/agreements. |
400 | compliance.invalid_dispersal_clabe | dispersal_clabe ausente o inválida al aceptar una autorización de dispersión. | Manda una CLABE de 18 dígitos con dígito de control correcto: es la CLABE exacta a la que autorizas dispersar; solo se guardan sus últimos 4 dígitos como evidencia. |
400 | compliance.dispersal_not_authorized | POST /v1/connect/accounts intentó ligar la CLABE de liquidación ya aprobada de un sub-comercio, pero no existe una autorización de dispersión (POST /v1/compliance/agreements con agreement_type: "dispersal_authorization") sellada para ESA CLABE exacta. | Es el gate del sin custodia (ADR-0001), no un bug: Winal jamás dispersa a una CLABE que nadie autorizó explícitamente. Acepta primero la autorización de dispersión con la CLABE de liquidación del sub-comercio, y solo entonces crea la cuenta Connect. |
400 | compliance.invalid_dsr | Falta request_type, o no es access/rectification/cancellation/opposition, al abrir una solicitud ARCO. | Usa uno de esos cuatro valores. |
400 | compliance.invalid_subject | Al abrir una solicitud ARCO no se mandó ni email ni rfc del titular. | Al menos uno de los dos es requerido para identificar a quién ejerce el derecho. |
400 | compliance.identifiers_unlinked | Abriste la solicitud con email Y rfc a la vez, pero no hay ningún consentimiento o mandato previo capturado con ese MISMO par que demuestre que pertenecen a la misma persona. | Protección anti-fusión de identidades: sin esa evidencia, exportar/anonimizar por ambos identificadores a la vez mezclaría los datos de dos personas distintas. Abre dos solicitudes ARCO separadas, una por cada identificador. |
404 | compliance.dsr_not_found | El id de la solicitud ARCO no existe. | Usa el id devuelto al abrirla con POST /v1/compliance/dsr. |
400 | compliance.subject_mismatch | El email/rfc que mandaste en POST .../export o .../anonymize no coincide con el titular con el que se abrió la solicitud. | Protección contra exportar o anonimizar los datos de una persona distinta a la que abrió el derecho: re-identifica al titular con EXACTAMENTE el mismo email/rfc que usaste al abrirla. |
payment_intent.no_route es lo que verás al confirmar payment_method: "bnpl"
hoy — el conector Sim no lo implementa (ver Métodos de pago → BNPL).
La administración de terminales (terminal.not_found, terminal.invalid_transition,
etc.) vive bajo /admin (portal), no como error público de /v1.
Test Clock (/v1/test/clock)
Reloj virtual manipulable por tenant, SOLO en modo prueba — ver Modo de pruebas. Adelanta el tiempo para probar renovaciones, dunning y auto-CFDI sin esperar días reales.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
403 | test_clock.live_forbidden | Llamaste a /v1/test/clock* con una llave sk_live_. Es una garantía de seguridad, no un capricho: producción jamás puede viajar en el tiempo. | El reloj de test solo existe en modo prueba; usa una llave sk_test_. |
400 | test_clock.invalid_advance | POST /v1/test/clock/advance con days/seconds negativos, o con un avance total (days + seconds) que no es estrictamente positivo — el reloj de test nunca retrocede. | Manda al menos uno de los dos campos con un valor tal que la suma sea mayor que cero. |
404 | test_clock.tenant_not_found | Segunda capa de la misma garantía que live_forbidden: tu tenant no se encontró al validar el reloj de test, en cualquiera de las operaciones de /v1/test/clock*. Caso de borde interno — con una llave viva no debería ocurrir. | No debería pasar en operación normal. Si lo ves, escríbenos a hola@winal.com.mx con el request_id. |
400 | test_clock.live_tenant_forbidden | Autenticaste con una llave sk_test_, pero tu tenant YA tiene producción activada — segunda capa de la misma garantía: aunque la llave sea de prueba, un tenant productivo no puede tener ni avanzar un reloj de test. | El reloj de test deja de estar disponible en cuanto tu cuenta pasa a producción, aunque conserves una llave sk_test_ viva. |
Alta self-service (/public/signup)
Firma pública, sin API key: registra un correo, verifícalo y recibe tu sk_test_ con un tenant sandbox listo (conector Sim ya ruteado). El paso natural antes de todo lo demás en esta página.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | signup.invalid_body | Falta un campo obligatorio del cuerpo: email en POST /public/signup, o token en POST /public/signup/verify. | Revisa el cuerpo contra el endpoint que estás llamando antes de reintentar. |
400 | signup.email_unavailable | El alta en línea no está disponible por ahora: falta conectar el envío de correo de verificación de nuestro lado. Se rechaza ANTES de crear nada, para no dejarte con una solicitud pending que nunca va a recibir su correo. | No es un error de tu request. Escríbenos a hola@winal.com.mx y te damos de alta nosotros mientras tanto. |
400 | signup.invalid_email | El email de POST /public/signup no tiene formato válido. | Manda una dirección con @ y un dominio con punto (p. ej. tu@negocio.com). |
400 | signup.invalid_business_name | Falta business_name en POST /public/signup: será el nombre de tu comercio dentro de Winal. | Manda un nombre no vacío. |
400 | signup.disposable_email | El dominio del email es de un proveedor de correo desechable/temporal (mailinator, yopmail y similares) — no se acepta para el alta. | Usa un correo permanente al que de verdad tengas acceso: ahí llega tu token de verificación y, luego, tus recibos y alertas. |
400 | signup.already_pending | Ya existe una verificación pendiente y vigente para ese correo (respaldado por un índice único en base: dos solicitudes concurrentes del mismo correo no duplican). | Revisa tu bandeja de entrada (y spam) por el correo anterior; si de verdad no llegó, espera a que venza (24 h) para volver a solicitarlo con ese correo. |
400 | signup.rate_limited | Superaste el límite de solicitudes de alta por correo (3/día) o por IP (5/hora) — protección anti-abuso propia del alta, independiente del límite general por IP de la API. | Espera a que la ventana se libere; si es legítimo y urge, escríbenos a hola@winal.com.mx. |
400 | signup.email_send_failed | No se pudo encolar/enviar el correo de verificación en este momento. La solicitud se libera de inmediato: no queda pending bloqueando tu correo ni consumiendo uno de tus 3 intentos diarios. | Reintenta POST /public/signup en unos minutos; si sigue fallando, escríbenos a hola@winal.com.mx. |
400 | signup.password_required | POST /public/signup/verify sin password: es la contraseña con la que vas a entrar a tu tablero, y se pide justo aquí —no al solicitar el alta— para nunca guardar una credencial de un correo que todavía no probaste que es tuyo. | Manda password junto con el token del enlace de verificación. |
400 | signup.invalid_token | El token de POST /public/signup/verify falta, o no corresponde a ninguna solicitud de alta — mismo código en ambos casos. | Usa el token exacto del enlace que te llegó por correo; pide un alta nueva con POST /public/signup si lo perdiste. |
400 | signup.already_verified | Ese token ya se usó: la solicitud ya está verificada y su sk_test_ ya se emitió una sola vez. | No hay una segunda llave para el mismo token. Inicia sesión en tu tablero con el correo y la contraseña que ya definiste, o genera una llave nueva desde ahí. |
400 | signup.token_expired | El token de verificación venció (ventana de 24 h desde que lo solicitaste). | Vuelve a solicitar el alta con POST /public/signup; te llega un token nuevo. |
409 | signup.verification_conflict | Ya hay una verificación de ESE mismo enlace corriendo (un doble clic, o dos pestañas). Solo una puede completarse: si no, tu alta acabaría con dos comercios y enlazada al equivocado. | Espera unos segundos y vuelve a abrir el enlace. No pidas el alta otra vez: el token sigue siendo válido, y si la primera terminó bien ya puedes entrar a tu tablero. |
400 | signup.email_already_registered | Al verificar, ese correo ya tiene una cuenta en Winal (de otro comercio, o staff). La solicitud queda descartada: nunca se crea un comercio huérfano para un correo que no puede ser su dueño. | Inicia sesión con esa cuenta existente, o repite el alta (POST /public/signup) con otro correo para este negocio nuevo. |
Autenticación de tu tablero (/portal-auth)
Tu tablero (winal.com.mx/app, ver
Tu tablero) se autentica con una sesión de cookie
(winal_portal), no con Authorization: Bearer sk_... — es un plano
de acceso aparte del resto de esta página. No es parte de la superficie /v1 de
Referencia de API, pero usa el mismo envelope de error.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
401 | portal_auth.unauthenticated | Falta la cookie winal_portal, o la sesión no existe o venció (GET /portal-auth/me, /logout-all, /totp/setup, /totp/confirm). | Inicia sesión de nuevo con POST /portal-auth/login. |
401 | portal_auth.invalid_credentials | El email o la contraseña de POST /portal-auth/login son incorrectos — mismo mensaje genérico exista o no esa cuenta. | Verifica tus credenciales, o usa "¿olvidaste tu contraseña?" en el tablero. |
403 | portal_auth.mfa_enrollment_required | Tu rol exige 2FA por política y tu cuenta todavía no lo tiene inscrito — POST /portal-auth/login te rechaza ANTES de darte sesión, así que no puedes llegar a /portal-auth/totp/setup (exige sesión) para resolverlo tú mismo. | Usa la pareja de endpoints hecha para este bloqueo, que autentican con email + contraseña SIN sesión previa: POST /portal-auth/mfa-enrollment/setup (te da el secreto y el QR) y POST /portal-auth/mfa-enrollment/confirm (activa el 2FA con tu primer código). En cuanto confirmas, vuelve a /portal-auth/login con tu código TOTP normal. |
429 + Retry-After( 400 fuera del login) | portal_auth.account_locked | Demasiados intentos fallidos seguidos: tu cuenta queda bloqueada temporalmente. En POST /portal-auth/login sale como 429 con Retry-After; en las rutas que solo confirman tu contraseña con la sesión ya abierta (inscribir un dispositivo, generar códigos de respaldo, restablecer el 2FA de un miembro) sale como 400, porque ahí no hay un reintento que cronometrar sino una operación que no se hizo. | Espera el tiempo indicado en Retry-After, o restablece tu contraseña para desbloquearla de inmediato. |
401 | portal_auth.sso_invalid | El id_token de POST /portal-auth/sso/login es inválido, venció, o tu comercio no tiene una conexión SSO configurada. | Vuelve a iniciar sesión con tu proveedor de identidad; si persiste, revisa la conexión SSO con quien administra tu cuenta. |
400 | portal_auth.reset_token_invalid | El token de POST /portal-auth/password/reset no existe, ya se usó o venció. | Pide un enlace nuevo con POST /portal-auth/password/forgot. |
503 | portal_auth.mail_unavailable | La cola de correo de la plataforma está saturada y POST /portal-auth/password/forgot no pudo aceptar tu solicitud. Es un hecho del servidor, no de tu cuenta: sale idéntico exista o no esa dirección, para no convertir el endpoint en un probador de correos registrados. | Reintenta en unos minutos. Si persiste, escríbenos a hola@winal.com.mx con el request_id. |
400 | portal_user.weak_password | La contraseña nueva tiene menos de 10 caracteres, al restablecerla (/portal-auth/password/reset) o al aceptar una invitación de equipo (/portal-auth/invitation/accept). | Usa una contraseña de al menos 10 caracteres. |
400 | totp.not_initialized | Pediste POST /portal-auth/totp/confirm sin haber llamado antes a /totp/setup. | Llama primero a /totp/setup para obtener el secreto y el QR. |
400 | totp.invalid_code | El código TOTP de 6 dígitos es incorrecto, o ya se usó (protección anti-repetición). | Genera un código nuevo en tu app autenticadora y reintenta. |
400 | totp.not_enabled | Pediste códigos de respaldo (POST /portal-auth/totp/backup-codes) sin tener la verificación en dos pasos activa. Sin segundo factor no hay nada que respaldar, y emitirlos igual sería crear una credencial de acceso extra a una cuenta que solo protege una contraseña. | Activa el 2FA primero (Seguridad en tu tablero): al confirmarlo te entregamos el lote de códigos en la misma respuesta. |
400 | portal_auth.password_required | Falta la contraseña —o no coincide— en una operación que TOCA tu segundo factor o EMITE una credencial peligrosa teniendo ya la sesión abierta: inscribir tu autenticador o cambiar de dispositivo (POST /portal-auth/totp/setup, también la PRIMERA vez), generar códigos de respaldo (POST /portal-auth/totp/backup-codes), restablecer el 2FA de un miembro (POST /app/api/team/{userId}/reset-2fa) crear/rotar una llave de PRODUCCIÓN o con permisos de dueño (POST /app/api/api-keys, .../rotate), AÑADIR un permiso a una llave que ya existe (PUT /app/api/api-keys/{id}/scopes — quitar permisos, en cambio, no pide nada), CREAR, ELEVAR O DESHABILITAR una identidad (POST /app/api/team, PUT /app/api/team/{userId}/role, DELETE /app/api/team/{id} sobre un MIEMBRO — revocar una invitación pendiente no pide nada, porque solo retira un acceso que aún no existe y es el freno de emergencia de quien invitó a quien no debía) o DESARMAR un control: ampliar/vaciar la lista de orígenes (PUT /app/api/origins), apagar o relajar la firma de peticiones (PUT /app/api/request-signing), registrar una llave de firma (POST /app/api/signing-keys) o aflojar un tope de actividad (PUT /app/api/accounts/{id}/limits). ENDURECER cualquiera de esos controles —estrechar la lista, encender la firma, revocar una llave, bajar un tope— NO pide nada, a propósito: es la reacción de quien sospecha una fuga y tiene que estar a un clic. Es el mismo mensaje si la mandas mal que si no la mandas, a propósito: distinguirlo convertiría estas rutas en un verificador de contraseñas para quien robara una cookie. | Vuelve a escribir tu contraseña. Los intentos fallidos cuentan hacia el bloqueo de tu cuenta, igual que en el login. |
400 | portal_totp_reset.token_invalid | El token de POST /portal-auth/totp-reset/cancel (el del correo que avisa de un restablecimiento de 2FA) no existe, o ese procedimiento ya se resolvió. Misma respuesta a propósito para los dos casos: distinguirlos convertiría este endpoint público en un probador de procedimientos ajenos. | Si no reconoces ese aviso y el enlace ya no sirve, escríbenos a hola@winal.com.mx de inmediato. |
Si pierdes tu segundo factor
Activar el 2FA no puede ser una puerta de un solo sentido. Hay tres salidas, en este orden, y ninguna de ellas deja entrar a nadie que solo tenga tu contraseña:
-
Tus códigos de respaldo. Te los entregamos una sola vez al activar el 2FA
(
POST /portal-auth/totp/confirmlos devuelve encodes). En el login se mandan comobackup_codeen vez detotp_code; cada uno sirve una vez.GET /portal-auth/totp/backup-codeste dice cuántos te quedan — nunca cuáles, porque en la base solo guardamos su hash — yPOSTa esa misma ruta emite un lote nuevo confirmando tu contraseña (los anteriores dejan de servir). Si activaste tu 2FA antes de que existieran los códigos, ésa es la vía para obtenerlos sin reinscribir tu autenticador. -
El dueño de tu cuenta. Si eres parte de un equipo, quien tenga el rol
ownerpuede restablecerte el 2FA desde Equipo en su tablero (POST /app/api/team/{userId}/reset-2fa, confirmando SU contraseña). Vuelves a entrar con la tuya y activas de nuevo tu segundo factor; tus sesiones abiertas se cierran. - Soporte, si eres el único dueño. Es el caso sin salida interna, y por eso el procedimiento es deliberadamente lento: identificamos al titular, lo abre una persona de Winal y lo aprueba otra (cuatro ojos), hay una espera obligatoria de 72 horas, y en el momento de abrirlo te llega un correo con el que puedes cancelarlo. Ese enlace del correo solo cancela: con tu buzón nadie puede desactivar tu segundo factor ni entrar a tu cuenta. Todo queda en tu bitácora, con el nombre del operador que lo ejecutó.
La forma de no llegar nunca al tercero: guarda tus códigos de respaldo y, si administras la cuenta,
nombra owner a alguien más.
Los códigos de abajo los devuelve ese procedimiento de soporte, que opera Winal desde su consola interna (no son rutas tuyas). Se documentan porque cada error del producto enlaza a su fila de esta página, y porque explican por qué el trámite tarda lo que tarda.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | portal_totp_reset.reason_required | Se intentó abrir el procedimiento sin declarar la identificación previa del titular y el motivo. | Cosa de quien opera: el motivo es obligatorio y queda en la bitácora del comercio. |
400 | portal_totp_reset.email_unavailable | No hay forma de avisar al titular por correo (sin SMTP cableado, o la cola llena), así que no se abre nada: el aviso no es un extra, es uno de los cuatro controles. | Reintentar más tarde. Sin aviso al titular, el procedimiento no arranca. |
400 | portal_totp_reset.self_approval_forbidden | Quien abrió el procedimiento intentó aprobarlo. Control de cuatro ojos: exige un segundo operador identificado (lo reimpone además un CHECK de la base). | Que lo apruebe otra persona de Winal con su propia sesión. |
400 | portal_totp_reset.not_approved | Se intentó ejecutar sin la aprobación del segundo operador. | Primero la aprobación, después la espera. |
400 | portal_totp_reset.waiting_period | Se intentó ejecutar antes de que venciera la espera obligatoria (72 h por defecto). El mensaje dice el instante exacto en que termina. | Esperar. Esa ventana existe para que el titular legítimo vea el aviso y pueda cancelar. |
400 | portal_totp_reset.resolved | El procedimiento ya se ejecutó o ya se canceló — lo más frecuente: lo canceló el titular desde su correo. | Si el titular lo canceló, es que no era él quien lo pidió: hay que volver a identificarlo antes de abrir otro. |
404 | portal_totp_reset.not_found | Ese procedimiento no existe en ese comercio (el comercio va en el predicado: conocer un id ajeno no alcanza para nada). | Consultar el listado de procedimientos vivos del comercio. |
409 | portal_totp_reset.concurrent | Dos operaciones tocaron el mismo procedimiento a la vez (p. ej. una ejecución contra la cancelación del titular). Gana una sola, y la ejecución solo apaga el 2FA si fue la ganadora. | Volver a consultarlo: su estado real ya es el que decidió la carrera. |
Tu tablero (/app/api): llaves, conectores, ruteo y producción
También bajo la sesión de cookie de arriba. Estos códigos son de
https://winal.com.mx/app/api/* — la trastienda de tu tablero (ver
Tu tablero), no pensada para integrarse desde fuera del navegador.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
401 | app.unauthenticated | Falta la cookie winal_portal, o la sesión no existe o venció, en cualquier ruta de /app/api/*. | Inicia sesión de nuevo en tu tablero. |
403 | app.staff_not_allowed | Tu sesión es de STAFF DE PLATAFORMA, no de un comercio, y /app/api/* es la consola de un comercio. | El staff de Winal opera por /admin, no por aquí. |
403 | app.missing_portal_header | Toda escritura en /app/api/* exige la cabecera X-Winal-Portal (protección anti-CSRF: un formulario de otro sitio no puede fijarla). | Si integras contra el tablero agrega esa cabecera; si usas la interfaz web normal, ya viaja sola. |
403 | app.read_only_role | Tu rol es lectura y la operación es una escritura. | Pide al dueño de tu cuenta que te cambie a operador u owner si necesitas escribir. |
403 | app.owner_required | La operación (emitir o cargar credenciales de producción, fijar ruteo de producción, solicitar producción, administrar el equipo) es exclusiva del rol owner. | Pide al dueño de tu cuenta que la haga, o que te promueva a owner. |
400 | request.invalid_body | Falta un campo requerido en el cuerpo (varía por endpoint: credentials, priorities, email, role, rfc…). | El mensaje del error nombra el campo que falta. |
400 | api_key.livemode_not_enabled | Pediste una llave sk_live_ (POST /app/api/api-keys con livemode: true, o rotaste una llave de producción) y tu cuenta todavía no tiene producción activada. | No es un error de tu código. Solicita la activación desde la sección Producción de tu tablero (Tu tablero → El camino a producción); en cuanto la plataforma la aprueba, ya puedes emitir llaves sk_live_. |
400 | api_key.invalid_scope | Pediste una llave con un permiso que no existe (un dedazo como payments:writ), con la lista vacía, o con el comodín * — que no se emite por autoservicio: sería saltarse de un plumazo las condiciones con las que se conceden los permisos que se piden aparte. | Consulta los permisos disponibles para tu cuenta en GET /app/api/api-keys/scopes (es lo que pinta el formulario de tu tablero) y manda esas claves tal cual. El mensaje del error trae la lista de las válidas. |
404 | api_key.not_found | El keyId de la ruta no existe en tu cuenta — mismo 404 genérico si la llave es de otro comercio. | Usa un id de GET /app/api/api-keys. |
409 | api_key.revoked | Intentaste rotar (POST /app/api/api-keys/{id}/rotate) una llave que ya está revocada. | Rotar solo aplica a una llave viva; emite una nueva con POST /app/api/api-keys si la necesitas. |
400 | api_key.already_rotated | Intentaste rotar una llave que ya tiene una sucesora. | Usa GET /app/api/api-keys para encontrar la llave nueva, o revoca la vieja directamente si ya no la necesitas. |
400 | provider.unknown_connector | El connectorKey de la ruta (/app/api/providers/{connectorKey}, o una fila de PUT /app/api/routing) no es uno de los conectores que puedes configurar. | Usa una de las claves que lista la sección Conectores de tu tablero. |
400 | routing.invalid_method | Una fila de PUT /app/api/routing trae un method que no es un método de pago reconocido. | Usa uno de los métodos que soporta tu cuenta (card, spei, oxxo…). |
400 | provider.connector_not_livemode | Guardaste credenciales de producción (PUT /app/api/providers/{connectorKey} con livemode: true) para un conector que solo existe para pruebas — hoy, el simulador sim. | Carga ahí las credenciales de un conector real (Mercado Pago, Conekta, Clip, Openpay, Kushki…). El simulador se configura y se usa únicamente en el ambiente de prueba. |
400 | routing.connector_not_livemode | Una fila de PUT /app/api/routing con livemode: true apunta a un conector que no puede atender producción (el simulador sim). Se rechaza la petición completa: no se guarda ninguna fila, ni siquiera las válidas. | Cambia esa fila a un conector real antes de guardar. Es una salvaguarda deliberada: un cobro real ruteado al simulador respondería succeeded sin que exista el dinero, y el ledger asentaría un ingreso inexistente. |
400 | production.checklist_incomplete | Pediste POST /app/api/production sin cumplir lo que se le exige a toda cuenta, sin importar qué haga con ella (hoy: 2FA activo en todo tu equipo). | El mensaje lista qué falta. GET /app/api/production lo devuelve además en account_missing. |
400 | production.no_capability_ready | Cumples lo que se le pide a toda cuenta, pero no tienes ninguna capacidad completa: ni cobrar de verdad (conector real con credenciales de producción y ruteo de producción) ni facturar de verdad (perfil fiscal y CSD/cuenta de PAC). No hacen falta las dos — hace falta una. | El mensaje dice qué te falta para cada capacidad. GET /app/api/production lo devuelve estructurado en capabilities[].missing, y can_request te dice si ya puedes solicitar. |
400 | production.already_enabled | Tu cuenta ya tiene producción activada. | No hace falta volver a solicitarla: ya puedes emitir llaves sk_live_. |
400 | production.already_requested | Ya tienes una solicitud de producción en revisión. | Espera la resolución; consulta el estado con GET /app/api/production. |
Tu tablero (/app/api): equipo e invitaciones
Desde 0088, invitar a alguien no crea su cuenta: emite una invitación por
email + role y la persona la acepta en
POST /portal-auth/invitation/accept, eligiendo ahí su propia contraseña — nunca
la tecleas tú por ella. Ver Tu tablero → Equipo.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | portal_invitation.invalid_email | El email de POST /app/api/team está vacío o no es una dirección válida. | Manda un correo con formato válido. |
400 | portal_invitation.invalid_role | El role de la invitación no es owner, operador ni lectura. | Usa uno de esos tres valores. |
400 | portal_invitation.email_unavailable | El correo saliente de la plataforma no está disponible en este momento. | No es un error de tu solicitud: reintenta en unos minutos; si sigue fallando escríbenos a hola@winal.com.mx. |
400 | portal_invitation.rate_limited | Tu comercio llegó al tope de destinatarios distintos invitados en 24 h: 5 mientras la cuenta está en modo de prueba y 50 con producción activada. Los reenvíos al mismo correo no consumen este cupo. | Continúa mañana, o activa producción para subir el límite (Tu tablero → El camino a producción). Si necesitas dar de alta a un equipo grande de golpe, escríbenos a hola@winal.com.mx. |
400 | portal_invitation.resend_limit | Ya se enviaron 3 invitaciones a ese mismo correo en las últimas 24 h (la inicial más dos reenvíos). El tope se cuenta sobre los correos que SALIERON, así que revocar invitaciones no devuelve cupo. | Pide a esa persona que revise su carpeta de spam, invítala a otra dirección, o reintenta mañana. |
400 | portal_invitation.email_send_failed | La invitación no se pudo poner en la cola de envío, así que se revocó sola: no queda un token vivo que nadie recibió bloqueando el correo. | No es un error de tu solicitud. Reintenta en unos minutos; el correo queda libre de inmediato para volver a invitarlo. Si sigue fallando, escríbenos a hola@winal.com.mx. |
400 | portal_invitation.already_member | La persona ya pertenece a tu equipo — al invitarla de nuevo (POST /app/api/team), o al aceptar ella misma una invitación (POST /portal-auth/invitation/accept) siendo ya miembro. | Si invitas: no hace falta, ya está dentro — usa PUT /app/api/team/{userId}/role para cambiarle el rol. Si aceptas: inicia sesión directamente con tu contraseña. |
400 | portal_invitation.email_taken | Al aceptar (POST /portal-auth/invitation/accept), ese correo ya tiene una cuenta en Winal bajo otro comercio. | Esa persona inicia sesión con su cuenta existente, o pide que la inviten con otra dirección. |
409 | portal_invitation.concurrent | Dos invitaciones al mismo correo se emitieron casi al mismo tiempo. | Reintenta POST /app/api/team; una de las dos ya salió. |
400 | portal_invitation.token_invalid | Misma respuesta a propósito para un token de POST /portal-auth/invitation/accept inválido, vencido, revocado o ya usado: distinguir esos cuatro casos convertiría este endpoint público (nadie tiene sesión todavía) en un probador de invitaciones ajenas. | Pide a quien te invitó que te mande una invitación nueva desde la sección Equipo de su tablero. |
404 | portal_invitation.not_found | La invitación que intentas revocar (DELETE /app/api/team/{id}) no existe en tu comercio o ya se resolvió (aceptada, revocada o vencida). | Refresca la lista de invitaciones pendientes con GET /app/api/team. |
400 | portal_user.invalid_role | El role de PUT /app/api/team/{userId}/role no es válido. | Usa owner, operador o lectura. |
404 | portal_user.not_found | El userId no existe en tu equipo, o está deshabilitado. | Usa un id de GET /app/api/team. |
400 | portal_user.last_owner | Intentaste degradar el rol o deshabilitar al único owner de la cuenta. | Nombra owner a otro miembro de tu equipo antes de cambiarlo o quitarlo. |
400 | portal_user.totp_not_enabled | Pediste restablecer el 2FA (POST /app/api/team/{userId}/reset-2fa) de un miembro que no lo tiene activo: no hay nada que restablecer. | Refresca GET /app/api/team: la columna totp_enabled dice quién lo tiene. Si esa persona no puede entrar, su problema es otro (contraseña, o cuenta deshabilitada). |
400 | portal_user.owner_required | Quien pidió restablecer el 2FA de un miembro no es el owner de la cuenta. Normalmente ya lo habrás visto como 403 app.owner_required: este código es la MISMA regla comprobada otra vez dentro del servicio, para que no dependa de que cada ruta nueva se acuerde de mirar el rol. | Que lo haga el dueño de la cuenta. |
400 | portal_user.self_totp_reset | Intentaste restablecer TU PROPIO 2FA por la ruta del equipo. No se permite: si bastara tener la sesión abierta para apagarse el segundo factor, una cookie robada lo apagaría. | Para tu propia cuenta usa Seguridad: entra con un código de respaldo, o inscribe un dispositivo nuevo confirmando tu contraseña. Si no tienes ni códigos ni teléfono y eres el único dueño, escríbenos a hola@winal.com.mx. |
404 | team.not_found | El id de DELETE /app/api/team/{id} no es ni un miembro ni una invitación viva de tu equipo. | Refresca GET /app/api/team y usa un id actual. |
404 | tenant.not_found | Caso de borde interno: tu comercio no se encontró al procesar la invitación. | No debería ocurrir con una sesión válida. Si lo ves, escríbenos a hola@winal.com.mx con el request_id. |
Cuentas administradas (/v1/accounts y la segunda credencial)
Esta sección documenta los errores. Para la guía completa —el alta, las dos credenciales con un ejemplo real de facturación, la firma con certificado y qué pasa cuando se rechaza un origen— ver Cuentas administradas.
Si tu negocio tiene sus propios clientes —y cada uno factura con su RFC— das de alta una
cuenta por cada uno con POST /v1/accounts y después operas por ellas presentando
dos credenciales: la tuya en Authorization: Bearer … (quién actúa) y la de la
cuenta en la cabecera Winal-Account-Key (sobre quién). No viaja ningún identificador de
cuenta: un identificador se puede equivocar, y equivocarlo aquí significa timbrar un CFDI con el RFC
de otro comercio.
Crear cuentas exige el scope accounts:write (o el comodín *) y
no admite la segunda credencial: es de administración, no de operación, así que la llave con
la que facturas todo el día —aunque se filtre— no puede fabricar cuentas ni credenciales. Si tu
llave no lo trae, responde 403 insufficient_scope.
Y la credencial correcta tampoco alcanza sola. Hay tres actos que no se pueden hacer solo con
tu llave de API, porque son exactamente los que usaría alguien metido en tu sistema para quedarse:
crear cuentas, emitir o rotar sus credenciales y cambiar su sello digital (CSD).
Los tres viven en tu consola (winal.com.mx/app) y piden tu
contraseña y tu segundo factor — un camino distinto del de la API, que quien te robara una llave no
tiene. Para dar de alta muchas cuentas de un tirón no hace falta repartir una llave permanente que
pueda fabricarlas: abres una ventana de alta desde tu consola (acotada en tiempo y en número
de cuentas) y durante ella POST /v1/accounts funciona con tu llave de siempre. Cerrada
la ventana, deja de funcionar.
Administrar cuentas de tus clientes se habilita una sola vez, no cuenta por cuenta: lo pides en tu consola (Cuentas → Solicitar) y lo aprueba Winal, porque vas a emitir comprobantes fiscales a nombre de terceros. Todo lo demás lo decides tú, sin pedirnos permiso.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | account.invalid_reference | Falta reference o no cumple el formato (1–128 caracteres; letras, dígitos y . _ : -). | Manda el identificador que esa cuenta ya tiene en tu sistema. Es lo que hace idempotente el alta: repetirla con la misma reference devuelve la cuenta que ya existe en vez de crear otra. |
400 | account.invalid_name | Falta name o pasa de 200 caracteres. | Manda el nombre comercial de la cuenta. |
400 | account.parent_is_managed | Tu cuenta ya la administra otra, así que no puede administrar cuentas a su vez: la estructura es de un solo nivel. | El alta la hace la cuenta matriz. |
400 | account.same_credential | Winal-Account-Key trae una credencial de la MISMA cuenta que actúa. | Para operar como tú mismo, omite la cabecera. |
401 | account.credential_invalid | La credencial de Winal-Account-Key es inválida, fue revocada o expiró. Una cabecera vacía también se rechaza: mandarla significa que quieres operar por otra cuenta, y degradar en silencio a "actúa como tú mismo" produciría comprobantes con TU RFC. | Revisa que estés mandando la credencial de esa cuenta. Si la perdiste, rótala desde tu consola (contraseña y segundo factor): la credencial de una cuenta nunca se puede volver a mostrar, pero la cuenta jamás queda inservible. |
403 | account.child_credential_not_standalone | Pusiste en Authorization la credencial de una cuenta administrada. Esa credencial nunca autentica sola: si bastara, exigir dos credenciales sería teatro. | En Authorization va tu credencial; la de la cuenta administrada va en Winal-Account-Key. (Este error también aparece si las invertiste.) |
403 | account.not_a_child | La credencial de Winal-Account-Key es de una cuenta independiente, no de una que tú administres. | Solo se puede actuar por cuentas creadas con POST /v1/accounts desde tu propia cuenta. |
403 | account.not_your_account | Esa cuenta no es una de las que tú administras. El mensaje es el mismo si la cuenta no existe: no confirmamos la existencia de cuentas ajenas. | Consulta GET /v1/accounts para ver las tuyas. |
403 | account.livemode_mismatch | Las dos credenciales son de ambientes distintos (una sk_live_ y otra sk_test_). | Una cuenta nueva nace en modo prueba y su paso a producción lo aprueba la plataforma —nunca quien la administra, ni ella misma—: mientras tanto, opérala con tu llave de prueba. |
403 | account.delegation_not_allowed | Esa ruta no admite la segunda credencial, aunque las dos sean válidas y la cuenta sea tuya. Es el caso de /v1/accounts: crear cuentas y rotar credenciales es justo lo que usaría quien te robara una llave para echar raíces. | Quita la cabecera Winal-Account-Key. Rotar credenciales y cambiar el sello digital se hacen desde la consola, que exige contraseña y segundo factor. |
403 | account.delegation_disabled | Tu cuenta ya no está habilitada para operar cuentas de tus clientes: la habilitación la concede —y la retira— Winal. Se comprueba en cada petición delegada, no solo al crear cuentas, así que retirarla detiene también lo ya creado. | Tu propia cuenta sigue funcionando igual: quita la cabecera Winal-Account-Key para operarla. Para volver a operar las de tus clientes, pídelo desde tu consola (Cuentas → Solicitar). |
404 | account.parent_not_found | Caso de borde interno: la cuenta que da el alta no se encontró. | No debería ocurrir con una llave válida. Si lo ves, escríbenos a hola@winal.com.mx con el request_id. |
409 | account.creation_conflict | Dos altas simultáneas de la misma reference chocaron. | Reintenta: la que ganó ya existe, y el reintento te la devolverá con created: false. |
400 | account.origin_allowlist_required | Intentaste crear tu primera cuenta administrada sin haber declarado desde qué direcciones opera tu sistema. Tu credencial va a abrir las cuentas de tus clientes —y a firmar sus comprobantes fiscales—, así que no puede valer desde cualquier punto de internet. | Declara tus orígenes en tu consola (winal.com.mx/app → Seguridad → Orígenes permitidos) y repite el alta. Acepta varias direcciones y rangos CIDR, para tu segunda instancia y tu ambiente de pruebas. |
503 | account.audit_unavailable | No se pudo dejar constancia en bitácora de una operación sobre una cuenta administrada, así que no se ejecutó. Todo acto sobre una cuenta ajena se registra antes de hacerse: preferimos no hacerlo a hacerlo sin rastro. | Reinténtala. |
400 | account.admin_not_enabled | Tu cuenta no está habilitada para administrar cuentas de tus clientes. Es lo único de todo esto que aprueba Winal, y se aprueba una vez por cliente, no cuenta por cuenta: vas a emitir CFDI a nombre de terceros. Sale también si la tenías y Winal te la retiró: en ese caso no solo dejas de crear cuentas nuevas — también dejas de emitirles credenciales y de tocarles su identidad fiscal o su proveedor de timbrado a las que ya tenías. | Solicítalo en tu consola (Cuentas → Solicitar) contando para qué lo necesitas. Mientras se resuelve, el resto de tu cuenta sigue funcionando igual: tus cuentas siguen a la vista y tu propia operación no se toca. |
400 | account.creation_window_closed | Crear cuentas con tu llave de API exige una ventana de alta abierta, y no la hay: nunca la abriste, la cerraste, venció o se agotó su presupuesto. El mensaje dice cuál de las cuatro. | Ábrela en tu consola (Cuentas → Ventana de alta), que pide contraseña y segundo factor; dura lo que le digas —hasta 24 h— y autoriza las cuentas que le digas. Para un alta suelta no hace falta ventana: créala desde la propia consola. |
400 | account.scope_not_allowed | Pediste para la credencial de la cuenta nueva un permiso que no puede llevar: el comodín *, payouts:write (dispersar dinero) o accounts:write (administrar cuentas). Esa credencial se la entregas a un tercero, así que su techo de daño es operar ESA cuenta; dispersar y administrar son actos tuyos, no suyos. | Omite scopes —la cuenta nace con los permisos de operación, incluidos los webhooks— o pide solo los de esa lista. El mensaje del error la trae completa. |
400 | account.window_duration_invalid | La duración pedida para la ventana de alta está fuera de rango (5 minutos a 24 horas). | Pide una duración dentro del rango. Más allá de eso ya no sería una autorización acotada, sino una llave permanente para crear cuentas. |
400 | account.window_budget_invalid | El número de cuentas pedido para la ventana está fuera de rango (1 a 500). | Pide un presupuesto dentro del rango; si tienes que migrar más, abre otra ventana al terminar. |
404 | account.not_found | Esa cuenta no es una de las que administras. El mensaje es el mismo si no existe: no confirmamos la existencia de cuentas ajenas. | Consulta la lista de tus cuentas en la consola o con GET /v1/accounts. |
409 | account.fiscal_identity_locked | Intentaste cambiarle el RFC a una cuenta que ya timbró comprobantes. Su historial fiscal es el de ese contribuyente y un CFDI no se borra: se cancela y deja rastro. | Si tu cliente cambió de RFC, es otro contribuyente: crea otra cuenta para él y opérala desde ahí. Corregir el nombre, el régimen fiscal o el código postal sí se puede. |
400 | account_admin.purpose_required | La solicitud para administrar cuentas viene sin explicar para qué la necesitas (de 10 a 2000 caracteres). | Cuéntanos qué vas a hacer y a cuántos clientes esperas dar de alta: es lo que se mira al resolverla. |
400 | account_admin.already_requested | Ya tienes una solicitud en revisión. | Espera la resolución; te llegará con notas si se rechaza. |
400 | account_admin.already_enabled | Tu cuenta ya puede administrar cuentas de tus clientes. | Nada que hacer: crea la primera desde tu consola. |
400 | account_admin.same_actor | Quien solicitó intentó resolver su propia solicitud. | La resuelve otra persona: un control que el controlado puede ejecutar no es un control. |
404 | account_admin.request_not_found | Esa solicitud no existe o ya se resolvió. | Consulta el estado en tu consola (Cuentas). |
400 | account_admin.not_enabled | Se intentó retirar la capacidad de administrar cuentas a una cuenta que hoy no la tiene. Es un error de la consola de Winal, no tuyo. | Nada que hacer de tu lado: la capacidad ya estaba apagada. Si tu operación se detuvo, escríbenos. |
400 | account_admin.revoke_reason_required | Winal intentó retirar esa capacidad sin escribir por qué. No se permite: del otro lado se detiene la operación de un cliente y la bitácora tiene que decir el motivo. | Interno de la plataforma; no llega a las llaves de un comercio. |
400 | portal_auth.step_up_mfa_required | El acto que pediste exige tu verificación en dos pasos y todavía no la tienes activa. Emitir una credencial de PRODUCCIÓN (o una que dispersa dinero o gasta el saldo de recargas), administrar las cuentas de tus clientes y manejar su sello digital no puede depender solo de una contraseña. | Actívala en tu consola (Seguridad → Verificación en dos pasos) y vuelve a intentarlo. Está a un clic, en la misma consola. |
400 | portal_auth.step_up_code_invalid | El código de verificación no es válido, o ya se usó (un código sirve una sola vez, también aquí). | Escribe el que muestra tu autenticador ahora mismo. Si perdiste el teléfono, sirve uno de tus códigos de respaldo: no te quedas sin poder operar tus cuentas. |
400 | portal_auth.sso_managed_totp | Tu identidad la administra tu proveedor de identidad (SSO), así que la verificación en dos pasos no se inscribe aquí: se activa allá. Antes sí se dejaba, y era el agujero: una identidad federada no tiene contraseña local, así que con la sola cookie de sesión se podía acuñar un autenticador propio, cobrar sus diez códigos de respaldo, cerrar las demás sesiones y a partir de ahí pasar todos los actos que la confirmación protege. Un segundo factor que la propia sesión puede inscribir no es un segundo factor. | Activa la verificación en dos pasos en tu proveedor de identidad. Para los actos peligrosos, la consola te pedirá volver a autenticarte con él (ver portal_auth.step_up_reauth_required). |
400 | portal_auth.step_up_reauth_required | El acto que pediste exige confirmación y tu identidad es federada (SSO): no hay contraseña ni segundo factor locales que puedas presentar. Lo que se pide es que tu proveedor de identidad te vuelva a autenticar AHORA. | En la consola no tienes que hacer nada especial: aparece el botón «Continuar con mi proveedor», te lleva con él y vuelves aquí. Por API, la ceremonia son dos llamadas: POST /app/api/security/reauth/start con el method y el path del acto (devuelve state, nonce y la URL de autorización), y POST /app/api/security/reauth/complete con el state y el id_token. Lo que devuelve —el boleto— va en la cabecera Winal-Reauth-Assertion al repetir el acto. |
400 | portal_auth.reauth_act_required | Abriste la ceremonia sin decir qué acto vas a confirmar. No se admite: el boleto se emite para UNA operación, no para todo lo que venga después. | Manda method y path con el verbo y la ruta exactos de la operación que vas a ejecutar. |
400 | portal_auth.reauth_not_federated | Tu identidad no la administra ningún proveedor externo, así que no hay nadie que pueda re-autenticarte: tu confirmación es contraseña + segundo factor. | Manda password y code en el cuerpo del acto, como siempre. |
400 | portal_auth.reauth_connection_disabled | La conexión con tu proveedor de identidad está desactivada, así que no puede volver a autenticarte. | Escríbenos: la conexión SSO la administra la plataforma. Mientras tanto tu sesión sigue viva para lo que no exige confirmación. |
400 | portal_auth.reauth_challenge_invalid | El reto que estás cerrando no existe, no es tuyo, ya se usó o venció (dura diez minutos). Es la misma respuesta para los cuatro casos a propósito: probar retos ajenos no debe distinguirse de inventarlos. | Vuelve a pulsar la acción y empieza la confirmación otra vez. |
400 | portal_auth.reauth_nonce_mismatch | El id_token es válido pero no responde a ESTE reto: no trae el nonce que se pidió. Sin esa atadura, una re-autenticación conseguida para cualquier otra cosa serviría para ejecutar la que sea. | Pide la aserción con el nonce que devolvió /reauth/start (la consola lo hace sola). |
400 | portal_auth.step_up_reauth_invalid | El boleto que presentaste no es válido, ya se gastó o venció (dura cinco minutos). Vale para un acto y una sola vez: quien mire por encima de tu hombro no puede encadenar un segundo acto peligroso con tu confirmación. | Vuelve a autenticarte con tu proveedor para confirmar esta operación. |
400 | portal_auth.step_up_reauth_act_mismatch | Te volviste a autenticar para confirmar OTRA operación. El boleto lleva escrito el verbo y la ruta del acto que lo pidió, y solo sirve para ése. | Confirma la operación que de verdad quieres hacer: cada una abre su propia ceremonia. |
400 | sso.no_token | No mandaste el id_token. | Completa la ceremonia con la aserción que te devolvió tu proveedor. |
400 | sso.malformed_token | Lo que mandaste no es un JWS compacto (o no se puede decodificar). | Manda el id_token COMPLETO, tal cual lo entregó tu proveedor: tres partes separadas por puntos, sin espacios ni saltos de línea. |
400 | sso.unsupported_alg | El id_token viene firmado con un algoritmo que no admitimos. Solo RS256. | Configura tu IdP para firmar los id_token con RS256. |
400 | sso.no_issuer | El id_token no trae iss, así que no hay forma de saber qué proveedor lo emitió. | Es un problema de configuración de tu IdP: el iss es obligatorio en OIDC. |
400 | sso.unknown_issuer | No hay ninguna conexión SSO activa para ese iss. | La conexión la registra la plataforma. Si acabas de cambiar de IdP —o de dominio— escríbenos para actualizarla. |
400 | sso.bad_signature | La firma del id_token no valida contra las llaves públicas registradas para tu conexión. | Suele ser una rotación de llaves de tu IdP que todavía no está registrada aquí. Escríbenos para actualizar el JWKS. |
400 | sso.bad_audience | El aud (o el azp) del id_token no corresponde a esta conexión: el token se emitió para otra aplicación. | Pide la aserción con el client_id registrado para Winal, no con el de otra de tus aplicaciones. |
400 | sso.no_exp | El id_token no trae exp. Un token sin caducidad no caduca nunca, y eso es un token robable para siempre. | Configura tu IdP para emitir exp (es obligatorio en OIDC). |
409 | sso.email_conflict | El correo que trae el id_token ya pertenece a OTRA identidad de esta consola. | Un IdP no puede tomar una cuenta ajena afirmando su correo: la federación va por sub. Si es una migración legítima, escríbenos. |
400 | sso.expired | El id_token ya caducó. | Vuelve a empezar la confirmación: la aserción se pide y se usa en el momento. |
400 | sso.no_iat | El id_token no trae iat. | Configura tu IdP para emitirlo; sin él no se puede medir si el token es reciente. |
400 | sso.iat_future | El iat del id_token está en el futuro. | Revisa el reloj de tu IdP: se admiten dos minutos de diferencia. |
400 | sso.stale | El id_token es demasiado viejo (más de cinco minutos), aunque siga vigente. | Pide la aserción justo antes de confirmar, no la guardes. |
400 | sso.not_yet_valid | El id_token todavía no es válido (nbf en el futuro). | Revisa el reloj de tu IdP. |
400 | sso.no_subject | El id_token no trae sub, que es con lo que se identifica a la persona. | Configura tu IdP para emitirlo. |
400 | sso.no_email | El id_token no trae un correo válido en el claim configurado para tu conexión. | Añade el claim de correo al token, o pídenos que apuntemos la conexión al claim correcto. |
400 | sso.email_unverified | El id_token no marca email_verified en true. | No aprovisionamos ni confirmamos con un correo que el propio IdP no da por verificado. |
400 | sso.mfa_required | Tu rol exige multifactor y el id_token no lo evidencia (claim amr). | Activa MFA en tu proveedor para esa cuenta: aquí no hay un segundo factor local que puedas inscribir en su lugar, y ésa es la idea. |
400 | sso.no_jti | El id_token no trae jti ni nonce, así que no se puede impedir que se reenvíe. | Configura tu IdP para emitir jti. En la ceremonia de confirmación el nonce ya lo pedimos nosotros. |
400 | sso.replayed | Ese id_token ya se usó. Vale una sola vez, aquí y en el inicio de sesión. | Pide una aserción nueva. Si esto aparece sin que hayas hecho nada, avísanos: puede ser alguien reenviando un token capturado. |
400 | sso.account_disabled | La identidad está deshabilitada. | Un inicio de sesión no reactiva una cuenta dada de baja: pídeselo a quien administra tu equipo. |
409 | sso.provision_conflict | El correo del token choca con otra identidad ya existente. | Un IdP no puede tomar una cuenta ajena afirmando su correo. Si es una migración legítima, escríbenos. |
400 | portal_auth.step_up_reauth_stale | La aserción que mandaste no trae auth_time, o ese auth_time es de hace demasiado. Un id_token recién emitido prueba que el token es nuevo, no que tú estés delante: tu IdP puede emitirlos desde una sesión de hace días. | Pide la aserción con prompt=login: OIDC obliga a tu IdP a devolver auth_time cuando mandas max_age, y ese instante tiene que ser de los últimos cinco minutos. |
400 | portal_auth.step_up_reauth_mismatch | La aserción es válida pero es de OTRA identidad de la misma conexión SSO (o de una cuenta deshabilitada). La confirmación pregunta "¿eres tú?", no "¿hay alguien de esta empresa?". | Vuelve a autenticarte con la MISMA cuenta con la que iniciaste sesión en la consola. |
Origen acotado y firma de peticiones
Script completo de firma con openssl, ya con la cabecera de la cuenta administrada
incluida en lo firmado, en Cuentas administradas → Firma
de peticiones.
Dos defensas OPCIONALES que se montan encima de tu API key y atacan la misma familia de problemas: que alguien más consiga tu llave. Las dos se administran solo desde tu consola (winal.com.mx/app → Seguridad), que exige contraseña y segundo factor — nunca por API, porque si bastara la llave que protegen, quien la robara se agregaría su propia dirección y su propia llave de firma.
Origen acotado: una lista de direcciones IP y rangos CIDR (IPv4 e IPv6) desde los que valen tus credenciales. Es opcional para un negocio suelto —si facturas desde tu local no tienes IP fija y no vamos a exigírtela— y obligatoria en cuanto administras cuentas de tus clientes. Cada intento desde una dirección no declarada, además de rechazarse, te llega como alerta operativa con la dirección exacta: es el primer síntoma de una credencial filtrada.
Firma de peticiones: tú conservas tu llave privada y nos registras solo la pública
(bloque PEM PUBLIC KEY o un certificado CERTIFICATE; RSA de 2048 bits o
más, o ECDSA P-256 o mayor). Tu API key sigue diciendo quién eres; la firma prueba que
eres tú. Un volcado completo de nuestra base no permitiría suplantarte: lo que guardamos es material
público.
La cabecera es
Winal-Signature: t=<segundos unix>,n=<nonce>,k=<key_id>,v1=<firma en base64>,
y lo que se firma (RSA-SHA256 PKCS#1 v1.5, o ECDSA con SHA-256) es la unión con saltos de
línea de, en este orden:
winal-request-v1- el método HTTP en mayúsculas
- la ruta (
/v1/payment_intents) - la cadena de consulta tal como viaja, con su
?, o vacía - la marca de tiempo, el mismo valor de
t= - el nonce, el mismo valor de
n=(8 a 128 caracteres, distinto en cada petición) - el SHA-256 del cuerpo en hexadecimal minúsculas (el del cuerpo vacío si no hay)
- el
key_id, el mismo valor dek= - el SHA-256 del valor de
Winal-Account-Keysi mandas esa cabecera, o vacío si no
Ese último renglón es el que más importa aquí: esa cabecera decide sobre qué cuenta se actúa, o sea
con qué RFC sale un CFDI, así que va dentro de la firma y una petición firmada para un comercio no se
puede re-apuntar a otro. La ventana de tolerancia de t es de 5 minutos y cada
nonce vale una sola vez. Si reintentas una petición, conserva tu Idempotency-Key
—esa es la que evita el cobro duplicado— y firma de nuevo con nonce y marca de tiempo nuevos.
Cómo conviven las dos formas de credencial. Toda cuenta empieza con la exigencia de firma apagada, así que si ya integras con API key no tienes que hacer nada. Con la exigencia apagada, una petición sin firma se atiende igual que siempre y una con firma se verifica de verdad — así pruebas tu implementación sin ventanas de silencio, porque una firma rota nunca pasa inadvertida. Cuando funcione, enciendes la exigencia desde tu consola. El camino de vuelta también está abierto: si pierdes tu llave privada, desde la consola registras otra o vuelves a apagar la exigencia, y si te la roban puedes revocarla al instante aunque sea la única (tu API queda cerrada, que es justo lo que quieres, y se reabre registrando otra).
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
403 | account.origin_not_allowed | Tu cuenta declaró orígenes permitidos y esta petición llegó desde otra dirección. El mensaje trae la dirección observada. | Si es tu servidor nuevo, agrégala en tu consola (Seguridad → Orígenes permitidos); puedes reemplazar la lista entera en una sola operación, que es lo que sirve al mudarte de región. Si no reconoces esa dirección, alguien más tiene tu llave: rótala ya. El dueño de la cuenta recibe una alerta con el intento. |
403 | account.origin_unknown | Tu cuenta tiene orígenes declarados y no se pudo determinar desde dónde llega la petición. Ante la duda se cierra, nunca se abre. | No debería ocurrir. Escríbenos a hola@winal.com.mx con el request_id. |
401 | account.signature_required | Tu cuenta exige peticiones firmadas y ésta no trae Winal-Signature. También aparece si revocaste tu última llave con la exigencia encendida. | Firma la petición, o entra a tu consola —que no depende de esta firma— para registrar una llave nueva o apagar la exigencia. |
401 | account.signature_malformed | La cabecera no se pudo interpretar: falta alguno de los cuatro componentes, o el nonce está fuera de los 8–128 caracteres. | Arma la cabecera como t=…,n=…,k=…,v1=…. |
401 | account.signature_expired | La marca de tiempo quedó fuera de la ventana de 5 minutos. | Nueve de cada diez veces es el reloj del servidor que firma: sincronízalo por NTP. Firma con la hora de ahora, no con la de cuando armaste la petición. |
401 | account.signature_invalid | La firma no corresponde a la petición que llegó: cambió el cuerpo, la ruta, la consulta o la cabecera Winal-Account-Key después de firmar, o la cadena firmada no se armó igual. | Revisa la cadena renglón por renglón contra la lista de arriba. Firma el cuerpo tal cual viaja, sin volver a serializarlo (un espacio de diferencia cambia el hash). |
401 | account.signature_replayed | Ese nonce ya se usó. Cada petición lleva el suyo, de un solo uso. | Genera un nonce nuevo en cada intento, incluidos los reintentos. Para que un reintento no cobre dos veces, lo que se conserva es el Idempotency-Key, no el nonce. |
401 | account.signing_key_unknown | La llave del componente k= no existe en tu cuenta, fue revocada, o el certificado con el que la registraste ya venció. | Coteja el key_id contra las llaves de tu consola y registra una nueva si hace falta. |
400 | origin.invalid_entry | Una entrada de la lista no es una IP ni un rango válido. En un CIDR los bits fuera del prefijo van en cero. | Escribe 203.0.113.0/24, no 203.0.113.4/24: lo segundo es ambiguo y preferimos rechazarlo a adivinar si querías el rango o el anfitrión. |
400 | origin.too_broad | Entre TODAS, las direcciones de la lista dejan pasar la mitad de internet o más. Se mide la lista completa y no cada entrada por separado: 0.0.0.0/1 y 128.0.0.0/1 se ven pequeñas una a una y juntas son internet entero — así se saltaba esta guarda. | Una lista que permite todo no es una lista: preferimos rechazarla a dejarte creer que estás protegido. Declara las direcciones desde las que de verdad llama tu sistema. Si quieres apagar el control, deja la lista vacía. |
400 | origin.limit_reached | Más de 20 entradas. | Agrupa las que puedas en un rango CIDR. |
400 | origin.required_for_managed_accounts | Intentaste vaciar la lista de una cuenta que administra cuentas de sus clientes. | Si te mudaste de servidor, manda la lista nueva en una sola operación en vez de vaciarla: el reemplazo es atómico y no te deja fuera en el intermedio. |
400 | signing_key.invalid_pem | El PEM no trae un bloque legible. | Manda el bloque completo, con sus líneas -----BEGIN …----- y -----END …-----. Se aceptan PUBLIC KEY y CERTIFICATE. |
400 | signing_key.private_key_submitted | Mandaste tu llave privada. | Winal solo necesita la pública. Y como esa privada ya salió de tu máquina, dala por comprometida: genera un par nuevo (openssl rsa -in llave.pem -pubout -out publica.pem) y registra la pública de ése. |
400 | signing_key.unsupported_algorithm | La llave no es RSA ni ECDSA sobre curva NIST. | Usa RSA de 2048 bits o más, o ECDSA P-256 o mayor. |
400 | signing_key.weak_key | La llave es más corta que el mínimo (RSA 2048, ECDSA 256). | Genera un par más largo. |
400 | signing_key.expired_certificate | El certificado que mandaste ya venció. | Registra uno vigente, o manda la llave pública suelta (bloque PUBLIC KEY), que no lleva fechas y por tanto no vence. |
400 | signing_key.already_registered | Esa llave ya está registrada en tu cuenta (el key_id se deriva del material, así que la misma llave siempre es la misma), o ya estuvo y fue revocada. | Si estaba revocada, genera un par nuevo: una llave revocada no vuelve. |
400 | signing_key.limit_reached | Ya tienes 5 llaves activas. | Revoca alguna que ya no uses antes de registrar otra. |
404 | signing_key.not_found | Ese key_id no existe en tu cuenta. | Cópialo de la lista de llaves de tu consola. |
400 | signing_mode.invalid | Modo desconocido. | Usa off o required. |
400 | signing_mode.no_usable_key | Quisiste exigir firma sin tener ninguna llave activa y vigente, lo que cerraría tu API en ese mismo clic. | Registra primero tu llave pública y pruébala con la exigencia en off (las firmas ya se verifican); cuando funcione, vuelve y exígela. |
Topes de actividad por cuenta
Cada cuenta tiene un tope de actos por ventana: comprobantes emitidos, comprobantes cancelados, recargas de tiempo aire, pagos de servicio, altas de cuentas y cualquier otra escritura. No es el límite de solicitudes de más abajo —ése protege a la plataforma y se mide por llave— sino un tope por cuenta, que es donde está el daño: si alguien entra a tu sistema, no emite una factura, emite miles con el RFC de tus clientes.
A quién se le cuenta. Los actos de comprobantes y de cuentas se cuentan cuando actúas sobre una cuenta administrada. Las recargas y los pagos de servicio se cuentan siempre, también cuando operas con tu propia llave sobre tu propia cuenta, y la razón es la asimetría del daño: un comprobante de más se cancela y una cuenta de más se revoca, pero el saldo que ya entró a un teléfono ajeno no vuelve.
Los topes de fábrica están por encima de cualquier operación normal (300 comprobantes por hora,
100 cancelaciones, 200 recargas, 100 pagos de servicio, 600 consultas de adeudo, 20 traspasos de
saldo entre bolsas —éste con techo de monto de fábrica además del de número, $2,000,000.00 MXN por
hora— y 50 altas de cuenta) y los actos que rechaza
el propio tope no cuentan, para que un bucle de reintentos no mantenga cerrada tu cuenta. Una
petición que falla más adelante —una referencia inválida, un producto que no existe— sí gastó su
lugar: el tope cuenta INTENTOS, no entregas. Tampoco cuenta un
reintento con la misma Idempotency-Key: es la repetición de un acto anterior, no
uno nuevo, y por eso nunca se frena.
El techo de monto solo puede aplicarse cuando la petición declara el importe. Con un
producto de denominación fija el cuerpo no lo lleva, así que ahí el acto cuenta por número: no lo
tomes como un límite de dinero. El Retry-After del 429 mide lo que falta para que se
libere el primer lugar de la ventana, no la ventana entera.
No todos los actos aplican a tu propia cuenta. Las recargas, los pagos de servicio y las altas
de cuenta se cuentan siempre; los comprobantes y las demás escrituras solo cuando actúas sobre una
cuenta que administras. La consola lo dice fila por fila (applies_to_own_account) y pinta
apagados los que no aplican, para que nadie configure un tope que jamás se va a evaluar.
Dónde se ajustan. Los de tu propia cuenta en
winal.com.mx/app → Seguridad → Topes de actividad; los de
cada cuenta que administras en Cuentas de clientes → Configurar → Topes de actividad. Las dos
pantallas viven detrás de la sesión de la consola, que se abre con contraseña y segundo factor: quien
te robe una API key no puede subirse el tope con ella, porque la API key no abre la consola. Cuando un tope frena algo, el dueño de la cuenta recibe además una alerta operativa
(account_activity.limit_reached) con el detalle.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
429 + Retry-After | account.activity_limit_reached | Esa cuenta pasó de su tope de actos en la ventana. El mensaje trae los números exactos (cuántos, en cuántos minutos, contra qué tope). Esa petición no se ejecutó. Sobre comprobantes eso significa que no se emitió ni se canceló nada. Sobre recargas y pagos de servicio, que no tienen reverso, significa exactamente eso y nada más: si venías reintentando una anterior, esa anterior puede haberse entregado. | Si de verdad tuvo un día así, sube o apaga su tope desde tu consola (Seguridad → Topes de actividad para la tuya, Cuentas de clientes → Configurar para la de un cliente) y reintenta con el mismo Idempotency-Key: un reintento con la misma llave no gasta cupo, no se frena y te devuelve el resultado del intento original. Nunca vuelvas a cobrar con una llave nueva para saltarte un 429 sobre una recarga. Si no reconoces esa actividad, rota la credencial de esa cuenta antes de subir nada. |
400 | activity_limit.invalid_act | El act no es uno de los que se cuentan. | Usa invoice.issue, invoice.cancel, recharge.purchase, service_payment.pay, service_debt.inquire, recharge.transfer, account.create o account.write. |
400 | activity_limit.invalid_window | window_minutes fuera de rango (1 minuto a 7 días). | Una ventana de un minuto sería un limitador de tráfico y una de meses no acotaría nada: la de fábrica es de 60 minutos. |
400 | activity_limit.invalid_max | Un tope encendido sin números, o con números fuera de rango. | Manda max_count y/o max_amount_minor. Si lo que quieres es no tener tope para ese acto, mándalo con enabled: false: apagarlo es explícito y queda en la bitácora, mientras que un tope sin números sería un hueco silencioso. |
400 | activity_limit.duplicate_act | El mismo act viene dos veces en la lista. | Cada acto lleva un solo tope: si mandas dos, no hay forma de saber cuál querías que rigiera. |
Autenticación, autorización y límite de solicitudes
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
401 | api_key.missing_or_invalid | Falta Authorization: Bearer sk_..., o la clave no existe / está mal formada / fue revocada. | Revisa el header; si la sospechas revocada, genera una nueva desde tu tablero (winal.com.mx/app → Llaves de API). |
403 | insufficient_scope | La API key autenticada no trae el scope que ese endpoint exige (p. ej. payouts:write, webhooks:manage). El mensaje nombra el scope faltante. | No hace falta emitir una llave nueva. Desde tu tablero (winal.com.mx/app → Llaves de API → Editar permisos sobre la llave que ya usas) puedes añadirle el scope que falta a la MISMA credencial — el cambio surte efecto de inmediato y no tienes que tocar el sistema que ya la usa. Ver Autenticación y seguridad → Scopes de la API key. |
429 + Retry-After: 60 | rate_limit_exceeded | 300 solicitudes/min por API key en /v1/*; 60/min por IP en /public/*. | Aplica backoff y agrupa reintentos; usa el mismo Idempotency-Key si reintentas una mutación. |
413 | request.body_too_large | El cuerpo de la solicitud pasa del tope (2 MiB en toda la API; las rutas que suben archivos tienen el suyo, más alto, y la ingesta de webhooks entrantes 256 KiB). | Manda solo lo que el endpoint pide. Si estás subiendo un archivo, usa la ruta que lo acepta (evidencia de contracargo, estado de cuenta bancario) en vez de incrustarlo en otro cuerpo. |
404 | webhook.endpoint_not_found | La URL de ingesta de webhooks entrantes (/webhooks/in/{proveedor}/{cuenta}) no corresponde a un proveedor y una cuenta que existan. | Copia la URL exacta que te da tu tablero al configurar el proveedor. La respuesta no distingue cuál de los dos falla, a propósito. |
Consola de plataforma (/admin) — solo staff de Winal
Esto no aplica a tu integración. /admin es la consola interna con la que Winal
opera a todos sus clientes; tu comercio se administra desde
tu tablero. Los cuatro códigos de abajo se documentan por una
sola razón: cada respuesta de error lleva un doc_url construido con su código, y sin
ancla ese enlace deja al operador en el tope de una página de cientos de filas justo cuando algo
acaba de fallarle. No describen nada que un comercio pueda alcanzar — /admin responde
404 en los hostnames públicos.
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
403 | admin.missing_portal_header | Escritura en /admin/* autenticada por cookie y sin la cabecera X-Winal-Portal. Es la protección anti-CSRF: SameSite=Strict se evalúa por sitio registrable, así que una página servida en un hermano de dominio manda la cookie igual, y hay rutas de /admin sin cuerpo alcanzables con un formulario autoenviado. | La consola la manda sola. Un script propio debe añadirla, o autenticarse con X-Admin-Key, que no la necesita. |
403 | admin.mfa_required | La sesión es de staff de plataforma y no tiene segundo factor activo. Se exige a toda sesión de plataforma sin mirar el rol: lo que la hace privilegiada es cruzar a todos los comercios, no poder escribir. | No es una puerta de un solo sentido: con esa misma sesión, activa tu 2FA en el portal (Mi cuenta) y vuelve. Si ni siquiera logras entrar, usa /portal-auth/mfa-enrollment/*. |
403 | portal.missing_portal_header | Lo mismo que el anterior, en las rutas de staff de /portal-auth (POST/DELETE /portal-auth/users), que acuñan y deshabilitan identidades de plataforma. | Igual que el anterior: la consola la manda sola. |
403 | platform.ip_not_allowed | Hay una lista de IPs configurada (Platform:IpAllowlist, apagada por defecto) y el origen no está en ella. Cubre /admin/*, /portal-auth/users* y /portal-auth/mfa-enrollment*; nunca el login de los comercios. | Operar desde una IP de la lista, o editarla y recrear el servicio. Ver docs/ops/consola-plataforma-en-internet.md. |
error.code (contiene
not_found → 404, unauthorized → 403, contiene
concurrent/conflict/not_confirmable/not_refundable/blocked_by_risk/revoked
→ 409, si no → 400) — no lee una categoría explícita del error de dominio. Por eso
refund.exceeds_refundable, refund.nothing_refundable,
attempt.not_capturable, api_key.already_rotated,
production.already_enabled y production.already_requested llegan
como 400 aunque conceptualmente son conflictos de estado (los últimos tres, pese
a nacer de Error.Conflict en el dominio, no contienen el texto
conflict ni revoked). Para tu lógica de reintento, confía siempre en
error.code, no en suposiciones sobre el HTTP status.