Recargas de tiempo aire

MODO PRUEBA

Vende tiempo aire y paquetes de datos de las operadoras mexicanas (Telcel, AT&T, Movistar, Unefon, Bait) contra TAECEL, un agregador PREFONDEADO: tu comercio deposita su saldo directo en TAECEL y Winal orquesta la entrega y contabiliza el consumo — nunca lo custodia (ADR-0001). El flujo son tres pasos: consulta el catálogo de operadoras, actívalas para tu comercio y consulta el catálogo de productos que eso desbloquea, y confirma la recarga a un celular. Por este mismo camino se cobran los recibos de luz, agua, telefonía y TV de paga (3.1) y se consulta su adeudo antes de cobrarlos (3.2): es la ruta REAL del pago de servicios. El módulo Pago de servicios es otra cosa —un simulador de fase 0, bloqueado en producción— y solo sirve para desarrollar.

⚠️ Una recarga ENTREGADA es irreversible

Textual de TAECEL: «bajo ninguna circunstancia se podrán realizar cancelaciones o reversos de solicitudes exitosas». No hay refund, no hay cancel, y no hay excepción por soporte. Si tu product_code o tu phone_number están mal, el saldo llega igual — a un número que no es el que querías — y no hay forma de recuperarlo. Valida en tu propio sistema ANTES de llamar a POST /v1/recharges: Winal valida todo lo que se puede validar (producto, formato del celular, operadora encendida, cuadre con un cobro enlazado) antes de tocar al proveedor, pero nada sustituye confirmar el número con tu cliente.

Modo prueba vs. producción: quién entrega la recarga

Con una llave sk_test_ y sin credenciales propias de TAECEL configuradas en tu tablero (sección Recargas), tus recargas las resuelve el simulador de Winal: responde al instante con un folio SIMRCG-… y no gasta saldo real — la API, el catálogo, las comisiones, la idempotencia y los asientos contables son reales y definitivos, así que integras y pruebas tu flujo completo sin arriesgar nada. En cuanto das de alta tu llave y NIP de TAECEL —de prueba o de producción—, Winal ruta tus recargas contra tu propia cuenta en TAECEL: con credenciales de prueba, contra la plataforma de pruebas de TAECEL (tampoco gasta saldo real); con credenciales de producción y una llave sk_live_, contra tu saldo REAL.

Esto se impone por código, no solo por convención: POST /v1/recharges con una llave sk_live_ se rechaza con 400 recharge.livemode_unsupported antes de reservar nada si el proveedor que atiende a tu cuenta no entrega saldo real (el simulador nunca lo hace). Da de alta tus credenciales de producción desde tu tablero para destrabarlo.

Cómo entrega Winal una recarga: dos fases, nunca una

Por dentro, cada recarga contra TAECEL se resuelve en dos llamadas separadas — RequestTXN para pedir la transacción y StatusTXN para resolverla con evidencia—, y esa separación es lo que hace posible no duplicar una entrega. Como integrador no llamas a ninguna de las dos: POST /v1/recharges hace ambas por ti y te devuelve el resultado que ya tiene, o processing si todavía no lo tiene.

Un timeout NO es un fracaso

Textual de TAECEL: «toda solicitud en status "En Proceso" genera un cargo y deberá realizar un request al método StatusTXN para corroborar el status final». Si la respuesta de TAECEL tarda o se corta, la transacción PUDO haber entrado igual —el saldo se pudo entregar sin que Winal se enterara a tiempo—, así que Winal nunca da por fallida una recarga por un timeout local. La deja en processing y la resuelve después con evidencia real del proveedor (consultando StatusTXN, o buscándola en su reporte de ventas si el proceso murió antes de guardar su identificador). Dar por fallida una recarga que sí entró y reintentarla significaría recargar DOS VECES el mismo teléfono — y eso no se puede deshacer.

Qué significa esto para tu integración: cuando POST /v1/recharges —o un GET posterior— te devuelve status: "processing", no la repitas ni la des por perdida. Vuelve a consultar GET /v1/recharges/{id} pasado un rato (unos segundos suele bastar; el peor caso documentado por TAECEL son 60 s) hasta ver delivered o failed. Hoy no hay webhook para el desenlace de una recarga (a diferencia de un payment_intent): el único camino es consultar. (Sí lo hay para el depósito que entra a una bolsa: el webhook recharge.deposit.detected.) La misma Idempotency-Key con la que la creaste nunca vuelve a pedir la transacción al proveedor —repetir la petición es exactamente la forma segura de "reintentar" mientras está processing.

1. Catálogo de operadoras

bash
curl -s https://api.winal.com.mx/v1/recharge-carriers \
  -H "Authorization: Bearer $SK"
200 · respuesta real
{
  "object": "list",
  "data": [
    { "object": "recharge_carrier", "code": "telcel", "name": "Telcel",
      "active": true, "category": "Tiempo Aire" },
    { "object": "recharge_carrier", "code": "att", "name": "AT&T",
      "active": true, "category": "Tiempo Aire" },
    { "object": "recharge_carrier", "code": "cfe", "name": "CFE (código de barras)",
      "active": true, "category": "Servicios" },
    { "object": "recharge_carrier", "code": "netflix", "name": "Netflix",
      "active": true, "category": "GiftCards" }
  ]
}

Es un catálogo global (sin variación por tenant) y se sincroniza solo con el del proveedor: hoy son 172 operadoras y 737 productos. Si una operadora aparece aquí, ya puedes vender sus productos: el paso 2 solo existe para apagar las que no quieras ofrecer.

category es la categoría del proveedor y viaja tal cual — es con la que él agrupa su propio catálogo, así que agrupar por ella te da la misma vista que el comercio ya conoce:

categoryQué traeOperadoras
ServiciosRecibos: luz, agua, TV de paga, telefonía fija. Monto libre.88
GiftCardsTarjetas de regalo de denominación fija.41
Tiempo AireRecargas de celular.35
PaquetesPaquetes de datos y minutos.8

logo_url trae el logotipo que publica el proveedor para esa operadora, y cada producto lo repite en carrier_logo_url para que puedas pintar tu pantalla de venta sin cruzar dos respuestas. Con 172 operadoras, un nombre en texto obliga a leer — y se lee despacio con un cliente enfrente.

Esa URL es del proveedor, no nuestra. Puede cambiar o dejar de responder sin aviso, así que guárdala en tu punto de venta en vez de pedirla en cada venta, y ten un respaldo: una imagen que no carga no puede impedir un cobro. Llega null en las operadoras para las que el proveedor no publica ninguna.

La categoría llega null solo si el proveedor no la declara. No la conviertas en un enum de tu lado: el proveedor puede añadir categorías y una lista cerrada rechazaría operadoras nuevas que ya podrías estar vendiendo. Agrupa por el texto y deja un grupo para lo que no reconozcas.

Tu tablero (sección Recargas) muestra este mismo catálogo agrupado igual, para que no tengas que llamar a este endpoint solo para verlo.

availability dice cómo se está comportando cada operadora ahora, para que no ofrezcas en el mostrador lo que va a fallar. Se calcula con las recargas terminales de la última hora en toda la plataforma —no solo las tuyas: una farmacia que vende dos al día no tendría muestra para enterarse de que una operadora lleva una hora rechazando— y en el ambiente de tu llave: lo que pase con recargas de prueba no puede apagarte una operadora en producción, ni al revés:

ValorQué pasóSugerencia
okSe está entregando con normalidad.Ofrécela.
degradedMás de la mitad de los intentos recientes fueron rechazados.Ofrécela con reservas, o baja su prioridad.
downTodos los intentos recientes fueron rechazados.No la ofrezcas hasta que vuelva.

Con poca muestra responde ok a propósito: no vender por una duda estadística cuesta más que un rechazo, que el cajero resuelve cobrando otra cosa. Y es una señal, no una garantía — una operadora en ok puede rechazar la siguiente.

2. Apaga las operadoras que no quieras vender

No hay nada que activar. Tu comercio vende todas las operadoras del catálogo desde el primer día; este endpoint existe para apagar las que no quieras ofrecer. Una operadora apagada desaparece de GET /v1/recharge-products y vender contra sus productos falla con recharge.carrier_not_enabled.

Antes era al revés —un opt-in que arrancaba vacío— y eso dejaba a un comercio con su bolsa conectada y con saldo real sin poder vender nada hasta activar operadora por operadora. El proveedor abre el catálogo completo; el candado lo ponía Winal sin ninguna razón.

bash
curl -s https://api.winal.com.mx/v1/recharge-carriers/telcel/enable \
  -H "Authorization: Bearer $SK" \
  -H "Content-Type: application/json" \
  -d '{"active": false}'
200 · respuesta real
{ "object": "recharge_carrier", "code": "telcel", "name": "Telcel", "active": false }

Manda { "active": true } (o un cuerpo vacío) para volver a venderla. Este endpoint no exige Idempotency-Key: es una operación de configuración, no una que mueva dinero. Desde el tablero es un botón por fila, sin llamar a nada.

3. Productos (montos) disponibles

GET /v1/recharge-products devuelve el catálogo completo —737 productos—, sin los de las operadoras que hayas apagado en el paso 2.

Dos formas de código conviven, y las dos son estables. Los productos históricos llevan un código legible (telcel_airtime_5000); los que el sincronizador descubre del proveedor llevan el código del proveedor como sufijo (att_att070). No es un detalle estético: dos productos distintos pueden compartir operadora, tipo y precio —el tiempo aire de $50 y un paquete de $50— y un código derivado del monto los colapsaría en uno solo. Trata el code como opaco: guárdalo tal cual y no lo construyas ni lo interpretes.

Cada producto trae además carrier_name y carrier_category, para que puedas agrupar tu pantalla de venta por operadora sin cruzar dos respuestas — el cliente dice «Telcel de cien», no «telcel_airtime_10000». Y kind distingue los cuatro flujos del mostrador: airtime (acredita un teléfono), data (paquete), service (recibo de monto libre) y giftcard (entrega un código).

El nombre viene ya legible del proveedor (AT&T $70 · 1.5 GB Navegación), y merchant_commission_minor llega en cero en los productos descubiertos: la comisión del comercio no la declara el proveedor, así que el único valor honesto es cero hasta que exista un acuerdo que la fije.

bash
curl -s https://api.winal.com.mx/v1/recharge-products \
  -H "Authorization: Bearer $SK"
200 · respuesta real (extracto)
{
  "object": "list",
  "data": [
    {
      "object": "recharge_product",
      "code": "telcel_airtime_1000",
      "carrier_code": "telcel",
      "kind": "airtime",
      "name": "Tiempo aire Telcel $10",
      "face_amount_minor": 1000,
      "merchant_commission_minor": 30,
      "currency": "MXN",
      "active": true
    },
    {
      "object": "recharge_product",
      "code": "telcel_airtime_2000",
      "carrier_code": "telcel",
      "kind": "airtime",
      "name": "Tiempo aire Telcel $20",
      "face_amount_minor": 2000,
      "merchant_commission_minor": 60,
      "currency": "MXN",
      "active": true,
      "open_amount": false
    },
    {
      "object": "recharge_product",
      "code": "att_att070",
      "carrier_code": "att",
      "kind": "airtime",
      "name": "AT&T $70 · 1.5 GB Navegación",
      "face_amount_minor": 7000,
      "merchant_commission_minor": 0,
      "currency": "MXN",
      "active": true,
      "open_amount": false
    },
    {
      "object": "recharge_product",
      "code": "cfe_bill",
      "carrier_code": "cfe",
      "kind": "service",
      "name": "CFE (código de barras)",
      "face_amount_minor": 0,
      "merchant_commission_minor": 0,
      "currency": "MXN",
      "active": true,
      "open_amount": true
    }
  ]
}
CampoQué es
face_amount_minorValor nominal de la recarga, en centavos: lo que recibe el celular del cliente y lo que le cobras al pagador.
merchant_commission_minorComisión que tu comercio se queda por vender esa recarga, en centavos — ya incluida dentro de face_amount_minor, no se suma aparte.
kindairtime (saldo abierto), data (paquete con vigencia) o service (pago de un recibo: luz, telefonía, TV de paga).
open_amounttrue = el importe lo pone quien paga y por tanto no está en el catálogo: face_amount_minor vale 0 y cada operación manda su amount_minor. Es el caso de todos los recibos. false = denominación fija del catálogo, y entonces mandar amount_minor se rechaza (recharge.amount_not_allowed).

