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. |
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 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. |
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.no_route | No hay conector configurado en el portal para ese método. | Configura el ruteo del método en el portal (o usa Sim mientras pruebas). |
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 | 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). |
404 | attempt.not_found | El intento referido no existe. | Usa un attempt_id devuelto por ?expand=attempts. |
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 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 | invoice.invalid_body | Falta payment_intent_id (POST /v1/invoices//payments) o total_minor positivo (POST /v1/invoices/ppd). | 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; usa XAXX010101000 para público en general. |
400 | invoice.invalid_currency | La currency de POST /v1/invoices/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 de PAC no configuradas o inválidas en el perfil fiscal del tenant, o el CFDI no pasó sus validaciones). | Revisa error_detail en el CFDI (GET /v1/invoices/{id}) y el perfil fiscal en el portal. |
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) contra una factura PPD que aún no timbró. | Consulta status antes de descargar; un REP solo aplica sobre una factura PPD ya stamped. |
404 | invoice.not_found | El id del CFDI no existe. | Usa el id devuelto al crear la factura. |
400 | invoice.receptor_incoherente | Un RFC genérico (XAXX010101000/XEXX010101000) sin la tercia que exige el SAT: nombre PÚBLICO EN GENERAL, regimen_fiscal 616 y uso_cfdi S01. El mensaje del error dice cuál de las tres falló. | Con RFC genérico, manda esa combinación exacta. Con un RFC real, elige el uso_cfdi que tu cliente pida — ver Facturación CFDI. |
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 modo del cobro no coincide con el ambiente del PAC configurado (p. ej. cobro de prueba contra PAC productivo). | Guarda de seguridad: nunca se timbra un CFDI real desde un cobro de prueba. Alinea la llave (sk_test_/sk_live_) con el ambiente del PAC en el perfil fiscal. |
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: se hace desde el portal (o PUT /admin/tenants/{id}/fiscal-profile). No es un error de tu código. |
400 | invoice.pac_credentials_missing | Hay perfil fiscal, pero sin credenciales del PAC para ese ambiente. | Siguiente paso tras el perfil fiscal: cargar usuario/contraseña del PAC (hoy Facturama) en el portal. |
400 | invoice.pac_credentials_incomplete | Las credenciales del PAC existen pero les falta usuario o contraseña. | Vuelve a capturar ambas en el portal. |
400 | invoice.pac_unreachable | No se pudo contactar al PAC (red o indisponibilidad del proveedor). | Transitorio: reintenta con backoff usando la misma Idempotency-Key — no se duplica el CFDI. |
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}. |
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.
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 desde el portal.
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. |
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. |
409 | 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 configurado 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. |
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. |
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. | Usa un RFC válido (o XAXX010101000 para público en general). |
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. |
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. |
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_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.transfer_not_found | El id del transfer no existe. | Verifica el id. |
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. |
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. |
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 | 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). |
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.not_found | El id no existe o es de otro tenant. | Usa el id devuelto al crear el pago. |
Errores de recharges
Catálogo completo (incluida la activación de operadoras y el catálogo de productos) en Recargas de tiempo aire. Aquí solo la guarda de producción:
| HTTP | code | Causa | Qué hacer |
|---|---|---|---|
400 | recharge.livemode_unsupported | La API key es sk_live_ (modo producción) y el único gateway conectado hoy solo simula la entrega — no hay agregador real conectado aún. | Integra y prueba con una llave sk_test_; el endpoint sigue bloqueado en producción hasta que se conecte un agregador real. |
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.
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; genera una clave nueva desde el portal si la sospechas revocada. |
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. | Emite una llave con ese scope (o con el comodín *, acceso total) desde el portal. 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. |
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 y
attempt.not_capturable llegan como 400 aunque conceptualmente son
conflictos de estado. Para tu lógica de reintento, confía siempre en error.code,
no en suposiciones sobre el HTTP status.