El costo mayorista que tu comercio le adeuda a la operadora es face_amount_minor − merchant_commission_minor — no viene como campo aparte porque es aritmética exacta sobre los otros dos (ver la sección de contabilidad más abajo). product_code es un código PROPIO de Winal, no el de TAECEL: por dentro, Winal traduce cada código al del proveedor antes de pedir la transacción, así que tu integración nunca depende de la nomenclatura interna de TAECEL.

3.1 Pago de servicios: el mismo camino, con el importe del recibo

Un recibo de luz, de telefonía o de TV de paga se cobra por este mismo endpoint y con esta misma máquina de estados. No es una simplificación: un pago de servicio es tan irreversible como una recarga, lo entrega el mismo agregador con las mismas dos fases y por tanto necesita exactamente las mismas garantías de «una sola entrega». Tener dos caminos habría significado construir dos veces la protección que impide cobrar dos veces — y la segunda, sin ejercitarla nunca.

Lo único distinto es el importe: un recibo no tiene denominación, así que su producto lleva open_amount: true, face_amount_minor: 0 y cada operación manda amount_minor en centavos.

bash · pago de un recibo
curl -s https://api.winal.com.mx/v1/recharges \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "product_code": "sky_bill", "reference": "871235412635", "amount_minor": 9500 }'
Servicioproduct_codeReferencia que pide
CFE (código de barras)cfe_bill30 dígitos del código de barras
Telmextelmex_billNúmero telefónico, 10 dígitos
SKYsky_billNúmero de cuenta, 12 dígitos
Megacablemegacable_billNúmero de suscriptor, 10 dígitos
Dishdish_billReferencia de pago, 14 o 15 dígitos
Maxcommaxcom_billReferencia de pago, 7 dígitos

No copies esas longitudes a tu formulario. La regla exacta de cada operadora —etiqueta, longitud mínima y máxima, formato y si admite cero inicial— viaja en reference dentro de GET /v1/recharge-carriers, sincronizada del catálogo vivo del proveedor. Es la única fuente que no se desincroniza, y es además la que de verdad protege: el agregador declara esas reglas y no siempre las aplica al recibir (medido: aceptó y cobró un número con cero inicial en una operadora que lo prohíbe).

El cargo del proveedor sale de tu bolsa, además del importe. Un recibo de $95.00 con cargo de $5.00 deja tu saldo prefondeado en −$100.00, y así se asienta: el consumo real lo dice la evidencia del proveedor, no el catálogo. Si le cobras al cliente solo el importe del recibo, tu margen en esa operación es negativo por el cargo — y se registra como tal, sin esconderlo. Cobra tu comisión encima si no quieres absorberlo.

3.2 Consultar el adeudo antes de cobrarlo

POST /v1/service-debt-inquiries le pregunta al prestador cuánto debe una referencia, antes de cobrar nada. Es lo que evita que el cajero teclee el importe a ciegas: sin esto, la única fuente del monto es el papel que trae el cliente.

bash · consultar
curl -s https://api.winal.com.mx/v1/service-debt-inquiries \
  -H "Authorization: Bearer $SK" \
  -H "Content-Type: application/json" \
  -d '{ "product_code": "cfe_bill", "reference": "123456789012" }'
json · 200
{
  "object": "service_debt_inquiry",
  "id": "9c1f…",
  "status": "succeeded",
  "carrier_code": "cfe",
  "product_code": "cfe_bill",
  "reference": "12******9012",
  "amount_minor": 147500,
  "currency": "MXN",
  "provider_amount_raw": "1475",
  "due_at": "2026-09-15T00:00:00+00:00",
  "provider_message": null
}
⚠️ El adeudo es una FOTO, no un precio garantizado

Entre la consulta y el pago el importe puede cambiar: un abono en otra ventanilla, un recargo por mora, un corte de facturación. Winal no reserva ni congela nada. Por eso el cobro sigue declarando su propio amount_minor en POST /v1/recharges y ése es el que se paga — la consulta informa, no autoriza.

Enseña la cifra al cliente y vuelve a validarla al cobrar. Si tu pantalla muestra $1,475.00 y el cargo sale por otra cantidad, el reclamo es tuyo, no del prestador.

Solo algunos prestadores lo admiten, y el catálogo lo dice

La consulta la publica el proveedor por operadora, no por cuenta: hoy la admiten 8 de las 172 del catálogo de producción (CFE por código de barras y por número de servicio, Megacable, SKY, Star TV, Star Go, Fuller y Pagos Abonos ELEKTRA). El dato viaja en supports_balance_inquiry dentro de GET /v1/recharge-carriers — léelo y ofrece el botón de «consultar» solo donde valga true, en vez de dejar que el cajero descubra a mitad de una venta que esa operadora no lo ofrece.

Pedirlo donde no aplica responde service_debt.inquiry_not_supported y no se llama al proveedor. No es un fallo transitorio: para esas operadoras el recibo se cobra con el importe impreso, como siempre.

Puede tardar: 200 con el adeudo, 202 mientras no se sepa

Quien habla con el agregador es el Worker de Winal, no la API, así que la respuesta llega en un par de segundos. El POST los espera por ti (6 s por omisión; ajústalo con wait_ms, máximo 20 000, o pon 0 para no esperar). Si para entonces no llegó, responde 202 con status: "pending" y su id: léelo después con GET /v1/service-debt-inquiries/{id}.

Nunca recibes un 200 sin adeudo cuando el adeudo se desconoce. Los dos hechos —«esto es lo que debe» y «todavía no se sabe»— llegan con códigos de respuesta distintos a propósito: confundirlos termina en una venta cerrada sin cobrar.

Cuando el prestador no responde, la consulta se falla

Y esto es lo contrario de lo que hace una recarga. Ahí un timeout nunca es un fracaso (las dos fases): la transacción pudo entrar y repetirla recargaría dos veces el mismo teléfono. Una consulta no deja nada en vuelo —no crea transacción, no consume saldo—, así que un timeout se cierra como status: "failed" con failure_code: "service_debt.provider_unreachable" y reintentar es seguro. Es la única superficie de este módulo donde eso es cierto.

Límites

provider_amount_raw y por qué te lo publicamos. Es el importe tal como lo escribió el proveedor, sin interpretar. Winal lo lee como pesos —es la escala que usa esa API en todo lo demás—, pero el campo llega sin unidad declarada, así que la primera vez que integres compara este crudo con el recibo de papel del cliente. Si algún día no cuadran, ahí está el dato para verlo, en vez de descubrirlo por un cargo de cien veces la cifra correcta.

4. Vender una recarga

POST /v1/recharges ejecuta la recarga contra la operadora. Exige el header Idempotency-Key (UUID) — igual que toda operación que mueve dinero en Winal (ver Errores); el endpoint lo valida él mismo, con el mismo mensaje que el resto de la API.

bash
curl -s https://api.winal.com.mx/v1/recharges \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "product_code": "telcel_airtime_5000", "reference": "5512345678" }'
201 · respuesta real (simulador, resuelta al instante)
{
  "id": "029fc23d-bef6-477f-a969-8ad8f9c15f6e",
  "object": "recharge",
  "carrier_code": "telcel",
  "product_code": "telcel_airtime_5000",
  "phone_masked": "55****5678",
  "face_amount_minor": 5000,
  "merchant_commission_minor": 150,
  "currency": "MXN",
  "status": "delivered",
  "provider_ref": "SIMRCG-502685aa15034d6ebf935c8a4117432d",
  "payment_intent_id": null,
  "failure_reason": null,
  "created_at": "2026-08-20T04:11:44.083258+00:00",
  "updated_at": "2026-08-20T04:11:44.12255+00:00",
  "outcome": "delivered",
  "settled": true,
  "safe_to_retry": false,
  "needs_review": false,
  "provider_cost_minor": null,
  "client_reference": "winal029fc23dbef6477fa9698ad8f9c15f6e",
  "provider_transaction_id": null,
  "connector_key": "sim",
  "livemode": false,
  "requested_at": "2026-08-20T04:11:44.083258+00:00",
  "attempt_count": 1,
  "attempts": [
    { "id": "029fc23d-bef6-477f-a969-8ad8f9c15f6e", "attempt_no": 1, "connector_key": "sim",
      "status": "delivered", "provider_ref": "SIMRCG-502685aa15034d6ebf935c8a4117432d",
      "provider_transaction_id": null, "provider_cost_minor": null,
      "client_reference": "winal029fc23dbef6477fa9698ad8f9c15f6e",
      "requested_at": "2026-08-20T04:11:44.083258+00:00", "resolved_at": "2026-08-20T04:11:44.12255+00:00",
      "routing": null }
  ]
}
201 · respuesta real (TAECEL conectado, todavía sin resolver)
{
  "id": "8a3d0f61-2b4a-4b8e-9c11-0f4e6d2a7b19",
  "object": "recharge",
  "carrier_code": "telcel",
  "product_code": "telcel_airtime_5000",
  "phone_masked": "55****5678",
  "face_amount_minor": 5000,
  "merchant_commission_minor": 150,
  "currency": "MXN",
  "status": "processing",
  "provider_ref": null,
  "payment_intent_id": null,
  "failure_reason": null,
  "created_at": "2026-08-20T11:19:37.0Z",
  "updated_at": "2026-08-20T11:19:37.4Z",
  "outcome": "undetermined",
  "settled": false,
  "safe_to_retry": false,
  "needs_review": false,
  "provider_cost_minor": null,
  "client_reference": "winal8a3d0f612b4a4b8e9c110f4e6d2a7b19",
  "provider_transaction_id": null,
  "connector_key": "taecel",
  "livemode": true,
  "requested_at": "2026-08-20T11:19:37.0Z",
  "attempt_count": 1,
  "attempts": [
    { "id": "8a3d0f61-2b4a-4b8e-9c11-0f4e6d2a7b19", "attempt_no": 1, "connector_key": "taecel",
      "status": "processing", "provider_ref": null, "provider_transaction_id": null,
      "provider_cost_minor": null, "client_reference": "winal8a3d0f612b4a4b8e9c110f4e6d2a7b19",
      "requested_at": "2026-08-20T11:19:37.0Z", "resolved_at": null, "routing": null }
  ]
}

Nota el 201 Created en ambas respuestas: la recarga se creó de todas formas, esté ya resuelta o no. El código HTTP no te dice el desenlace — el campo status sí. Y hay un tercer caso que también llega como 201: si el propio TAECEL rechaza la transacción (una referencia que su catálogo no acepta, un límite de monto del carrier), la recarga se crea igual con status: "failed" y failure_reason con el motivo — nunca como un error HTTP, porque la recarga SÍ se llegó a reservar. Revisa siempre status, nunca solo el código HTTP.

El celular SIEMPRE se devuelve enmascarado
phone_number solo viaja completo en la solicitud; la API jamás lo regresa en claro. En toda respuesta y en cualquier listado verás phone_masked (p. ej. "55****5678": dos primeros dígitos + cuatro últimos), nunca el número de 10 dígitos completo.

Si ya cobraste al pagador el valor nominal con un payment_intent propio (p. ej. le cobraste por tarjeta en tu POS y ahora entregas la recarga con ese dinero), manda payment_intent_id para enlazar ambos registros. A diferencia de billers (donde ese campo es de correlación libre), aquí sí se valida: el intent debe pertenecer a tu tenant, estar en succeeded, y su monto debe coincidir exactamente con face_amount_minor del producto.

Consultar tu saldo

GET /v1/recharges/balance devuelve el saldo de la bolsa de la que sale tu operación, leído del agregador. Consúltalo antes de vender si quieres avisar en tu punto de venta antes de que una recarga falle por saldo.

bash
curl -s https://api.winal.com.mx/v1/recharges/balance \
  -H "Authorization: Bearer $SK"
200 · respuesta real
{
  "object": "recharge_balance",
  "provider_key": "taecel",
  "livemode": true,
  "uses_own_credentials": true,
  "low": false,
  "observed_at": "2026-08-22T03:11:15Z",
  "pouches": [
    { "pouch_id": "1", "name": "Tiempo Aire",       "balance_minor": 530000, "currency": "MXN",
      "observed_at": "2026-08-22T03:11:15Z" },
    { "pouch_id": "2", "name": "Pago de Servicios", "balance_minor": 0,      "currency": "MXN",
      "observed_at": "2026-08-22T03:11:15Z" }
  ]
}

Seis cosas que conviene leer antes de usarlo:

El ambiente sale de la LLAVE (sk_live_ o sk_test_), nunca del cuerpo. Con llave de prueba el saldo es del simulador y no corresponde a dinero real.

Vender por cuenta de un cliente tuyo

Si administras cuentas —un integrador con sus farmacias, cada una con su RFC— la venta va con dos credenciales: tu Authorization: Bearer dice quién actúa y Winal-Account-Key dice sobre quién. No viaja ningún identificador de cuenta, y esa es la idea: un identificador se equivoca al teclearlo, una credencial no.

bash
curl -s https://api.winal.com.mx/v1/recharges \
  -H "Authorization: Bearer $SK_DEL_INTEGRADOR" \
  -H "Winal-Account-Key: $KEY_DE_LA_FARMACIA" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"product_code": "telcel_airtime_5000", "reference": "5512345678"}'

Todo lo de esta página funciona igual con la segunda credencial: el catálogo, los topes, la contabilidad y el consumo se resuelven contra la cuenta efectiva —la farmacia—, no contra la tuya. De qué bolsa de saldo sale cada una se configura desde tu tablero, en Cuentas de clientes: pueden compartir una sola bolsa o tener la suya.

Los detalles del modelo —cómo se dan de alta, la lista de orígenes permitidos, la firma de petición y los actos que exigen segunda vía— están en Cuentas de clientes.

5. Consultar y listar recargas

bash
curl -s https://api.winal.com.mx/v1/recharges/029fc23d-bef6-477f-a969-8ad8f9c15f6e \
  -H "Authorization: Bearer $SK"

curl -s "https://api.winal.com.mx/v1/recharges?limit=50" \
  -H "Authorization: Bearer $SK"

Ambos devuelven el mismo shape que la creación (GET /v1/recharges es una lista envuelta en { "object": "list", "data": [...] }, más recientes primero — una entrada por pedido, con sus intentos dentro en attempts[]). ?limit= es opcional (default 100, máx. 500). Este GET es tu ÚNICA forma de saber si una recarga que quedó processing ya se resolvió — ver Cómo entrega Winal una recarga.

El desenlace: qué hacer después de cada respuesta

Toda respuesta de una recarga trae tres campos pensados para que no tengas que interpretar el estado. Responden la única pregunta que importa en un mostrador: ¿le devuelvo el dinero al cliente, lo intento por otra vía, o espero?

CampoValoresQué significa
outcomedeliveredLlegó al teléfono. Definitivo.
failedNo se entregó y no se va a entregar. Es seguro devolver el dinero o intentar por otra vía.
undeterminedNo se sabe. Ni se reintenta ni se devuelve dinero: el proveedor pudo haberla entregado. Casi siempre es temporal (la recarga sigue en curso); en un solo caso es un pedido ya cerrado que nadie pudo determinar, y ahí needs_review es true.
settledtrue/falseSi el desenlace ya no va a cambiar. Es true exactamente cuando outcome NO es undetermined.
safe_to_retrytrue/falseSolo es true con outcome: "failed". Programa if (!safe_to_retry) return; y con eso no vuelves a vender de más: un status: "failed" que Winal no pudo determinar trae safe_to_retry: false y te frena.
needs_reviewtrue/falseUna persona tiene que mirarlo. true en un solo caso: failure_code: "absent_from_report" — el agregador no contestó y la venta no aparece en su reporte, así que lo más probable es que no se registrara, pero no está probado. Comprueba en el portal del agregador antes de volver a vender. Winal sigue buscándola unos días y, si aparece, esa misma recarga cambia sola.

Un error de red NO es failed. Si tu petición se corta antes de recibir respuesta, no tienes ningún desenlace: consulta GET /v1/recharges/{id} con el mismo Idempotency-Key, o repite el POST con esa MISMA llave —te devuelve la recarga original, nunca una segunda—. Lo que jamás hay que hacer es mandar una llave nueva: eso sí crea una recarga nueva, y una entregada no tiene reverso.

Estados de una recharge

EstadoQué significaQué hacer
pendingReservada (Idempotency-Key, montos y rastro hacia el proveedor ya persistidos) pero nadie la ha pedido todavía — transitorio, dura milisegundos.Vuelve a consultarla en un momento.
processingYa se pidió al proveedor y todavía no hay evidencia del desenlace. No significa que vaya a fallar: es literalmente lo que dice TAECEL sobre una transacción "En Proceso". También es processing mientras Winal la surte por otro agregador tras un rechazo firme del primero (ver Cuando un agregador falla).No la repitas. Consulta GET /v1/recharges/{id} pasado un rato hasta ver un estado terminal.
deliveredLa operadora confirmó la entrega del tiempo aire; provider_ref trae su folio. Terminal e IRREVERSIBLE.Nada — la venta está cerrada. No existe un endpoint de reembolso para esto.
failedNo se entregó nada y no hubo cargo: rechazo antes de pedir (formato, catálogo), rechazo del carrier al procesar, o la solicitud nunca llegó a registrarse en el proveedor. failure_reason trae el motivo en español y failure_code la clase (not_requested, rejected, failed, absent_from_report, exhausted, unfunded; ver Cuando un agregador falla). Con exhausted, Winal ya la intentó por todos los agregadores que podían atenderla. Terminal, con UNA excepción: absent_from_report llega con needs_review: true y settled: false porque Winal no pudo determinar el desenlace y sigue buscándolo.Mira primero safe_to_retry. Si es true, corrige lo que indique failure_reason y vuelve a intentar con una Idempotency-Key NUEVA (la anterior sigue apuntando a la recarga fallida). Si es false —solo pasa con absent_from_report—, no vuelvas a vender todavía: comprueba en el portal del agregador.

Los cuatro estados son EXACTAMENTE los que existen — no hay un quinto "cancelado": cancelar una recarga entregada no es una operación que la API ofrezca, porque TAECEL no la admite. Tampoco hay un quinto estado para «se intentó por otro agregador»: eso lo cuenta attempts[], no status.

Cuando un agregador falla: qué hace Winal por dentro

Una recarga es un pedido tuyo, y Winal puede surtirlo por más de un agregador. Tú no eliges cuál ni te enteras del cambio: pides un product_code de Winal, y Winal decide por dónde va — y si el primero no puede, por dónde sigue. Cada intento contra un agregador queda registrado en attempts[], en orden, con su proveedor, su estado, su transacción y su costo real; attempt_count dice cuántos hubo. Los campos del proveedor que ya conocías (provider_ref, provider_transaction_id, provider_cost_minor, client_reference, connector_key, requested_at) son los del intento que entregó —o del último, si ninguno entregó—. Para un pedido de un solo intento, que es el caso normal, ves exactamente lo mismo que antes.

La regla que gobierna el respaldo, y por qué es la única posible. Solo pasa al siguiente agregador un fracaso del que Winal tiene CERTEZA: el agregador lo contestó, o consta que nada se le envió. Ni un timeout ni «no aparece en el reporte» la dan:

Qué pasó con el intentoattempts[].failure_codeQué hace Winal
Falló antes de enviar: el intento se reservó y nunca llegó a pedirse, o las credenciales de su bolsa ya no estaban al armar la petición. Es un estado de Winal, anterior a cualquier llamada.not_requestedEs un fracaso FIRME: abre el siguiente intento con otro agregador. Si pasó por conciliación, minutos después; tu GET lo verá aparecer en attempts[].
No aparece en el reporte de ventas del agregador pasado el umbral de abandono (30 min): lo más probable es que nunca se registrara y no hubiera cargo, pero no está probado.absent_from_reportCierra el intento Y el pedido (status: "failed", failure_code: "absent_from_report") sin abrir otro intento, y lo publica como lo que es: outcome: "undetermined", settled: false, safe_to_retry: false, needs_review: true. La ausencia en un reporte acotado por tiempo no prueba que no se entregó —el reporte se sella con el reloj del agregador, y una venta que sí entró puede caer fuera de la ventana—; pasar a otro agregador ahí serían dos recargas al mismo teléfono, y decirte que es seguro reintentar sería pedirte que las hagas tú. Comprueba en el portal del agregador y solo entonces vuelve a pedirla con una llave nueva. Y Winal no la abandona: sigue leyendo los reportes de los días siguientes (ver abajo).
El agregador contestó rechazo (en línea, sin procesar y sin cargo), o aceptó y al consultarlo reportó fracaso con evidencia.rejected / failedEs un fracaso FIRME: abre el siguiente intento con otro agregador — en la misma petición si el rechazo fue inmediato, así que tu POST ya vuelve con el resultado del segundo.
Timeout, 5xx o respuesta ilegible: la transacción PUDO entrar.— (el intento sigue processing)Jamás pasa a otro agregador. Textual de TAECEL: «toda solicitud En Proceso genera un cargo». Pasar a otro ahí serían dos recargas al mismo teléfono, y ninguna se devuelve. El intento se resuelve después con evidencia del proveedor; si esa evidencia es un fracaso firme, entonces —y solo entonces— sigue por otro.

Lo que no se pudo determinar se vuelve a mirar

Una recarga cerrada con failure_code: "absent_from_report" no es el final de la historia. Durante los tres días siguientes Winal vuelve a leer el reporte de ventas del agregador —cada pocas horas, y solo de lectura: nunca le vuelve a pedir la recarga— por si la venta aparece tarde. Si la encuentra:

Lo que dice el reporte tardíoCómo queda la recarga
Entregada. El agregador sí la surtió y su reporte la selló fuera de la ventana.Pasa a delivered sola, con su folio y su costo real, y Winal la contabiliza. Esa misma recarga —mismo id— cambia de status: por eso venía con settled: false. Si ya le habías devuelto el dinero a tu cliente, es el caso que needs_review te pedía revisar.
Fracasada. El agregador contestó: no hubo cargo.Sigue en failed, pero ya con certeza: failure_code pasa a failed, outcome a failed y safe_to_retry a true. Ahí sí, vuelve a venderla con una llave nueva.
Nada (no aparece en esos tres días).Se queda como está, con needs_review: true. Winal no va a afirmar lo que no sabe: la cierras tú, con el portal del agregador a la vista.

En la práctica esto es raro —hace falta que el agregador acepte la venta, no conteste y su reporte selle tarde—, pero cuando pasa es dinero de tu bolsa: es la diferencia entre enterarte y no.

Dos límites más, escritos en la estructura: nunca el mismo agregador dos veces en el mismo pedido, y un tope de tres intentos. Cuando tras un fracaso firme no queda ningún agregador elegible (ya se intentaron todos, o se llegó al tope), el pedido termina con status: "failed" y failure_code: "exhausted", con el motivo del último intento en failure_reason: ahí sí, corrige y vuelve a pedir con una llave nueva. Si había otra vía pero tu cuenta no pudo fondearla (cupo o saldo de esa bolsa), failure_code es unfunded y el mensaje dice cuál era la vía.

Qué tienes que hacer tú: nada. Sigue programando contra status, outcome y safe_to_retry como hasta ahora. safe_to_retry es false mientras Winal tenga un respaldo en curso, justo para que no reintentes por tu cuenta lo que Winal ya está reintentando. attempts[] es para tu conciliación: cada intento que llegó al agregador tiene su propia transacción en el reporte de ventas de ese agregador.

201 · respaldo por otro agregador en la misma petición (fragmento)
{
  "id": "5e0f2c1a-7b6d-4c3e-9a21-4f8e1d2b7c90", "object": "recharge",
  "status": "delivered", "outcome": "delivered", "settled": true, "safe_to_retry": false,
  "connector_key": "mtcenter", "provider_ref": "MTC-88213", "provider_cost_minor": 4850,
  "attempt_count": 2,
  "attempts": [
    { "id": "5e0f2c1a-7b6d-4c3e-9a21-4f8e1d2b7c90", "attempt_no": 1, "connector_key": "taecel",
      "status": "failed", "failure_code": "rejected",
      "failure_reason": "El operador rechazó la recarga: número no recargable.",
      "requested_at": "2026-09-20T15:02:11Z", "resolved_at": "2026-09-20T15:02:12Z",
      "routing": { "reason": "agregadores con credenciales en este ambiente, en orden de preferencia: taecel, mtcenter", "excluded": [] } },
    { "id": "1c9a4b77-0d2e-4f61-b3a5-9e7f6a5d4c21", "attempt_no": 2, "connector_key": "mtcenter",
      "status": "delivered", "provider_ref": "MTC-88213", "provider_transaction_id": "88213",
      "provider_cost_minor": 4850, "requested_at": "2026-09-20T15:02:12Z", "resolved_at": "2026-09-20T15:02:13Z",
      "routing": { "reason": "agregadores con credenciales en este ambiente, en orden de preferencia: mtcenter", "excluded": ["taecel"] } }
  ]
}

Cómo elige Winal el agregador: los filtros, en orden

Tú pides un product_code de Winal; Winal decide por dónde va. La decisión se toma en este orden —filtros primero, preferencias después—, y cada paso deja escrito su motivo en attempts[].routing, en español de mostrador. Una preferencia nunca elige a un agregador que no puede entregar.

  1. ¿Puede? Solo entran los agregadores con credenciales alcanzables para tu cuenta en ese ambiente (las tuyas, o las de la bolsa a la que tu cuenta apunta), que tienen ese producto dado de alta, y que entregan saldo real si la llave es sk_live_. El simulador de Winal atiende solo cuando no tienes credenciales de ningún agregador real: jamás es el respaldo de uno real.
  2. ¿Está sano? Winal lleva un cortacircuito por agregador y operadora (Telcel en TAECEL es una cosa; Telcel en MTCenter, otra), con tres estados: cerrado (sano), abierto (fuera del ruteo mientras haya otro) y medio-abierto (pasa una sola recarga de sonda; si entrega, se cierra; si falla, se reabre). Se abre con 3 intentos distintos con fallo del agregador en 10 minutos, siempre que sean al menos 1 de cada 4; se queda abierto 5 minutos. Qué cuenta como fallo del agregador: que no conteste (timeout, 5xx, respuesta ilegible), que no reconozca su propia transacción, o que reporte un fracaso por causa suya (sin enlace con la operadora). Qué NO cuenta: un rechazo por el dato —número inválido, monto fuera de rango, línea suspendida, producto desconocido—; cien de ésos no apagan a un agregador sano. Un cortacircuito abierto nunca deja a tu cuenta sin vender: si es el único agregador que puede, se intenta igual y el motivo lo dice.
  3. ¿Le alcanza el saldo a la bolsa? Con la última lectura de saldo de cada bolsa, lo ya comprometido que el agregador todavía no cobra y los depósitos que declaraste después —la misma cuenta que hace el techo de la bolsa— Winal comprueba que quepa el costo con margen (10 % del costo, mínimo $5.00, porque el agregador cobra importe más cargo en denominaciones chicas y su lectura de saldo va retrasada). La bolsa que no alcanza no recibe el pedido si otra sí. Sin lectura de saldo, la bolsa entra solo si ninguna otra tiene saldo conocido suficiente.
  4. Entre los que quedan, tu prioridad (fijada por operadora, o para todas, en tu tablero → Recargas → Agregadores). Si no la fijaste, gana el más barato de los sanos, medido con el único dato real que existe: el último costo que cada agregador te cobró por ese producto (provider_cost_minor). Si a alguno todavía no se le conoce el costo, Winal no compara nada —comparar un costo medido contra uno inventado es inventar— y desempata por orden de alta de la bolsa, diciéndolo en el motivo.

La advertencia de las dos bolsas. Con dos agregadores tienes dos saldos que no se mezclan: Winal no mueve dinero entre ellos. Si TAECEL tiene $8,000 y MTCenter $0, MTCenter no entra, aunque lo hayas puesto primero en tu prioridad — y tu tablero te lo dice con esas cifras («¿Por dónde iría ahora?»). Si quieres que un agregador reciba pedidos, deposítale saldo a ÉL. Winal tampoco cambia de agregador a media operación: el intento queda sellado con el suyo.

attempts[].routing con los veredictos (fragmento)
"routing": {
  "reason": "'mtcenter' para el intento 1: 'mtcenter' primero: sano; saldo suficiente: $6,430.00 disponibles para $194.00; más barato de los sanos: $193.20 frente a $194.50 de 'taecel'; 'taecel' no entra: cortacircuito abierto hasta 21:32 UTC por 3 fallos del agregador para 'telcel' en 10 min",
  "excluded": [],
  "evaluated": [
    { "provider_key": "taecel", "eligible": false, "reason": "cortacircuito abierto hasta 21:32 UTC por 3 fallos del agregador para 'telcel' en 10 min" },
    { "provider_key": "mtcenter", "eligible": true, "reason": "sano; saldo suficiente: $6,430.00 disponibles para $194.00; más barato de los sanos: $193.20 frente a $194.50 de 'taecel'" }
  ]
}

Idempotencia

Repetir la misma Idempotency-Key con el mismo product_code, la misma referencia, el mismo amount_minor y el mismo payment_intent_id devuelve el mismo pedido tal como está ahora, con todos sus intentos (sin volver a pedir nada a ningún proveedor) — si sigue processing, la respuesta también es processing; el reintento no la resuelve más rápido, solo es seguro repetirlo. Con datos distintos bajo la misma llave, 409 recharge.idempotency_conflict — y el importe cuenta como dato: repetir la llave cambiando amount_minor es un conflicto, no una repetición, porque si no creerías haber pagado el recibo de $900 cuando el que salió fue el de $90.

Y esa llave es de tu ambiente: se identifica por (tu cuenta, el ambiente de tu credencial, la Idempotency-Key), no solo por la llave. Tu sk_test_ y tu sk_live_ no comparten espacio: si derivas la llave del folio de tu ticket —lo recomendable— y probaste en sandbox con los mismos folios con los que vendes de verdad, tu primera venta real sí se procesa — la misma llave con tu credencial de producción es una recarga NUEVA, nunca la repetición de la que hiciste en pruebas ni al revés. Antes de esto, la credencial productiva podía recibir REPETIDA la respuesta del sandbox (delivered con el folio del simulador) sin que un peso llegara al teléfono — sobre la única operación de esta página que no tiene reverso.

Por dentro, Winal también manda una referencia propia (refCte) a TAECEL en cada solicitud, visible en el portal de TAECEL para rastrear la transacción. No confíes en ella para deduplicar nada de tu lado: se midió llamando a la API que TAECEL la acepta y la guarda, pero igual procesa una segunda transacción si se la mandas dos veces — la garantía de "nunca dos veces" la da la máquina de estados de Winal (tu Idempotency-Key), no esa referencia.

El permiso que exige una recarga

POST /v1/recharges y POST /v1/service-payments exigen que la llave lleve recharges:write. No se emite por omisión y solo el dueño de la cuenta puede pedir una llave con él — el mismo criterio que payouts:write, y por la misma razón: es dinero que sale y no vuelve. Una llave que no lo lleva recibe:

403 · insufficient_scope
{
  "error": {
    "type": "authorization_error",
    "code": "insufficient_scope",
    "message": "Esta credencial no tiene el permiso 'recharges:write'."
  }
}

Se pide al emitir la llave, en winal.com.mx/app → Llaves de API. Una llave que ya existe también puede ganarlo: Editar permisos sobre la misma credencial, sin rotarla ni tocar tu sistema — no hace falta redistribuir una llave nueva solo para vender tiempo aire desde el punto de venta que ya tienes en producción. Lo que NO es automático es la decisión: añadir recharges:write —a una llave nueva o a una que ya existe— pide contraseña y segundo factor, y solo el dueño de la cuenta puede hacerlo. Es a propósito — que una llave viva pueda ganar poder sobre dinero irreversible sin que nadie lo decida sería justo lo que este permiso viene a impedir.

Dáselo a la llave que vende, no a todas
Si emites una llave por integración (un POS, un reporteador, una app de consulta), solo la que de verdad vende tiempo aire necesita este permiso. Las demás siguen funcionando exactamente igual sin él, y si una se filtra no puede gastar tu saldo.

Topes: el freno que tú ajustas

Una recarga es el único dinero de la plataforma que no se puede devolver: una factura se cancela y una cuenta se revoca, pero el saldo que ya entró a un teléfono ajeno no vuelve. Por eso cada cuenta trae un tope de recargas y otro de pagos de servicio por ventana de tiempo, y a diferencia del resto de los actos se cuentan siempre —también cuando operas con tu propia llave sobre tu propia cuenta—, que es exactamente el caso del comercio al que le roban una credencial.

De fábrica: 200 recargas por hora, 100 pagos de servicio por hora, 600 consultas de adeudo por hora y 20 traspasos de saldo por hora (éste con techo de monto de fábrica además del de número: $2,000,000.00 MXN por hora, porque un traspaso siempre declara cuánto mueve). Están por encima del mejor día de un mostrador y frenan en seco un bucle. La consulta tiene tope PROPIO —y más alto que el del pago que la sigue— para que consultar no te gaste el cupo de vender: un cajero consulta varias veces por venta, y hay quien consulta sin llegar a pagar. Los actos que rechaza el propio tope no cuentan para la ventana, así que un reintento no te mantiene la cuenta cerrada. Una petición que falla más adelante (referencia inválida, producto inexistente) sí gastó su lugar: el tope cuenta intentos.

Ajústalos a lo que de verdad vendes desde winal.com.mx/app → Seguridad → Topes de actividad (los tuyos) o Cuentas de clientes → Configurar → Topes de actividad (los de cada cliente que administras). Se cambian desde la consola, que se abre con contraseña y segundo factor, nunca con la API key: si la misma credencial que el tope acota pudiera levantarlo, el tope no serviría de nada.

Sobre el techo de monto, sin adornos: solo puede aplicarse cuando la petición declara el importe (amount_minor), y con un producto de denominación fija el cuerpo no lo lleva —la API lo rechaza— así que ahí el acto cuenta únicamente por número. No lo tomes como un límite de dinero: los límites de dinero son otros dos, y están más abajo (Los dos techos del dinero). El tope de actividad es el freno de ritmo, que es lo que aquéllos no dan.

Cuando un tope frena algo, la respuesta es 429 con account.activity_limit_reached, los números exactos (cuántos actos, en cuántos minutos, contra qué tope) y un Retry-After que mide lo que falta para que se libere el primer lugar, no la ventana entera.

Esa petición no se ejecutó — y eso no es lo mismo que decir que no pasó nada. Si venías reintentando una recarga anterior, repite con la MISMA Idempotency-Key: un reintento así no gasta cupo, no se frena nunca y te devuelve la recarga original. Lo que no debes hacer es cobrar de nuevo con una llave nueva; eso sí sería una segunda recarga, y no tiene reverso. Ver Errores → Topes de actividad.

Los dos techos del dinero: el cupo y el saldo

El tope de actividad frena el ritmo. El dinero lo frenan otras dos cosas, y conviene no confundirlas porque se levantan de formas distintas.

TechoQué acotaCuándo saltaCómo se levanta
El cupo
recharge.allowance_exhausted
Tu parte de una bolsa que es de otro (la del integrador, la del grupo). Cuando ya gastaste todo lo que te asignaron. Hay dinero en la bolsa; no es tuyo. Quien administra la cuenta le acredita un depósito más (Recargas → la cuenta → Asignar depósito). El saldo de las demás cuentas no se toca.
El saldo
recharge.bag_balance_insufficient
El bolsillo de la bolsa contra el que consume ese producto: el dinero que de verdad hay en el agregador para ESE tipo de venta. Cuando ese bolsillo no da para esa recarga. Depositando en el agregador. Si repartes la bolsa, además regístralo en Recargas → Depósitos para que cuente de inmediato.
La duda
recharge.bag_balance_unknown
Lo mismo, cuando Winal todavía no ha podido leer ese saldo. En producción, si la bolsa no está dada de alta o nunca se ha leído su saldo. En modo prueba no aplica. Dando de alta la bolsa en Recargas → Bolsas (se verifica al guardarla y su saldo aparece en segundos). El propio rechazo vuelve a pedir esa lectura: reintenta con la misma Idempotency-Key.

Cada vínculo tiene uno de los dos, siempre. Si consumes una bolsa que no es tuya, tu cuenta nace con cupo y no puede gastar hasta que su dueño te asigne saldo: es lo que impide que una sola cuenta se lleve el de todas las que comparten la bolsa. Y ese cupo no se puede apagar mientras la bolsa sea ajena (recharge.allowance_cap_required). Si la bolsa es tuya no hay nada que repartir, y tu techo es directamente su saldo.

Cómo calcula Winal el saldo, dicho sin adornos. No llama al agregador en cada venta: usa la última lectura de su saldo, le resta todo lo comprometido que el agregador todavía no ha cobrado de esa cifra y le suma los depósitos que hayas registrado con fecha posterior. «Comprometido» son las tres cosas: lo entregado, lo que está en vuelo —que el agregador ya cobró aunque no conste que llegó— y lo que aceptamos y todavía no hemos pedido. Esto último importa más de lo que parece: la API no habla con el agregador (lo hace el Worker, segundos después), así que toda recarga pasa por ese estado, y no contarla dejaba pasar tantas ventas como cupieran entre dos lecturas del saldo. Y una recarga deja de restar cuando llega una lectura posterior a su desenlace, que es la primera que puede traerla ya descontada: una lectura nueva no borra lo que sigue pendiente, por muy vieja que sea la venta. El mensaje del rechazo trae las cifras y la hora de la lectura, para que se pueda comprobar. Es una estimación deliberadamente conservadora, y el rechazo pide una relectura del saldo: si acabas de fondear, reintenta en unos segundos con la misma Idempotency-Key.

El saldo se compara por BOLSILLO, no por bolsa. Una cuenta del agregador tiene varios bolsillos con saldo propio que él no mezcla —tiempo aire, pago de servicios, timbres— y cada producto consume del suyo. Winal compara contra el del producto que estás vendiendo: tener $5,000 en pago de servicios no te deja vender tiempo aire si esa bolsa está en cero. A qué bolsillo pertenece cada producto lo dice el catálogo del proveedor, no una regla nuestra; mientras un producto no lo declare, se compara contra la bolsa entera.

Si Winal nunca ha podido leer el saldo de tu bolsa, en producción no se vende (recharge.bag_balance_unknown). Antes se dejaba pasar, con el argumento de que el techo de verdad era el cupo; eso es falso justo donde esta guarda es el único techo: el vínculo contra tu propia bolsa no lleva cupo por diseño, y ahí «no sé cuánto hay» era literalmente «sin techo». No es un callejón: dar de alta la bolsa la verifica y escribe su saldo en segundos, y el propio rechazo vuelve a pedir esa lectura. En modo prueba no aplica: ahí el saldo no es dinero.

Tres formas de manejar el saldo de una bolsa

Si administras cuentas de clientes, el saldo de recargas se puede organizar de tres maneras. Las tres salen del mismo mecanismo —la bolsa y el vínculo— y lo único que cambia entre ellas es quién acredita el cupo de cada cliente. Ninguna cambia quién puede gastar: eso lo decide el cupo, y sigue donde estaba.

ModoCómo entra el dineroQuién acreditaCuándo conviene
Global, manual
global_manual
Tus clientes depositan todos a tu cuenta del agregador, sin distinguirse. Tú, mirando el comprobante que te manda cada uno (Recargas → la cuenta → Asignar depósito). Es el valor por omisión y funciona desde siempre. Con pocos clientes, es suficiente.
Global, por referencia
global_by_reference
Igual: todos depositan a la misma cuenta, pero cada uno con su propia referencia, que el agregador te devuelve en el movimiento. Winal: reconoce la referencia y propone (o acredita) el cupo del cliente dueño. Con decenas de clientes. Es lo que convierte «entraron $50,000» en «la farmacia de Tlalpan depositó $5,000», sin abrirle una cuenta a cada una ante el proveedor.
Una bolsa por cliente
per_account
Cada cliente tiene su propia cuenta en el agregador y deposita a la suya, con su referencia, y reporta su depósito en el formulario del agregador. Nadie: no hay nada que repartir. Su techo es su saldo real. Con TAECEL es el camino nativo: Winal le abre esa cuenta a cada cliente desde su ficha (ver La cuenta propia de cada farmacia). La bolsa que nace así ya está en este modo.

El modo se elige por bolsa en Recargas → Depósitos de tus clientes → Modo de saldo de cada bolsa. Cambiarlo no mueve un peso ni toca los cupos ya asignados.

Con TAECEL, el camino nativo es la cuenta propia de cada farmacia. El agregador ya resuelve por su lado lo que los dos modos «globales» construyen por fuera: con su alta de cuentas, cada farmacia tiene su referencia, su formulario para reportar el depósito y su saldo, y nadie tiene que adivinar de quién es un depósito ni repartir cupo. Los modos globales siguen existiendo —para un agregador que no abre cuentas, o si prefieres operar una sola bolsa— y no cambian en nada.

La referencia de depósito

Es el código que tu cliente escribe en su ficha y que el agregador te devuelve en el movimiento. En Winal la das de alta por cliente y por bolsa, en la misma pantalla. Reglas:

El modo sombra, y por qué existe

Winal empieza PROPONIENDO, no acreditando. Una bolsa en modo «por referencia» nace con la acreditación automática apagada: el sistema reconoce el depósito, te dice de quién cree que es y en qué campo del movimiento encontró la referencia, y tú confirmas con un clic. El motivo es un hecho y no una precaución: el contrato del agregador publica un solo ejemplo de su reporte de movimientos, y en qué campo viaja la referencia de un depósito real todavía no está medido. Acreditar sobre una suposición regala saldo que se gasta en recargas que no se devuelven; proponer sobre una suposición cuesta un clic. Cuando tus primeros depósitos reales confirmen el campo —la bandeja te lo dice por cada uno—, enciendes la acreditación automática y dejas de confirmar.

Encender la acreditación automática pide contraseña y segundo factor: es el único estado en el que el cupo de un cliente sube sin que nadie mire. Apagarla no los pide, a propósito: poner un trámite en el camino de salida de un control consigue que nadie lo use.

La bandeja: propuestos, sin dueño y ambiguos

Todo abono que entra a una bolsa en modo «por referencia» acaba en la bandeja (Recargas → Depósitos de tus clientes) con uno de estos desenlaces. Nunca se acredita ante la duda, y las dos dudas son distintas:

EstadoQué pasóQué hacer
Propuesto Casó exactamente una referencia. El importe que se acreditaría es el del abono, tal como lo reportó el agregador; tu comisión se aplica encima, igual que en el camino manual. Confirmar (pide contraseña y segundo factor). Con la acreditación automática encendida no aparece: se acredita solo.
Sin dueño Ninguna referencia vigente casó. Entró dinero y el sistema no sabe de quién es, así que no le subió el cupo a nadie: quien depositó no puede vender. Dar de alta la referencia que falta —el depósito que ya llegó se reevalúa solo en el siguiente barrido— o Asignar el depósito a mano (el mismo formulario crea la referencia, si quieres, para que los siguientes se reconozcan solos).
Ambiguo Casaron las referencias de dos clientes distintos en el mismo movimiento. Winal no elige: elegir sería regalarle el dinero de un cliente a otro. No puede ser «la misma referencia dada de alta para dos clientes» —eso lo impide el índice único de la bolsa—: es un dato del movimiento en el que casan dos etiquetas distintas. Mira la columna Campo (dice con qué valor casó) y Asigna el depósito a mano. Si una de las dos referencias ya no va, archívala: el depósito se reevalúa solo en el siguiente barrido.

Un depósito no se acredita dos veces, nunca. El reporte del agregador se relee cada ciclo (el día de ayer y el de hoy), así que el mismo abono se ve muchas veces; lo que lo impide es que cada movimiento tiene una sola fila de atribución, en la base, incluida la de los que rechazaste —un rechazo que no se recuerda sería un bucle—. Y cuando el abono se acredita, el depósito que se crea lleva por comprobante el propio movimiento (mov:<agregador>:<id>), que es la segunda barrera contra lo mismo.

Un importe que no se puede leer no se propone. Si el agregador reporta el monto de un abono de una forma que Winal no puede interpretar como dinero, el depósito llega igual a la bandeja —con el texto crudo a la vista— en vez de desaparecer o convertirse en cero. La salida es Asignar declarando el importe: el formulario pide el monto solo para esas filas, y por API es amount_minor en el cuerpo de POST /app/api/recharge-deposit-attributions/{id}/assign. Sobre un abono cuyo importe sí se leyó, ese campo se rechaza (recharge.attribution_amount_not_editable): Winal transcribe el abono del agregador, y lo que se elige al asignar es de quién es el depósito, no cuánto entró. El importe que pone una persona se guarda aparte del que reportó el agregador — el hecho no se reescribe, se resuelve.

Un abono de MODO PRUEBA nunca acredita cupo (recharge.deposit_test_movement). Sale en la bandeja marcado Prueba —sirve para comprobar que el casador reconoce tu referencia sin estrenarlo con dinero— y ahí se queda: el cupo de recargas no distingue ambiente, así que acreditarlo daría saldo real, gastable en recargas que no se devuelven. La consola no ofrece el botón para esas filas; sí se pueden rechazar para limpiar la bandeja.

Y no se acredita contra una bolsa cuyo saldo Winal nunca leyó (recharge.deposit_bag_balance_unknown). Sin una lectura del agregador no hay contra qué comprobar que el dinero exista, y «sin cifra no hay techo» es exactamente cómo se reparte saldo que no está: el propio rechazo vuelve a pedir la lectura, que llega en segundos.

La bandeja vacía te dice de cuál de las dos cosas se trata. «No llegó ningún depósito» y «todavía no hemos leído un solo movimiento de esa bolsa» se ven idénticos y significan lo contrario, así que la pantalla avisa cuando es lo segundo. Si acabas de dar de alta la bolsa, es normal unos minutos; si lleva horas, revisa sus credenciales.

Sin custodia, como todo lo demás. El dinero está en el agregador, a tu nombre; lo que Winal escribe es de quién es cada peso de esa bolsa. Es una obligación tuya con tu cliente, no de Winal con nadie.

La cuenta propia de cada farmacia en el agregador

Si administras cuentas de clientes, puedes darle a cada una su propia cuenta en el agregador (TAECEL: RegistroCuenta), dentro de tu red de distribuidor, sin salir de tu consola: Cuentas de clientes → la cuenta → Su cuenta en el agregador. Con ella, la farmacia:

Pedir el alta

Datos del titular —nombre, apellidos, correo y teléfono de 10 dígitos— y, opcional, el nombre comercial. La pide el dueño de tu cuenta con contraseña y segundo factor: crea una cuenta en un tercero a nombre de otra persona. Queda en la bitácora de la cuenta del cliente (con el teléfono enmascarado; el titular jamás se escribe en claro en ningún registro). Nunca es automática y lleva un tope de 30 altas por hora por distribuidor (recharge_subaccount.rate_limited), para que una sesión robada no pueda sembrar cuentas en masa.

El alta la ejecuta Winal en segundos. Si el agregador contesta con las credenciales de la cuenta nueva, se guardan cifradas —nadie las vuelve a ver, ni tú— como la bolsa propia de la farmacia; se verifica su saldo y queda created con su referencia y su enlace de reporte. Si la farmacia no vendía de ninguna bolsa, queda vendiendo de la suya; si vendía de tu bolsa compartida, sigue ahí hasta que pulses «Que venda de su cuenta» (una cuenta recién creada nace en cero).

Si el alta queda sin desenlace

Un timeout NO es un fracaso — la regla 5 aplicada a crear una cuenta
Crear una cuenta en el agregador no es idempotente —no hay llave que evite una segunda— y sus credenciales viajan una sola vez, en la respuesta. Si la llamada no trae respuesta (se agotó el tiempo, se cortó la red, llegó algo ilegible), la cuenta pudo haberse creado. Por eso Winal la deja indeterminate —«sin desenlace»—, nunca «falló», y jamás la vuelve a pedir sola: darla por fallida invitaría a pedirla otra vez, o con otro teléfono, y la farmacia quedaría con dos cuentas.

Sus salidas están en la ficha, y todas existen de verdad:

  1. Registrar sus credenciales: si al titular le llegó el correo del agregador con su llave y su NIP, regístralas en la ficha. Resuelven la misma alta —no crean otra—, se guardan cifradas y la cuenta queda created; Winal lee su enlace de reporte con esas credenciales. Tienen que ser de esa cuenta: con otro teléfono se rechaza (recharge_subaccount.phone_mismatch_conflict), y con otro número de cuenta que el que dio el agregador, también (recharge_subaccount.provider_account_mismatch_conflict).
  2. Reintentar con los mismos datos: si no le llegó nada, reintenta. El reintento no recibe datos —copia el teléfono y el titular del alta original—, así que no puede ir con otro teléfono ni por error. Si la primera llamada sí creó la cuenta, el agregador contesta que el teléfono ya existe (recharge_subaccount.phone_taken): casi seguro es la prueba de que la cuenta existe, y la salida es la primera.
  3. Darla por no creada: solo si el agregador te confirma (su portal de distribuidor o su soporte) que no existe ninguna cuenta con ese teléfono en tu red — por ejemplo, porque el teléfono estaba mal y es de otra persona. Lo afirma el dueño con escalón y queda en la bitácora; es la única forma de volver a pedirla con otro teléfono (recharge_subaccount.confirmed_not_created).
La regla es sobre la CADENA, y la impone la base
Mientras cualquier intento de la cadena de una farmacia pudo haber creado su cuenta, nada deja pedirla con otro teléfono: tampoco que el reintento termine con otro rechazo (sin comisión, un dato que el agregador objetó, tus credenciales de distribuidor que ya no están). Ese rechazo dice que ese intento no creó nada, no que el primero tampoco — así que la ficha sigue indeterminate, con el motivo del reintento y las mismas salidas. Un reintento frenado por el tope de altas no toca nada: el alta sigue sin desenlace y se reintenta al liberarse.

Un alta que se queda en cola (nadie la atendió en un par de minutos) muestra cuánto lleva así y ofrece volver a encolarla, cancelarla —nadie llamó al agregador, así que no se creó nada— o registrar las credenciales de la cuenta que la farmacia ya tuviera.

Si el agregador dijo que la creó pero no devolvió las credenciales, queda awaiting_credentials: se sabe que existe, y la única salida es registrarlas cuando le lleguen al titular. Y si la farmacia ya tenía su cuenta en el agregador antes de Winal, no hace falta pedir nada: registra sus credenciales directamente — sirve también con un agregador que no abre cuentas por API.

En tu punto de venta

GET /v1/recharge-subaccounts con la credencial de la farmacia (Winal-Account-Key) devuelve su referencia de depósito, su enlace de reporte y su número de cuenta en el agregador — de esa farmacia y de ninguna otra. Es de solo lectura y nunca trae credenciales. Contrato en Referencia de API → La cuenta propia de cada cliente. El enlace de reporte, el aviso de que el depósito entró (recharge.deposit.detected) y el saldo al momento están en Referencia de API → Depósitos.

Si tu cuenta administra cuentas de clientes, las guías de operación están en tu consola, en Guías.

Fondearla desde tu bolsa

Un traspaso con destination_account = la farmacia llega a su cuenta del agregador: Winal toma su número de cuenta del alta (cuentaID). El cuadre de la bolsa de la farmacia ve ese traspaso como dinero que entró —aunque lo ordenaste tú— y el conciliador lo cierra con los movimientos de su cuenta. El traspaso llega a su cuenta propia aunque todavía venda de tu bolsa compartida: así se fondea antes de pasarla a vender de ella. Si la registraste a mano sin su número de cuenta, ponlo en la ficha: sin él no hay a quién mandarle el saldo.

El número de cuenta decide a dónde llega un traspaso, y un traspaso no se deshace. Por eso solo el dueño de tu cuenta lo fija o lo corrige, con escalón; el que devolvió el agregador no se pisa en silencio (la ficha dice quién lo cambió, cuándo y desde qué valor), y una misma cuenta del agregador no puede estar en dos bolsas vivas de tu red (recharge_subaccount.provider_account_conflict).

Lo que todavía no está medido, dicho como tal. Que el clienteID que TAECEL espera en un traspaso sea el cuentaID que devuelve su alta de cuentas es una lectura de su documentación, no una medición: la cuenta de pruebas con la que se mide el traspaso aún no tiene una segunda cuenta a la cual mandarle saldo. Hasta medirlo, un traspaso que TAECEL rechace por cuenta destino desconocida lo hace sin mover nada (recharge_transfer.provider_rejected).

Contabilidad: sin custodia, suma cero

Winal nunca retiene el dinero de una recarga (ADR-0001, sin custodia). TAECEL es un agregador PREFONDEADO: tu comercio deposita su saldo directo en TAECEL y una recarga simplemente consume ese saldo ya existente — no es un pasivo que Winal deba saldar después. Al confirmarse la entrega, el valor nominal cobrado al pagador se reparte por partida doble entre el consumo de ese saldo y la comisión que gana el comercio. Una recarga de $50.00 con comisión de $1.50 genera este asiento, exacto al centavo:

CuentaMovimientoQué representa
recharge_clearing+50.00 (cargo)Valor nominal entregado al celular del cliente.
recharge_prepaid:taecel−48.50 (abono)Saldo prefondeado consumido en TAECEL.
merchant_recharge_income−1.50 (abono)Comisión que gana tu comercio por la venta.

La suma es cero: 50.00 = 48.50 + 1.50. El COSTO del asiento sale de la evidencia que reporta TAECEL para esa transacción exacta —no del catálogo—, así que un margen distinto del esperado se refleja tal cual (incluso NEGATIVO, si el costo reportado superó el nominal: se asienta como un cargo a merchant_recharge_income en vez de esconderse). El fee de Winal jamás entra en este flujo — se factura aparte, igual que en todos los demás módulos de la plataforma.

Validación de la referencia (el celular, o el número del recibo)

Antes de pedir cualquier transacción, Winal comprueba dos cosas y en este orden. Primero, que la referencia sea almacenable: letras, dígitos o @ . _ -, sin espacios y hasta 64 caracteres (recharge.invalid_phone si no). Después —y esta es la que de verdad protege— la valida contra la regla EXACTA que el catálogo VIVO del proveedor declara para esa operadora: longitud mínima y máxima, formato y si admite empezar en cero (recharge.invalid_reference, con el motivo concreto en el mensaje).

La primera comprobación dejó de ser «10 dígitos» a propósito. Mientras lo único que se vendía era tiempo aire, «diez dígitos» parecía una guarda razonable; en cuanto entró el pago de servicios pasó a ser una puerta que ninguna referencia legítima de recibo puede cruzar — CFE pide treinta caracteres y Maxcom siete. La longitud correcta la dice el catálogo del proveedor, no una constante nuestra.

Y la segunda validación importa más de lo que parece: se midió que TAECEL no aplica su propia regla en la recepción — aceptó y cobró una recarga de Telcel a un número que su propio catálogo prohíbe. Es la única barrera real contra una operación bien formada mandada a la referencia equivocada, y esa no se devuelve.

Errores de recharge-carriers / recharge-products / recharges

Guía completa (incluida la activación de operadoras y el catálogo de productos) arriba. Catálogo de códigos:

HTTPcodeCausa
400recharge.invalid_carrierFalta el código de operadora en /enable.
404recharge.carrier_not_foundEl código de operadora no existe o está inactivo.
400recharge.livemode_unsupportedLa API key es sk_live_ y el proveedor que atiende a tu cuenta (el simulador, sin credenciales reales de TAECEL) no entrega saldo real — conecta tu cuenta de TAECEL en producción o usa una llave sk_test_ mientras tanto.
400recharge.missing_fieldsFalta product_code o el destino (reference, o el histórico phone_number).
400idempotency_key_requiredFalta el header Idempotency-Key o no es un UUID válido.
404recharge.product_not_foundproduct_code no existe o no está activo.
400recharge.invalid_phoneLa referencia no es almacenable (espacios, caracteres raros, más de 64 caracteres).
400recharge.amount_requiredEl producto es de monto libre y falta amount_minor (o llegó en cero o negativo).
400recharge.amount_not_allowedEl producto tiene denominación fija y aun así llegó amount_minor.
400recharge.carrier_not_enabledLa operadora del producto no está activada para tu tenant — actívala primero (paso 2).
400recharge.product_not_supported_by_providerEl producto existe en el catálogo de Winal pero no está dado de alta con el agregador que atiende a tu cuenta. Se rechaza ANTES de pedir nada: no hubo cargo.
404recharge.payment_intent_not_foundpayment_intent_id no existe o no pertenece a tu tenant.
400recharge.payment_intent_not_succeededEl payment_intent enlazado no está en succeeded.
400recharge.payment_intent_amount_mismatchEl monto del payment_intent no coincide con el nominal de la recarga.
409recharge.idempotency_conflictMisma llave, datos distintos.
404recharge.not_foundEl id no existe (o es de otro tenant).
400recharge.invalid_periodEl from/to no es una fecha válida o el rango está invertido. Ojo con el + de un desfase horario en la URL: hay que escaparlo como %2B (o mandar la fecha en Z), porque un + sin escapar se decodifica como espacio.
400recharge.invalid_cursorEl cursor no es uno emitido por esta API. Usa tal cual el next_cursor de la página anterior; no lo construyas.

Estos DOS solo aparecen del lado del proceso que ejecuta, nunca como respuesta directa a tu petición — si los ves reflejados en un failure_reason, es un problema de despliegue de Winal, no de tu integración: recharge.provider_unavailable (el agregador de esa recarga no está conectado en el proceso que la tomó) y recharge.reference_mismatch (guarda interna: el destino no coincide con el de la reserva). Catálogo completo, con fila y ancla, en Errores.

400 · ejemplo real
{
  "error": {
    "type": "invalid_request_error",
    "code": "recharge.missing_fields",
    "message": "Se requieren 'product_code' y el destino ('reference', o 'phone_number' para un celular).",
    "doc_url": "...",
    "request_id": "..."
  }
}
400 · ejemplo real (llave sk_live_ sin TAECEL conectado)
{
  "error": {
    "type": "invalid_request_error",
    "code": "recharge.livemode_unsupported",
    "message": "Las recargas en modo producción requieren un agregador real conectado; el proveedor 'sim' solo simula la entrega. Usa una llave de prueba (sk_test_) o da de alta las credenciales del agregador.",
    "doc_url": "...",
    "request_id": "..."
  }
}
201 · ejemplo real (rechazo del carrier, NO es un error HTTP)
{
  "id": "5c3a9e2f-1d4b-4a9e-9b7f-2e8f0c6d4a1b",
  "object": "recharge",
  "status": "failed",
  "failure_reason": "«Numero Celular» no puede empezar con cero.",
  "provider_ref": null,
  "…": "…"
}

Endpoints

MétodoRutaNotas
GET/v1/recharge-carriersCatálogo global de operadoras.
POST/v1/recharge-carriers/{code}/enableEnciende o apaga una operadora; el default es venderla. No exige Idempotency-Key.
GET/v1/recharge-productsSolo productos de operadoras activadas por tu tenant.
POST/v1/rechargesRequiere Idempotency-Key. Devuelve 201 aunque el desenlace sea processing o failed.
GET/v1/recharges?limit= opcional (default 100, máx. 500), ?from=/?to= ISO-8601 y ?cursor=. Devuelve has_more y next_cursor.
GET/v1/recharges/statementEstado de cuenta del período: entregado con costo real, en vuelo, rechazado, pagos de servicio, abonos, cupo y —si la cuenta es dueña de la bolsa— su saldo. Sin fechas, los últimos 30 días.
GET/v1/recharges/statement.csvEl mismo período, una línea por movimiento, para cruzarlo en una hoja de cálculo.
GET/v1/recharges/{id}404 recharge.not_found si no existe. Tu ÚNICA forma de saber si una processing ya se resolvió. Trae attempts[]: cada intento contra un agregador, con su desenlace y por qué fue ahí.
GET/v1/recharges/balanceSaldo de la bolsa de la que sale tu operación, con la hora en que se leyó.
POST/v1/service-debt-inquiriesConsulta el adeudo de un recibo antes de cobrarlo. No exige Idempotency-Key. 200 con el adeudo, 202 si todavía no se sabe.
GET/v1/service-debt-inquiries/{id}Relee una consulta que salió 202. 404 service_debt.not_found fuera del ambiente de tu llave.

Conciliar un período

Los listados aceptan ?from= y ?to= en ISO-8601 y paginan por cursor, y GET /v1/recharges/statement resume el período de una cuenta. Las mismas tres cosas valen para GET /v1/service-payments, con los códigos de su propia familia.

Sin from/to y sin seguir el cursor, solo ves tus últimas limit recargas (tope 500). Todo lo anterior a esa página es inalcanzable hasta que pagines: manda ?cursor= con el next_cursor de la respuesta (con o sin fechas) mientras has_more sea true, o acota con from/to un rango que sepas que cabe en una sola página. Un integrador que solo llama una vez sin fechas y sin mirar has_more puede creer que vio "todas" sus recargas cuando en realidad vio solo las 500 más recientes.

El rango es semiabierto: [from, to). Incluye from y excluye to, así que enero y febrero nunca comparten la venta del instante del corte y sumar los doce meses da el año exacto. Sin zona horaria las fechas se leen en UTC. Si escribes el desplazamiento (+00:00), escápalo o usa la forma Z: en una query string el + se decodifica como espacio y la petición se rechaza con recharge.invalid_period.

Un mes completo, página a página
curl -s "https://api.winal.com.mx/v1/recharges?from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z&limit=500" \
  -H "Authorization: Bearer sk_test_..."

# La respuesta trae has_more y next_cursor; repite mientras has_more sea true:
curl -s "https://api.winal.com.mx/v1/recharges?from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z&limit=500&cursor=<next_cursor>" \
  -H "Authorization: Bearer sk_test_..."

El cursor es opaco: reenvíalo tal cual y no lo construyas a mano. Ordena por (fecha, id), que es un orden total, así que un lote de recargas enviado en el mismo milisegundo no se salta ni se repite entre páginas. Un cursor corrupto responde 400 recharge.invalid_cursor en vez de devolverte la primera página otra vez —una continuación silenciosa te haría sumar dos veces las mismas ventas—.

Las recargas en curso NO entran a un corte cerrado, y no todas cuentan igual

Antes de sumar un período, separa las filas por outcome (ver El desenlace): solo delivered y failed están settled: true. Una fila con outcome: "undetermined" no es un fracaso — pero cubre DOS situaciones distintas, y el estado de cuenta las distingue con in_flight.charged_by_provider_count y su importe in_flight.charged_by_provider_cost_minor —que es la única cifra de este bloque que cruza contra el estado de cuenta de tu agregador, porque cost_minor incluye también las pending, que el proveedor nunca vio—: status: "pending" todavía no se pidió al proveedor y no le costó saldo a la bolsa; status: "processing" ya se pidió y puede haber llegado al teléfono sin que Winal lo sepa todavía (regla 5: processing solo se resuelve con evidencia del proveedor, nunca por tiempo). Si excluyes una undetermined de tu corte de hoy, vuelve a consultarla en unos días — GET /v1/recharges/{id} con el mismo id — antes de darla por perdida: tratarla como failed antes de tiempo es exactamente el error que le haría creer a alguien que falta dinero cuando en realidad ya se entregó.

El campo con el que se concilia es provider_cost_minor

Cada recarga trae ahora provider_cost_minor (lo que el agregador descontó de verdad de la bolsa), client_reference (el rastro que viaja con la transacción y vuelve en el reporte de ventas del proveedor), provider_transaction_id, connector_key, livemode y requested_at.

provider_cost_minor puede venir en null, y hay que preverlo. El proveedor no siempre lo reporta —una operación resuelta por conciliación contra su reporte de ventas puede llegar sin él, y una sin desenlace nunca lo tiene—. Cuando falta, Winal usa el costo del catálogo (face_amount_minor − merchant_commission_minor), que es lo que se esperaba cobrar y no necesariamente lo que se cobró. Por eso el estado de cuenta publica without_provider_cost: una diferencia contra el estado de cuenta del agregador del tamaño de unos pocos cargos puede venir de ahí y no de un faltante.

El estado de cuenta

GET /v1/recharges/statement
curl -s "https://api.winal.com.mx/v1/recharges/statement?from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer sk_test_..." \
  -H "Winal-Account-Key: sk_test_de_la_farmacia"

Los bloques que devuelve, y qué significa cada uno:

BloqueQué es
periodLas fechas efectivas (eco de lo pedido) y el livemode reportado.
chargesLo ENTREGADO: conteo, suma nominal, suma del costo real, comisión y without_provider_cost.
in_flightLo que al cerrar el período seguía sin desenlace. charged_by_provider_count y charged_by_provider_cost_minor dicen cuántas ya le costaron saldo al agregador y por cuánto (las pending no cuentan: es el importe que cruza contra su estado de cuenta). cost_minor suma todo el bloque, pendientes incluidas.
failedConteo de lo que el proveedor rechazó. No consume saldo: se publica para que no lo busques.
service_paymentsPagos de servicio del período. Van aparte de las recargas porque su tabla no guarda el costo del proveedor ni la comisión, así que se concilian con menos precisión; el ambiente sí lo guardan, y se acotan por él (livemode_scoped: true).
depositsLos abonos que recibió la cuenta en el período (declarado, acreditado y comisión). Se ancla en la fecha PROPIA del abono (cuándo se depositó), no en la de ninguna recarga — un abono de julio sigue en el estado de cuenta de julio aunque el saldo que soltó se gaste en agosto. Siempre viaja: en modo prueba llega con available: false y el motivo, como bag.
Desde 0152 trae además attributed_count / attributed_claimed_minor —cuántos de esos abonos entraron SOLOS, reconocidos por la referencia de depósito de la cuenta— y manual_count, la resta ya hecha: los que alguien tuvo que identificar a mano. Es la única cifra que dice si la referencia por cliente está funcionando: con la referencia bien puesta tiende a cero.
allowanceEl cupo actual de la cuenta. Es un contador corriente, no una foto del período.
bagEl saldo de la bolsa, solo si la cuenta es su dueña. Si no, viene available: false con el motivo.

Una cuenta hija no tiene saldo propio, y el estado de cuenta no se lo inventa. El dinero vive en el agregador a nombre de quien fondeó la bolsa —Winal no custodia fondos—, y una bolsa la comparten todas las cuentas que su dueño apuntó a ella. Decirle a una farmacia «tu saldo es X» sería atribuirle el de sus hermanas. Lo suyo son sus movimientos (charges, in_flight, deposits) y su cupo (allowance).

El CSV

GET /v1/recharges/statement.csv devuelve el mismo período con una línea por movimiento —recargas y pagos de servicio, distinguidos por la columna tipo— con costo_proveedor_minor y costo_estimado para que una hoja de cálculo pueda sumar una columna sin fórmulas. Los pagos de servicio dejan vacías las nueve columnas que su tabla no tiene —producto, comision_comercio_minor, costo_proveedor_minor, costo_estimado, txn_proveedor, referencia_cliente, conector, pedido_id e intento_no—; el livemode sí lo llevan, para que una línea suelta se pueda leer sola. costo_estimado importa: en las filas de recarga siempre trae true/false y nunca va vacío, así que un parser que asuma eso para todas las filas se rompe justo en las de pago de servicio.

Cada línea de recarga es un intento contra un agregador —el costo real es del intento—, así que un pedido que Winal surtió por respaldo aparece en DOS líneas: la del intento rechazado (sin costo) y la del que entregó. Las dos últimas columnas, pedido_id e intento_no, dicen a qué pedido pertenece cada línea; pedido_id es el id que te devolvió POST /v1/recharges y el que resuelve GET /v1/recharges/{id}. Van al final para que ninguna columna que ya leías cambie de posición.

Cuadrar por agregador: dos bolsas, dos portales

Cuando Winal surte tus recargas por más de un agregador, el fin de mes son DOS cuadres: cada bolsa contra el portal de su agregador. El estado de cuenta lo trae ya partido: by_connector[] es el mismo período por agregador, con sus charges, in_flight, failed, deposits y allowance, y cada cifra del total es la suma de la misma cifra en los agregadores — al centavo, y una prueba lo cruza en cada build.

El costo real es del intento, y una venta servida por respaldo aparece en el agregador que ENTREGÓ. Un pedido que TAECEL rechazó y el segundo agregador entregó cuenta como una failed de TAECEL (sin costo: no consumió saldo) y una entregada del segundo, con el provider_cost_minor que reportó el segundo — porque es de su bolsa de donde salió el dinero y contra su portal contra el que cuadra. Atribuirla al primero cuadraría contra el portal equivocado. El connector_key del pedido dice quién entregó (o, sin entrega, quién fue el último en intentarlo).

Para ver UN agregador —y solo ése— manda ?connector_key= en el estado de cuenta, en el CSV y en el listado:

El mes de un solo agregador
curl -s "https://api.winal.com.mx/v1/recharges/statement?from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z&connector_key=taecel" \
  -H "Authorization: Bearer sk_live_..."

curl -s "https://api.winal.com.mx/v1/recharges/statement.csv?from=2026-08-01T00:00:00Z&to=2026-09-01T00:00:00Z&connector_key=taecel" \
  -H "Authorization: Bearer sk_live_..." -o taecel_agosto.csv

Dos ventanas, y no es un descuido. charges y by_connector usan el [from, to) de la API; el bloque bag cuadra entre las DOS LECTURAS de saldo que acotan tu período y cuenta la venta del instante exacto de la lectura que cierra ((desde, hasta]), porque esa lectura ya la refleja. Si una venta cae justo en to, la verás en la bolsa de este período y en los cargos del siguiente; no falta ni sobra dinero.

Conciliar un depósito

Si lo que quieres es saber si ya entró el depósito que una farmacia acaba de reportar, el camino corto es el webhook recharge.deposit.detected (Winal lo manda la primera vez que ve el abono) o POST /v1/recharge-balance/refresh para leer el saldo al momento — contrato de las dos en Referencia de API → Depósitos. Lo que sigue es la conciliación con detalle, movimiento por movimiento.

Cuando depositas saldo directo en tu cuenta del agregador (Winal no lo custodia — ver Los dos techos del dinero), la forma de confirmar que entró era mirar el saldo antes y después y hacer la resta. GET /v1/recharges/bag-movements te ahorra la resta: lee el HISTORIAL de movimientos que reporta el propio agregador, incluidos los ABONOS (depósitos), para que cruces tu comprobante —fecha, importe, folio— contra un movimiento concreto en vez de inferir que "algo entró".

¿Llegó mi depósito de hoy?
curl -s "https://api.winal.com.mx/v1/recharges/bag-movements?from=2026-08-20T00:00:00Z" \
  -H "Authorization: Bearer sk_live_..."

Cada movimiento trae is_deposit (derivado de que el proveedor lo marque como "Abono"), amount_minor interpretado y raw_amount —el importe EXACTO tal como lo escribió el proveedor, sin interpretar—, para que la primera vez que cuadres un depósito puedas comparar contra el crudo y no solo contra un número ya convertido. pouch_name puede no ser "Tiempo Aire" ni "Servicios": el mismo reporte ve TODAS las bolsas del agregador (timbres CFDI, SMS…), así que un abono de otra bolsa también aparece aquí.

Solo ves tu propia bolsa, nunca la de quien te administra. A diferencia del saldo (/balance, que sí es visible a quien consume) y del estado de cuenta (/statement, acotado a tus propios movimientos), el historial CRUDO de una bolsa compartida mezcla el consumo de TODAS las cuentas que la apuntan. Si tu cuenta consume la bolsa de tu integrador, esta ruta te devuelve una página vacía: sigue viendo lo tuyo en /statement (bloque deposits).

Es una caché sincronizada, no una llamada en vivo. Un ciclo periódico lee "hoy" y "ayer" del agregador cada ~15 minutos; synced_through dice hasta cuándo se sabe con certeza que no faltan filas, y coverage_from desde cuándo hay historial (los movimientos de ANTES de esa fecha nunca se leyeron — no es que no existieran). Si acabas de depositar, puede tardar hasta el siguiente ciclo en aparecer.

Y hay un tercer estado que conviene mirar antes de concluir nada: si truncated_at trae fecha, ese día se leyó a medias — la lista del agregador venía paginada y se alcanzó el tope de páginas. El listado está INCOMPLETO aunque synced_through tenga valor, así que no encontrar un depósito ahí no prueba que no exista. El siguiente ciclo lo reintenta; si persiste, avísanos.

Configurar tus bolsas de saldo

Desde tu tablero, sección Recargas: registra una o varias bolsas con tu llave y NIP de TAECEL —una por farmacia, una por grupo, o una sola para todo tu negocio, el mismo mecanismo sirve para los tres casos—, apunta tu cuenta a la que va a usar, activa las operadoras que quieras vender, y arma ahí mismo el comando para probar una recarga con tu propia llave sk_. A diferencia del resto de tu integración, registrar una bolsa y decidir de cuál consume una cuenta son actos que exigen confirmar tu contraseña y tu segundo factor (mismo criterio que cargar un CSD): con esas credenciales se gasta saldo real e irreversible, así que una cookie robada no puede recorrer ese camino. El tablero además te muestra el saldo real de cada bolsa —Winal lo consulta contra TAECEL al guardar la credencial y lo sigue vigilando después— sin que tengas que entrar al portal de TAECEL para verlo.

Ahí mismo ves a dónde depositar (botón «A dónde depositar» en cada bolsa): las cuentas bancarias de TAECEL (CLABE, número de cuenta y, si aplica, tarjeta) y la referencia de depósito de cada bolsa, sin salir a su portal a buscarlas. Es dato sensible —coordenadas bancarias reales, aunque no un secreto tuyo— y por eso viaja completo solo aquí, en tu tablero con sesión, nunca por una llave sk_: así una credencial de API filtrada no puede leerlo. A diferencia de registrar una bolsa o decidir de cuál consume una cuenta, verla no pide el paso extra de contraseña y segundo factor: es una lectura y no decide a dónde se mueve un peso, así que basta con la sesión abierta (con el mismo rol que sea, incluido «solo lectura»). La referencia por bolsa la publica TAECEL como propia de cada una, pero no está confirmado que un depósito se concilie solo por traerla — trátala como una ayuda para tu ficha de depósito, no como una garantía.

Traspasar saldo entre tus bolsas

Si administras varias cuentas y cada una tiene su PROPIA cuenta en el agregador —el caso de un integrador con decenas de farmacias—, fondear a cada una hoy exige un depósito bancario por separado. POST /v1/recharge-transfers mueve saldo YA depositado de una bolsa tuya a otra: depositas una vez a tu bolsa central y repartes desde tu sistema, sin volver al banco por cada una.

No es lo mismo que un depósito a una bolsa compartida (consola: Cuentas de clientes → Asignar depósito, ver Los dos techos del dinero). Ese depósito acredita cupo: el dinero ya estaba en una única bolsa y solo cambia a quién le toca. Un traspaso mueve saldo de VERDAD entre DOS cuentas distintas del agregador. Son los dos modelos de reparto y no se mezclan: si la cuenta destino consume la MISMA bolsa desde la que traspasarías, esta ruta lo rechaza (recharge_transfer.same_bag) y te dirige al depósito.

Irreversible — la regla 5 importa aquí más que en ningún otro lado
El agregador no cancela ni reversa un traspaso aplicado —textual de su manual—, así que si mandas de más la única salida es que te lo regresen con OTRO traspaso. Por lo mismo, requesting y processing NO son un fracaso: son el estado de un traspaso del que todavía no hay evidencia concluyente, y el saldo pudo haberse movido ya. No lo vuelvas a ordenar. Consulta GET /v1/recharge-transfers/{id} hasta ver un estado terminal (settled o failed), o reintenta con la MISMA Idempotency-Key: nunca crea un traspaso nuevo. Sobre uno ya pedido (requesting, processing) te devuelve ese mismo y no vuelve a pedir nada; sobre uno que se quedó pending —reservado, sin haber llegado a pedirse— lo REANUDA, que es la única cosa que un reintento puede empujar.
Si el traspaso queda pending: qué pasó y cómo se sale
Un traspaso pending está reservado y no tocó al agregador: no se movió un peso. El caso normal es que la bolsa origen no tenga las credenciales del agregador capturadas en ese ambiente, y entonces la respuesta es 400 recharge_transfer.provider_unavailable. Captúralas en Recargas → Bolsas y reintenta con la MISMA Idempotency-Key: ese reintento reanuda el mismo traspaso (una sola fila, un solo folio). Esa llave no queda quemada por el error —es uno de los pocos rechazos que la capa de idempotencia trata como transitorio, precisamente para que la salida funcione—. Y si nadie reintenta, el traspaso no se queda invisible: el conciliador lo cuenta y te manda una alerta operativa.

El destino se nombra por CUENTA, nunca por la cuenta en el agregador. Mandas destination_account con el identificador que te devolvió POST /v1/accounts —la cuenta que administras—, y Winal resuelve de ahí su bolsa y, de la bolsa, a qué cuenta del agregador llega el saldo. No existe ningún campo para mandar esa cuenta del agregador directamente: si lo hubiera, cualquiera podría nombrar la de un desconocido y el dinero saldría igual. Si la cuenta tiene su cuenta propia en el agregador dada de alta por Winal, ese número ya está; si su bolsa es tuya, antes tienes que haberlo puesto en Recargas → Bolsas → «Cuenta en el agregador» de la bolsa destino.

Exige Idempotency-Key (como cualquier escritura de dinero) y el mismo permiso recharges:write que vender — ver El permiso que exige una recarga. Y es de tu propia cuenta, no delegable: no admite Winal-Account-Key —ordenar un traspaso es mover tu propia tesorería, no operar la cuenta de un cliente— y si la mandas responde 403 account.delegation_not_allowed. Tiene además su PROPIO tope de actividad (recharge.transfer, de fábrica 20 por hora y $2,000,000.00 MXN por hora — el único acto de esta página con techo de monto de fábrica, porque un traspaso siempre declara cuánto mueve): ver Topes: el freno que tú ajustas.

Contrato completo —cuerpo, campos de la respuesta y los códigos de error— en Referencia de API → Traspasos de saldo entre bolsas.