Cobrá con CVU, conciliá solo y pagá retiros desde tu sistema
Con la API, tu plataforma le asigna un CVU a cada depósito de tus jugadores, consulta saldos y movimientos y paga retiros a cualquier CBU o CVU. Cada retiro te llega con un aviso firmado a tu sistema.
URL basehttps://api.luckycobros.com.ar/api/v1
Es la misma URL que ves en Integración del panel. Todos los endpoints de la API pública cuelgan de /external.
Qué está disponible en la versión 1.0
Ya podés pedir CVU, crear depósitos esperados, consultar saldos y movimientos, pedir retiros y configurar tu webhook. La acreditación automática de cobros y sus avisos transaction.* están en Próximamente: hasta que te confirmemos que están activos en tu cuenta, los depósitos esperados se crean, pero ningún cobro se acredita ni te avisa. El contrato de esos avisos ya es el definitivo, así que podés dejar tu receptor listo.
1
Pedís un CVUTu sistema crea un depósito esperado con el monto y el CUIT del jugador.
2
El jugador transfiereDesde cualquier banco o app de pagos, a la cuenta CVU que le mostrás.
3
Se concilia solo PróximamenteEl cobro se acredita a tu saldo y se cruza con el depósito esperado por CUIT.
4
Te avisamos PróximamenteTu sistema recibe un webhook firmado: acreditado, o retenido para revisión si algo no coincide.
JSON en UTF-8 en pedidos y respuestas. Mandá Content-Type: application/json en los POST y PUT.
Montos en pesos argentinos como número con punto decimal: 25000 o 1500000.5. En el listado de transacciones los montos llegan como texto ("25000"): convertilos con un tipo decimal, no con punto flotante.
Fechas en ISO 8601, en UTC: 2026-10-28T17:03:11.482Z es el 28/10/2026 a las 14:03 en Argentina.
CUIT o CUIL con 11 dígitos y sin guiones (27402187354). CBU o CVU con 22 dígitos.
Códigos de respuesta: los GET y el PUT responden 200; los POST que se procesan responden 201. En un retiro, el resultado real está en el campo status del cuerpo.
Cada pedido lleva tu API key en el encabezado X-API-Key.
Te damos la key en el alta; cuando tengas el panel, también vas a poder crearla en Integración › API keys. Tiene 64 caracteres y se entrega una sola vez: guardala en tu gestor de secretos. Nunca la pongas en el front de tu sitio ni en una app: solo tu servidor tiene que conocerla.
Cada key tiene uno o dos permisos: read (consultar) y write (operar). Cada endpoint exige uno de los dos.
Permisos por endpoint
Permiso
Endpoints
Para qué
read
GET /external/balance · /cbu · /transactions · /webhook
Consultar saldos, pedir un CVU, listar movimientos y leer la configuración del webhook.
write
POST /external/deposit/create · /transfer · /transfer/verify · PUT /external/webhook
Crear depósitos esperados, pedir y validar retiros, configurar el webhook.
write no incluye read
Una key que solo tiene write recibe 403 permisos insuficientes en los GET. Si tu integración consulta y opera, la key necesita los dos permisos. Una buena práctica es usar dos keys: una de solo lectura para reportes y otra de lectura y escritura para la operación.
IPs permitidas
Cada key puede tener una lista de IPs públicas desde las que se acepta. Si la lista está vacía, la key funciona desde cualquier IP. Es una capa más de protección: no reemplaza mantener la key en secreto.
Cargá cada IP exacta (por ejemplo 203.0.113.24). Hoy no se aceptan rangos ni notación CIDR.
La IP se controla antes que el permiso: un pedido desde una IP no habilitada recibe 403 con "code": "IP_NOT_WHITELISTED", sin importar el endpoint.
Rotar y revocar
Las keys no vencen. Para cambiar una: pedinos la nueva a soporte, desplegala en tus servidores y recién después pedí que revoquemos la anterior (cuando tengas el panel, lo hacés vos en Integración › API keys). Una key revocada deja de funcionar al instante.
Respuestas de autenticación
Estos errores tienen un cuerpo propio, con message y code:
Errores de autenticación
HTTP
Cuerpo exacto
Cuándo
401
{"message":"API Key faltante","code":null}
Falta el encabezado X-API-Key.
401
{"message":"API Key inválida o revocada","code":null}
La key no existe o fue revocada.
403
{"message":"IP no whitelisted","code":"IP_NOT_WHITELISTED"}
La IP del pedido no está en la lista de la key.
403
{"message":"permisos insuficientes","code":null}
La key no tiene el permiso del endpoint.
02
Entornos
Hoy hay un único entorno: producción.
Entornos
Entorno
URL base
Estado
Para qué
Producción
https://api.luckycobros.com.ar/api/v1
Disponible
Operación real: los cobros y retiros mueven plata de verdad.
Pruebas
—
Próximamente
Un entorno aislado con plata ficticia para probar de punta a punta.
Cómo probar mientras tanto
Usá una key aparte para tu entorno de staging (por ejemplo, "Integración · pruebas"), con las IPs de esos servidores. Así la revocás sin tocar producción.
Los GET no mueven plata: balance, CVU, transacciones y webhook se pueden llamar sin riesgo.
Los avisos se prueban en tu máquina, firmados con tu secreto y sin plata real: seguí la guía Probar webhooks localmente.
Para un retiro de prueba, mandá $ 1,00 a una cuenta propia externa (por ejemplo, la cuenta bancaria de tu empresa, no una de tus cuentas CVU de cobro) y validá el circuito completo: respuesta, aviso y movimiento en el listado.
Estado del servicio
GET /health responde {"status":"ok"} sin API key. Usalo en tu monitoreo: ver referencia.
03
Errores
Decidí por el código HTTP; el texto de message es para tus logs.
Los errores llegan en JSON con dos formas. Los de autenticación traen message y code; el resto, statusCode, message y error:
403Autenticación (401 / 403)
{
"message": "IP no whitelisted",
"code": "IP_NOT_WHITELISTED"
}
400Resto de los errores
{
"statusCode": 400,
"message": "saldo insuficiente para el retiro",
"error": "Bad Request"
}
Códigos HTTP
HTTP
Significado
Qué hacer
200
OK
Consulta (GET) o configuración (PUT) correcta.
201
Procesado
El POST se procesó. En un retiro, mirá status en el cuerpo: done, pending, error o duplicate.
400
Pedido inválido
Datos mal formados o la operación no se puede hacer (saldo insuficiente, sin cuentas operativas). Corregí y volvé a intentar.
401
Sin autenticar
Falta la API key o es inválida.
403
Prohibido
IP no habilitada (code: IP_NOT_WHITELISTED) o key sin el permiso necesario.
404
No existe
La ruta o el método no existen. Revisá la URL (incluye /api/v1/external).
409
Conflicto
Ya existe un retiro con esa idempotency_key. Consultá el original; no lo repitas con otra clave.
500
Error interno
En consultas, reintentá con espera (1 s, 5 s, 30 s). En un retiro, no reintentes con otra clave: puede haber salido. Ver Retiros en duda.
Mensajes que podés recibir
Mensajes de error
message
HTTP
Endpoint
API Key faltante
401
Todos con key
API Key inválida o revocada
401
Todos con key
IP no whitelisted
403
Todos con key
permisos insuficientes
403
Todos con key
el operador no tiene cuentas operativas
400
cbu · deposit/create · transfer
el monto debe ser mayor a cero
400
transfer
el CBU/CVU de destino tiene que tener 22 dígitos
400
transfer
saldo insuficiente para el retiro
400
transfer
ya existe un pago con esa idempotency_key
409
transfer
Internal server error
500
Cualquiera
Los textos pueden cambiar de redacción. Para automatizar, usá el código HTTP y, en los 403, el campo code. Los códigos de error de máquina para todos los casos están en Próximamente.
04
Idempotencia
Un mismo retiro nunca se paga dos veces, aunque repitas el pedido.
Los retiros (POST /external/transfer) llevan el campo idempotency_keyen el cuerpo (no es un encabezado). Mandalo siempre, con un valor único y estable por retiro: lo ideal es el ID del retiro en tu sistema, por ejemplo RT-000398. Usá letras, números y guiones, hasta 64 caracteres.
La clave es obligatoria y es una por retiro
Dos retiros distintos nunca pueden compartir clave: el segundo responde duplicate y no se procesa, aunque vaya a otro destino o sea por otro monto. Antes de llamar, controlá en tu sistema que cada retiro lleve su propia clave.
Comportamiento de la idempotency_key
Situación
Respuesta
Primera vez que usás la clave
El retiro se procesa normalmente.
Repetís la clave dentro de las 24 h (incluye un pedido que todavía está en curso)
201 con {"status":"duplicate"}. No se hace nada nuevo.
Repetís la clave después de 24 h y el retiro original existe
409ya existe un pago con esa idempotency_key.
El retiro original fue rechazado (status: "error" o estado fallida)
La clave queda libre: podés reintentar con la misma clave.
duplicate no dice en qué quedó el retiro original. Para saberlo, buscalo en GET /external/transactions: es la fila con tipo: "pago" cuyo idempotencyKey es tu clave o cuyo referenciaExterna empieza con tu clave. Mientras el retiro original está en curso, referenciaExterna viene en null: buscá por los dos campos.
Los depósitos esperados no son idempotentes
Cada POST /external/deposit/create crea un depósito nuevo. Si un pedido se corta, podés volver a pedirlo, pero el depósito anterior queda pendiente y, como se cruza por CUIT con el más viejo, puede conciliar el próximo cobro de ese jugador. Llevá en tu sistema qué depósitos pediste para cada jugador.
05
Conceptos
Lo que tenés que saber antes de integrar.
Cuentas CVU
Son las cuentas a nombre de tu empresa donde tus jugadores transfieren. Cada una tiene un CVU de 22 dígitos y, opcionalmente, un alias. Las ves con GET /external/balance, con el saldo de cada una; el saldo_real total es la suma de todas.
Rotación de CVU
Cada vez que pedís un CVU (con GET /external/cbu o al crear un depósito esperado) el sistema elige una de tus cuentas CVU operativas, así los cobros se reparten entre todas. Hoy el criterio es la cuenta operativa con menor saldo.
No guardes un CVU fijo por jugador ni lo muestres en tu sitio de forma permanente: pedilo en cada depósito.
Si no tenés ninguna cuenta operativa, la API responde 400 el operador no tiene cuentas operativas.
La rotación por tope mensual (que cada cuenta deje de asignarse al acercarse a su tope del mes) está en Próximamente.
Depósito esperado y conciliación por CUIT
Antes de que el jugador transfiera, tu sistema registra un depósito esperado con el monto y el CUIT o CUIL del jugador (POST /external/deposit/create). Cuando entra una transferencia, se busca un depósito pendiente con el mismo CUIT del que transfirió y se compara el monto:
Resultado de la conciliación
Qué llegó
Evento
Qué hacer en tu sistema
El monto esperado, exacto
transaction.confirmed
Ya está en tu saldo: acreditale el depósito al jugador por data.monto.
Menos de lo esperado motivo monto_menor
transaction.in_review
No lo acredites: queda retenido y no suma a tu saldo. Esperá cómo lo resuelve tu equipo (ver abajo).
Más de lo esperado motivo monto_mayor
transaction.in_review
No lo acredites: queda retenido y no suma a tu saldo. Esperá cómo lo resuelve tu equipo (ver abajo).
Una transferencia de un CUIT sin depósito pendiente motivo sin_deposito
transaction.in_review
No lo acredites: queda retenido y no suma a tu saldo. Esperá cómo lo resuelve tu equipo (ver abajo).
Se acredita automáticamente solo el cobro que coincide con su depósito esperado. Los demás quedan retenidos: ningún movimiento dudoso se acredita sin revisión. Tu equipo los resuelve en A revisar del panel y te llega transaction.confirmed (con el monto que se acreditó) o transaction.refunded (se devolvió a quien transfirió), con el mismo data.id.
También quedan retenidos un CUIT casi igual al esperado (motivo cuit_distinto) y una transferencia repetida del mismo CUIT y monto en pocos segundos (posible_duplicado).
El cruce es solo por CUIT, no por monto ni por la cuenta CVU de destino. Si hay varios depósitos pendientes del mismo CUIT, se usa el más viejo. Cada depósito concilia un solo cobro.
El CUIT tiene que ser el del titular de la cuenta desde la que transfiere el jugador. Si transfiere desde la cuenta de otra persona, el cobro queda retenido y te llega transaction.in_review. Avisale al jugador en tu pantalla de depósito.
Mandá el CUIT con 11 dígitos y sin guiones (27402187354), igual que como lo informa el banco.
Un cobro con un código de transferencia ya registrado no se acredita dos veces.
Vencimiento:expires_at es informativo. Hoy un depósito pendiente sigue vigente después de esa hora y puede conciliar un cobro posterior. El vencimiento automático está en Próximamente (cómo va a funcionar: ver Próximamente).
Tipo de tráfico: FTD y STD
Al crear un depósito esperado podés marcar el tipo de tráfico en traffic_type. Se guarda en la transacción conciliada (campo trafficType) para que separes en tus reportes los primeros depósitos del resto. No cambia cómo se concilia ni cuánto se acredita.
Tipos de tráfico
Valor
Significado
ftd
First time deposit: el primer depósito del jugador.
std
Standard deposit: los depósitos siguientes. Es el valor por defecto.
Mandá solo ftd o std, en minúsculas.
Estados
Campo status de cada fila de GET /external/transactions. Los cobros (tipo: "cobro") están confirmados (con la acreditación automática, que está en Próximamente, un cobro retenido va a figurar en_revision y uno devuelto, devuelta); los retiros (tipo: "pago") pasan por estos estados:
Estados de transacciones
status
Aplica a
Qué significa
Saldo
confirmadaConfirmada
Cobros
El cobro entró y está acreditado a tu saldo.
Acreditado
confirmadaConfirmada
Retiros
El retiro se ejecutó (al momento o confirmado después, en la verificación posterior).
Debitado
iniciadaEn curso
Retiros
Saldo reservado y retiro en ejecución. Si queda así, pasa a la verificación posterior: se confirma o va a revisión.
Reservado
pendientePendiente
Retiros
El retiro fue aceptado y espera la confirmación de la ejecución.
Debitado
en_dudaEn duda
Retiros
No hubo respuesta clara: puede haber salido o no. Se verifica después contra los movimientos de la cuenta CVU.
Reservado (no se devuelve)
requiere_revisionEn revisión
Retiros
Pasaron más de 10 minutos sin poder confirmarlo. No se resuelve solo ni genera un aviso: escribinos a soporte. Mientras tanto se sigue verificando.
Reservado (no se devuelve)
fallidaFallida
Retiros
Rechazado sin ejecutarse.
Devuelto
El status de la respuesta de POST /external/transfer y el del aviso withdrawal.* usan otros valores. Así se relacionan:
Equivalencias de estados de retiro
Respuesta de /transfer
Aviso
status en el aviso
Estado en transacciones
201 · done
withdrawal.completed
done
confirmada
201 · pending(hoy no se produce)
withdrawal.pending
pending
pendiente
201 · error(hoy no se produce)
withdrawal.failed
error
fallida
500
withdrawal.failed
error
fallida
500 (o sin respuesta)
withdrawal.pending
unknown
en_duda → requiere_revision
—
withdrawal.completed con conciliado: true
done
confirmada
201 · duplicate
Ninguno
—
El del retiro original
Retiros en duda o en revisión: qué hacer
Nunca reintentes un retiro en duda con otra idempotency_key
Si el primer pedido salió, una clave nueva genera un segundo pago al mismo destino. Con la misma clave no hay riesgo: el sistema responde duplicate y no mueve plata.
Un retiro queda en duda cuando no se sabe si salió: tu pedido recibió 500, se cortó por tiempo o llegó el aviso withdrawal.pending con status: "unknown". El saldo queda reservado mientras se verifica, para que nunca se pague dos veces.
Marcalo como "en verificación" en tu sistema y no se lo informes al jugador como fallido.
Esperá el aviso. Si se confirma, te llega withdrawal.completed con conciliado: true. Un retiro en duda nunca vuelve solo a tu saldo ni termina en withdrawal.failed: si no se puede confirmar, pasa a requiere_revision, sin aviso.
Si querés reintentar, usá la misma idempotency_key. Si el original sigue en duda vas a recibir duplicate; si había fallado, se procesa de nuevo de forma segura.
Consultá el estado en GET /external/transactions: la fila de tipo: "pago" con tu clave en idempotencyKey o al principio de referenciaExterna.
Si pasa a requiere_revision (más de 10 minutos sin confirmación), escribinos a soporte con tu idempotency_key, el CBU y el monto. No se resuelve solo y este cambio no genera ningún aviso: hasta que soporte lo cierre, el saldo queda reservado.
06
Referencia de endpoints
Todas las rutas son relativas a https://api.luckycobros.com.ar/api/v1.
Hoy es igual a saldo_real. Cuando se descuenten comisiones del saldo, va a mostrar lo disponible después de comisiones (Próximamente).
cantidad_cuentas
number
Cantidad de cuentas CVU.
cuentas[].id
string (UUID)
Identificador de la cuenta (es el cuentaId de las transacciones).
cuentas[].cvu
string
CVU de 22 dígitos.
cuentas[].alias
string · null
Alias de la cuenta, si tiene.
cuentas[].saldo_real
number
Saldo de esa cuenta.
Si todavía no tenés cuentas, responde {"saldo_real":0,"saldo_neto":0,"cantidad_cuentas":0,"cuentas":[]}.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
GET/external/cbu
Pedir un CVU para cobrar
Permiso read
Devuelve la cuenta CVU que corresponde según la rotación, para mostrársela al jugador. Si además querés que el cobro se concilie con un pedido tuyo, usá POST /external/deposit/create, que elige la cuenta y registra el depósito esperado en un solo paso.
CVU de 22 dígitos al que tiene que transferir el jugador.
cbu
null
Reservado. Hoy siempre null: usá cvu.
alias
string · null
Alias de la cuenta, si tiene.
titular
string · null
Titular de la cuenta (tu razón social).
moneda
string
Siempre ARS.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
400
el operador no tiene cuentas operativas
No hay ninguna cuenta CVU operativa para asignar. Revisá tus cuentas en el panel.
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
GET/external/transactions
Listar transacciones
Permiso read
Lista cobros y retiros juntos, del más nuevo al más viejo. Usalo para conciliar contra tu sistema al final del día y para consultar el estado de un retiro. Hoy no tiene filtros: recorré las páginas hasta la fecha que necesites.
Pedido
Parámetros de transactions
Campo
Tipo
Obligatorio
Descripción
page
integer
No
Página, desde 1. Por defecto 1.
limit
integer
No
Filas por página. Por defecto 20. Recomendado: hasta 100 (hoy no hay un tope fijo, pero las páginas grandes tardan más). Mandá siempre enteros positivos.
Ojo con el formato: en este endpoint los campos van en camelCase y los montos llegan como texto. pagination.totalPages es total / limit redondeado hacia arriba y hasMore indica si hay otra página.
Campos de una transacción
Campo
Tipo
Descripción
id
string (UUID)
Identificador de la transacción. Es el mismo data.id de los avisos de cobro.
operadorId
string (UUID)
Identificador de tu empresa.
cuentaId
string (UUID) · null
Cuenta CVU donde entró el cobro o de la que salió el retiro (ver cuentas[].id en balance). Puede venir en null.
codigoCoelsa
string · null
Código de la transferencia en la red interbancaria. En retiros puede ser null si no se ejecutó.
idempotencyKey
string · null
Solo retiros: tu idempotency_key. Queda en null si el retiro falló, para que puedas reutilizarla.
referenciaExterna
string · null
Solo retiros: tu idempotency_key, cuando el retiro se cerró al pedirlo (también si falló). Mientras está iniciada viene en null, y sigue así si después se confirma o pasa a revisión desde ese estado: buscá por idempotencyKey o por este campo. En casos excepcionales lleva un sufijo después de un espacio: buscá por prefijo.
tipo
string
cobro o pago (retiro).
monto
string decimal
Monto en ARS, como texto: "25000", "92500.5".
montoEsperado
string decimal · null
Solo cobros conciliados con un depósito esperado: el monto que esperabas.
ftd o std, si el cobro se concilió con un depósito esperado. Se guarda el valor que mandaste en traffic_type.
contraparteNombre
string · null
Cobros: nombre del titular que transfirió. Puede venir vacío ("").
contraparteCuit
string · null
Cobros: CUIT o CUIL del titular que transfirió, normalmente de 11 dígitos. Puede venir vacío ("") si no se informó.
contraparteCbu
string · null
Retiros: CBU o CVU de destino.
isDelayed
boolean
Reservado. Hoy siempre false.
createdAt
string (ISO 8601 UTC)
Fecha y hora de alta.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
POST/external/deposit/create
Crear un depósito esperado
Permiso write
Registra que esperás un cobro de un jugador y te devuelve la cuenta CVU a la que tiene que transferir. Cuando entre una transferencia de ese CUIT, se concilia con este depósito y te llega el aviso transaction.confirmed, o transaction.in_review si algo no coincide. Ver cómo se concilia.
Pedido
Cuerpo de deposit/create
Campo
Tipo
Obligatorio
Descripción
monto
number
Sí
Monto que esperás, en ARS. Tiene que ser mayor a cero.
contraparte_cuil_cuit
string
Sí (o cuit)
CUIT o CUIL del jugador, 11 dígitos sin guiones. Tiene que ser el titular de la cuenta desde la que va a transferir.
cuit
string
No
Alternativa a contraparte_cuil_cuit (se usa si ese falta).
Identificador del depósito esperado. Guardalo junto a tu pedido.
cvu
string
CVU que tenés que mostrarle al jugador. Mostrá este, no uno pedido antes con /cbu.
expires_at
string (ISO 8601 UTC)
Vigencia informada: alta + ttl_minutes. Hoy no vence solo.
Validá los datos antes de enviar
Mandá siempre monto como número mayor a cero y el CUIT o CUIL del jugador con 11 dígitos, sin guiones: es el dato con el que se concilia el cobro, así que con un CUIT incompleto o equivocado el cobro no se cruza con este depósito. Este pedido no es idempotente: ver Idempotencia.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
400
el operador no tiene cuentas operativas
No hay cuentas CVU operativas para asignar.
500
Internal server error
Error interno. Revisá que el pedido tenga los campos obligatorios con su tipo y reintentá; si sigue, escribinos a soporte.
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
POST/external/transfer
Pedir un retiro
Permiso write
Transfiere desde tu saldo a cualquier CBU o CVU. El retiro sale de una sola de tus cuentas CVU: la de mayor saldo. Si ninguna cuenta alcanza por sí sola, responde 400 saldo insuficiente para el retiro aunque la suma de tus cuentas alcance.
Guardá el retiro en tu base antes de llamar. El aviso withdrawal.* se envía antes de que termine esta respuesta, así que normalmente te llega primero: tu receptor lo tiene que poder cruzar sin la respuesta, y la respuesta no tiene que pisar el estado que ya fijó el aviso.
Esperá hasta 60 segundos la respuesta. La respuesta sale recién cuando tu receptor contesta el aviso del retiro: si tu receptor tarda, este pedido también tarda y puede pasar ese plazo. Por eso, respondé los avisos en menos de 2 segundos. Si se corta, tratalo como en duda: no cambies la clave.
// Node 18 o superior. Guardá el retiro en tu base ANTES de llamar:// el aviso (webhook) puede llegar antes que esta respuesta.asyncfunction pedirRetiro(retiro) {
let respuesta;
try {
respuesta = await fetch('https://api.luckycobros.com.ar/api/v1/external/transfer', {
method: 'POST',
headers: {
'X-API-Key': process.env.LUCKY_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
cbu: retiro.cbu, // 22 dígitos
monto: retiro.monto, // número, mayor a cero
concepto: retiro.concepto,
idempotency_key: retiro.id, // estable: el ID del retiro en tu sistema
}),
signal: AbortSignal.timeout(60000),
});
} catch (error) {
return'en_duda'; // sin respuesta: reintentá más tarde con la MISMA clave
}
const datos = await respuesta.json().catch(() => ({}));
if (respuesta.status === 201) {
if (datos.status === 'done') return'confirmado'; // datos.coelsaId = código de la transferenciaif (datos.status === 'pending') return'pendiente'; // esperá withdrawal.completedif (datos.status === 'error') return'fallido'; // la plata volvió; podés reintentarif (datos.status === 'duplicate') return'consultar'; // ya existe: mirá GET /external/transactions
}
if (respuesta.status === 400) return'rechazado'; // datos.message dice por qué; corregí y reintentáif (respuesta.status === 409) return'consultar'; // esa clave ya se usó en un retiro anteriorreturn'en_duda'; // 500 u otro: NO cambies la clave; esperá el aviso
}
console.log(await pedirRetiro({
id: 'RT-000398',
cbu: '0070999830004123456781',
monto: 1500000,
concepto: 'Fondeo cuenta operativa',
}));
// Guardá el retiro en tu base ANTES de llamar: el aviso puede llegar antes que esta respuesta.function pedirRetiro(array$retiro): string
{
$ch = curl_init('https://api.luckycobros.com.ar/api/v1/external/transfer');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('LUCKY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'cbu' => $retiro['cbu'],
'monto' => $retiro['monto'],
'concepto' => $retiro['concepto'],
'idempotency_key' => $retiro['id'], // estable: el ID del retiro en tu sistema
]),
CURLOPT_TIMEOUT => 60,
]);
$cuerpo = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($cuerpo === false) {
return'en_duda'; // sin respuesta: reintentá más tarde con la MISMA clave
}
$datos = json_decode($cuerpo, true) ?: [];
if ($status === 201) {
switch ($datos['status'] ?? '') {
case'done': return'confirmado'; // $datos['coelsaId'] = código de la transferenciacase'pending': return'pendiente'; // esperá withdrawal.completedcase'error': return'fallido'; // la plata volvió; podés reintentarcase'duplicate': return'consultar'; // ya existe: mirá GET /external/transactions
}
}
if ($status === 400) return'rechazado'; // $datos['message'] dice por quéif ($status === 409) return'consultar'; // esa clave ya se usó en un retiro anteriorreturn'en_duda'; // 500 u otro: NO cambies la clave; esperá el aviso
}
echo pedirRetiro([
'id' => 'RT-000398',
'cbu' => '0070999830004123456781',
'monto' => 1500000,
'concepto' => 'Fondeo cuenta operativa',
]);
import os
import requests
# Guardá el retiro en tu base ANTES de llamar: el aviso puede llegar antes que esta respuesta.def pedir_retiro(retiro: dict) -> str:
try:
respuesta = requests.post(
"https://api.luckycobros.com.ar/api/v1/external/transfer",
headers={"X-API-Key": os.environ["LUCKY_API_KEY"]},
json={
"cbu": retiro["cbu"], # 22 dígitos"monto": retiro["monto"], # número, mayor a cero"concepto": retiro["concepto"],
"idempotency_key": retiro["id"], # estable: el ID del retiro en tu sistema
},
timeout=60,
)
except requests.RequestException:
return"en_duda"# sin respuesta: reintentá más tarde con la MISMA clavetry:
datos = respuesta.json()
except ValueError:
datos = {}
if respuesta.status_code == 201:
estado = datos.get("status")
if estado == "done":
return"confirmado"# datos["coelsaId"] = código de la transferenciaif estado == "pending":
return"pendiente"# esperá withdrawal.completedif estado == "error":
return"fallido"# la plata volvió; podés reintentarif estado == "duplicate":
return"consultar"# ya existe: mirá GET /external/transactionsif respuesta.status_code == 400:
return"rechazado"# datos["message"] dice por quéif respuesta.status_code == 409:
return"consultar"# esa clave ya se usó en un retiro anteriorreturn"en_duda"# 500 u otro: NO cambies la clave; esperá el aviso
print(pedir_retiro({
"id": "RT-000398",
"cbu": "0070999830004123456781",
"monto": 1500000,
"concepto": "Fondeo cuenta operativa",
}))
{
"statusCode": 400,
"message": "saldo insuficiente para el retiro",
"error": "Bad Request"
}
{
"statusCode": 500,
"message": "Internal server error"
}
La respuesta trae coelsaId (en camelCase) y el nombre del aviso que se envió en event. No incluye el id de la transacción ni tu clave. coelsaId identifica la operación, pero puede venir vacío o con un identificador que no es el código interbancario: no lo uses como clave única. Las respuestas 201 · pending y 201 · error son parte del contrato, pero hoy no se producen: un retiro hoy termina en done, duplicate, 400, 409 o 500. Manejalas igual, porque pueden empezar a aparecer sin cambio de versión.
Resultados de un retiro
Resultado
HTTP · status
Aviso
Saldo
Qué hacés
Ejecutado
201 · done
withdrawal.completed
Debitado
Marcalo como pagado.
Aceptado, sin confirmar (hoy no se produce)
201 · pending
withdrawal.pending
Debitado
Esperá withdrawal.completed.
Rechazado (hoy no se produce)
201 · error
withdrawal.failed
Devuelto
Podés reintentar con la misma clave.
Error interno o sin respuesta
500 o sin respuesta
withdrawal.failed (status: "error") o withdrawal.pending (status: "unknown")
Devuelto o reservado, según el aviso
No cambies la clave. El aviso te dice cómo terminó. Ver Retiros en duda.
Clave repetida (24 h)
201 · duplicate
Ninguno
Sin cambios
Consultá el original en transacciones.
Clave repetida (después de 24 h)
409
Ninguno
Sin cambios
Consultá el original en transacciones.
Datos inválidos o sin saldo
400
Ninguno
Sin cambios
Corregí y reintentá (la clave queda libre).
Ante un error interno, esperá el aviso
Ante un 500 o un corte, el retiro puede no haberse hecho (y la plata vuelve a tu saldo) o haber quedado en duda. Los distingue el aviso: withdrawal.failed o withdrawal.pending con status: "unknown". En los dos casos, reintentá solo con la misma clave.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
400
el monto debe ser mayor a cero
Falta monto, no es numérico o es cero o negativo.
400
el CBU/CVU de destino tiene que tener 22 dígitos
El destino no tiene 22 dígitos después de sacar espacios y guiones.
400
el operador no tiene cuentas operativas
No hay cuentas CVU operativas.
400
saldo insuficiente para el retiro
Ninguna cuenta CVU tiene saldo suficiente por sí sola.
409
ya existe un pago con esa idempotency_key
La clave ya se usó en un retiro anterior.
500
Internal server error
Error interno. No cambies la clave: el aviso te dice cómo terminó. Ver arriba.
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
POST/external/transfer/verify
Validar un destino
Permiso write
Antes de un retiro, revisa el CBU o CVU de destino. Siempre te dice si el formato es válido; si el destino ya es conocido por tus cuentas, además te devuelve el titular y su CUIT para que se lo muestres a quien pide el retiro.
valid: true significa que el formato es correcto. No confirma que la cuenta exista: si no viene el titular, pedile al usuario que lo confirme.
Pedido
Cuerpo de transfer/verify
Campo
Tipo
Obligatorio
Descripción
cbu
string
Sí
CBU o CVU, 22 dígitos seguidos, sin espacios ni guiones (acá no se limpian).
Cualquier key con permiso read puede leerlo. Restringí esas keys por IP y no las compartas con sistemas de reportes de terceros.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
PUT/external/webhook
Configurar el webhook
Permiso write
Define la URL donde recibís los avisos y el secreto con el que se firman. Hay una sola URL por empresa, para todos los eventos.
Pedido
Cuerpo de webhook
Campo
Tipo
Obligatorio
Descripción
callback_url
string
La primera vez
URL pública de tu sistema que recibe los avisos por POST. Usá https://. Si no la mandás, se mantiene la anterior. Si la mandás en null, se borra y dejás de recibir avisos.
webhook_secret
string
La primera vez
Secreto para firmar los avisos. Lo elegís vos: usá al menos 32 caracteres al azar (openssl rand -hex 32). Si lo mandás vacío o no lo mandás, se mantiene el anterior.
Si no configuraste webhook_secret, no te llega ningún webhook, aunque la URL esté cargada. La URL no se valida al guardarla: después de configurarla, probala con la guía Probar webhooks localmente.
Errores
Errores
HTTP
message
Qué pasó / qué hacer
401
API Key faltante
No mandaste el encabezado X-API-Key.
401
API Key inválida o revocada
La key no existe o fue revocada.
403
IP no whitelisted
El pedido salió de una IP que no está en la lista de la key.
403
permisos insuficientes
La key no tiene el permiso que pide el endpoint.
07
Webhooks
Avisos firmados que llegan a tu sistema con cada cobro y cada cambio de un retiro.
Configuración
Generá un secreto al azar: openssl rand -hex 32.
Configurá tu URL y el secreto con PUT /external/webhook (cuando tengas el panel, también desde Integración).
Publicá un endpoint POST en esa URL que verifique la firma y responda 2xx. Tenés ejemplos listos en Verificar la firma.
Hay una sola URL por empresa: todos los eventos llegan ahí y se distinguen por el campo event.
Formato del aviso
Cada aviso es un POST con un JSON {"event": "…", "data": {…}} y estos encabezados:
Encabezados del aviso
Encabezado
Ejemplo
Descripción
Content-Type
application/json
Siempre JSON en UTF-8.
X-Webhook-Signature
sha256=5f0c…
HMAC-SHA256 del cuerpo crudo con tu webhook_secret, en hexadecimal minúscula, con el prefijo sha256=.
X-Webhook-Event
transaction.confirmed
Nombre del evento (igual que event en el cuerpo).
X-Webhook-Timestamp
2026-10-28T17:03:11.482Z
Hora de envío en UTC. No forma parte de la firma: usala solo como referencia.
Eventos
Eventos
Evento
Cuándo se envía
transaction.confirmedPróximamente
El cobro se acreditó a tu saldo: coincidió con su depósito esperado o tu equipo lo aprobó desde A revisar. Es el único aviso de cobro que se acredita.
transaction.in_reviewPróximamente
Entró un cobro que no coincide (monto menor o mayor, sin depósito esperado, CUIT distinto o posible duplicado). Queda retenido: no se acredita hasta que tu equipo lo resuelva. El motivo viaja en data.motivo.
transaction.refundedPróximamente
Tu equipo devolvió un cobro retenido a la cuenta de origen. No se acredita.
withdrawal.completed
El retiro se ejecutó. Con conciliado: true si se confirmó en la verificación posterior de un retiro pendiente o en duda.
withdrawal.pending
El retiro fue aceptado sin confirmar (status: "pending") o su resultado es desconocido (status: "unknown").
withdrawal.failed
El retiro fue rechazado al pedirlo y la plata volvió a tu saldo. Un retiro que quedó en duda nunca termina en este aviso.
Cobros: transaction.*
Estos avisos llegan con la acreditación automática de cobros
Están en Próximamente: hoy no se envían. El formato de abajo es el definitivo, así que podés dejar tu receptor listo y probarlo con el caso de firma.
Identificador de la transacción (el id de GET /external/transactions). Es el mismo en todos los avisos de un cobro: deduplicá por event + id.
codigo_coelsa
string
Código de la transferencia en la red interbancaria.
monto
number
Monto que llegó de verdad, en ARS. En transaction.confirmed es lo que se acreditó a tu saldo y lo que tenés que acreditarle al jugador.
contraparte_nombre
string · null
Titular de la cuenta que transfirió.
contraparte_cuil_cuit
string · null
CUIT o CUIL de quien transfirió, 11 dígitos. Con este dato encontrás el depósito esperado en tu sistema.
status
string
confirmada (acreditado a tu saldo), en_revision (retenido, sin acreditar) o devuelta.
motivo
string
Solo en in_review y refunded: por qué quedó retenido. monto_menor, monto_mayor, sin_deposito, cuit_distinto o posible_duplicado.
El aviso no trae el id del depósito esperado, ni el monto esperado, ni el tipo de tráfico. Para cruzarlo con tu pedido, buscá en tu sistema el depósito pendiente más viejo de ese CUIT: es la misma regla que usamos para conciliar. Para que dé el mismo resultado de los dos lados, no des por vencidos ni canceles depósitos pendientes por expires_at mientras el vencimiento esté en Próximamente: para nosotros siguen pendientes. Si igual los vencés, cruzá cada cobro con el montoEsperado de su transacción en GET /external/transactions. El detalle completo está en GET /external/transactions.
Código de la transferencia. No viene cuando el retiro se rechazó antes de ejecutarse ni cuando su resultado es desconocido.
monto
number
Monto del retiro, tal como lo pediste.
cbu
string
CBU o CVU de destino, 22 dígitos.
status
string
done, pending, unknown o error.
conciliado
boolean
Solo en withdrawal.completed: true si se confirmó en la verificación posterior.
Cómo cruzar un aviso de retiro con tu pedido
El aviso no trae tu idempotency_key. Cruzalo por cbu + monto entre tus retiros abiertos: el aviso suele llegar antes que la respuesta de /transfer, así que todavía no tenés el coelsa_id. Para que el cruce sea siempre único, evitá tener dos retiros abiertos al mismo destino por el mismo monto.
Verificar la firma
Calculá el HMAC-SHA256 del cuerpo crudo, tal cual llegó, con tu webhook_secret como clave; pasalo a hexadecimal y comparalo en tiempo constante con lo que viene después de sha256= en X-Webhook-Signature. Si no coincide, respondé 401 y descartalo.
Verificá sobre el cuerpo crudo, no sobre el JSON rearmado
Si parseás el JSON y lo volvés a serializar, cambian los espacios o el orden de las claves y la firma no coincide. Leé los bytes del pedido antes de parsearlos.
const crypto = require('node:crypto');
// cuerpoCrudo: Buffer con el cuerpo tal cual llegó (antes de cualquier JSON.parse)function firmaValida(secreto, cuerpoCrudo, encabezado) {
if (typeof encabezado !== 'string') returnfalse;
const recibida = encabezado.trim().replace(/^sha256=/i, '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(recibida)) returnfalse;
const esperada = crypto.createHmac('sha256', secreto).update(cuerpoCrudo).digest('hex');
return crypto.timingSafeEqual(Buffer.from(recibida, 'hex'), Buffer.from(esperada, 'hex'));
}
// Con Express: app.post('/webhooks/cobros', express.raw({ type: 'application/json' }), (req, res) => {// if (!firmaValida(process.env.WEBHOOK_SECRET, req.body, req.get('X-Webhook-Signature'))) return res.sendStatus(401);// res.sendStatus(200); // respondé primero, procesá después// });
Reciben el aviso, verifican la firma, responden 200 enseguida, deduplican y procesan. Están listos para correr, sin dependencias:
// Lucky Cobros · servidor mínimo que recibe los avisos (webhooks) y verifica la firma.//// Uso (Node 18 o superior, sin dependencias):// WEBHOOK_SECRET="el-mismo-secreto-que-configuraste" node verificar-firma.js// Escucha en http://localhost:3000/webhooks/cobros (cambiá el puerto con PORT=8080).//// Reglas que sigue este ejemplo:// 1. La firma se calcula sobre el cuerpo CRUDO, byte por byte. No lo parsees ni lo vuelvas a serializar antes.// 2. La comparación es en tiempo constante (crypto.timingSafeEqual).// 3. Responde 200 enseguida y procesa después: tu demora retrasa el resto de la operación.// 4. Deduplica: el mismo aviso no se procesa dos veces.'use strict';
const http = require('node:http');
const crypto = require('node:crypto');
const SECRETO = process.env.WEBHOOK_SECRET;
const PUERTO = Number(process.env.PORT) || 3000;
const RUTA = '/webhooks/cobros';
if (!SECRETO) {
console.error('Falta WEBHOOK_SECRET: usá el mismo secreto que configuraste con PUT /external/webhook.');
process.exit(1);
}
/** true si el encabezado X-Webhook-Signature corresponde a este cuerpo y este secreto. */
function firmaValida(secreto, cuerpoCrudo, encabezado) {
if (typeof encabezado !== 'string') returnfalse;
const recibida = encabezado.trim().replace(/^sha256=/i, '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(recibida)) returnfalse;
const esperada = crypto.createHmac('sha256', secreto).update(cuerpoCrudo).digest('hex');
return crypto.timingSafeEqual(Buffer.from(recibida, 'hex'), Buffer.from(esperada, 'hex'));
}
/** Clave para no procesar dos veces el mismo aviso (null = no hay código para deduplicar). */
function claveDeDuplicado(aviso) {
const d = aviso.data || {};
// Un mismo cobro puede recibir transaction.in_review y después confirmed o refunded: la clave lleva el evento.if (aviso.event.startsWith('transaction.')) return d.id ? aviso.event + ':' + d.id : null;
if (aviso.event.startsWith('withdrawal.')) return d.coelsa_id ? aviso.event + ':' + d.coelsa_id : null;
returnnull;
}
// En producción guardá las claves en tu base, con un índice único. Acá alcanza con memoria.const procesados = new Set();
function procesar(aviso) {
const d = aviso.data;
switch (aviso.event) {
case'transaction.confirmed':
// Ya está en tu saldo: acreditá d.monto al jugador con CUIT d.contraparte_cuil_cuit.
console.log(`[cobro] ${aviso.event} · acreditar $ ${d.monto} de ${d.contraparte_nombre} (${d.contraparte_cuil_cuit}) · código ${d.codigo_coelsa}`);
break;
case'transaction.in_review':
case'transaction.refunded':
// No acredites. in_review: queda retenido hasta que tu equipo lo resuelva en el panel (después llega// confirmed o refunded con el mismo data.id). refunded: se le devolvió a quien transfirió.
console.log(`[cobro] ${aviso.event} · no se acredita · $ ${d.monto} de ${d.contraparte_nombre} · motivo ${d.motivo || '-'}`);
break;
case'withdrawal.completed':
case'withdrawal.pending':
case'withdrawal.failed':
// Buscá tu retiro abierto por cbu + monto (o por coelsa_id si ya lo tenías) y actualizá su estado.
console.log(`[retiro] ${aviso.event} · status ${d.status} · $ ${d.monto} a ${d.cbu}${d.coelsa_id ? ' · código ' + d.coelsa_id : ''}`);
break;
default:
console.log(`[aviso] evento nuevo, lo ignoramos: ${aviso.event}`);
}
}
const servidor = http.createServer((req, res) => {
const ruta = (req.url || '').split('?')[0];
if (req.method !== 'POST' || ruta !== RUTA) {
res.writeHead(404).end();
return;
}
const partes = [];
req.on('data', (parte) => partes.push(parte));
req.on('end', () => {
const cuerpoCrudo = Buffer.concat(partes);
if (!firmaValida(SECRETO, cuerpoCrudo, req.headers['x-webhook-signature'])) {
res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"ok":false,"motivo":"firma inválida"}');
return;
}
let aviso;
try {
aviso = JSON.parse(cuerpoCrudo.toString('utf8'));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"motivo":"JSON inválido"}');
return;
}
// 1) Respondé 2xx rápido.
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
// 2) Procesá después, sin repetir.
setImmediate(() => {
const clave = claveDeDuplicado(aviso);
if (clave && procesados.has(clave)) {
console.log(`[aviso] repetido, no se procesa de nuevo: ${clave}`);
return;
}
if (clave) procesados.add(clave);
procesar(aviso);
});
});
});
servidor.listen(PUERTO, () => {
console.log(`Esperando avisos en http://localhost:${PUERTO}${RUTA}`);
});
<?php// Lucky Cobros · servidor mínimo que recibe los avisos (webhooks) y verifica la firma.//// Uso (PHP 7.4 o superior, sin dependencias):// WEBHOOK_SECRET="el-mismo-secreto-que-configuraste" php -S localhost:3000 verificar-firma.php// Escucha en http://localhost:3000/webhooks/cobros// (En Windows PowerShell: $env:WEBHOOK_SECRET="..."; php -S localhost:3000 verificar-firma.php)//// Reglas que sigue este ejemplo:// 1. La firma se calcula sobre el cuerpo CRUDO (php://input). No uses json_encode para rearmarlo.// 2. La comparación es en tiempo constante (hash_equals).// 3. Responde 200 enseguida y procesa después.// 4. Deduplica: el mismo aviso no se procesa dos veces.declare(strict_types=1);
const RUTA = '/webhooks/cobros';
$secreto = getenv('WEBHOOK_SECRET') ?: '';
if ($secreto === '') {
http_response_code(500);
error_log('Falta WEBHOOK_SECRET: usá el mismo secreto que configuraste con PUT /external/webhook.');
exit;
}
/** true si el encabezado X-Webhook-Signature corresponde a este cuerpo y este secreto. */
function firmaValida(string $secreto, string $cuerpoCrudo, string $encabezado): bool
{
$recibida = strtolower((string) preg_replace('/^sha256=/i', '', trim($encabezado)));
if (!preg_match('/^[0-9a-f]{64}$/', $recibida)) {
returnfalse;
}
$esperada = hash_hmac('sha256', $cuerpoCrudo, $secreto);
return hash_equals($esperada, $recibida);
}
/** Clave para no procesar dos veces el mismo aviso (null = no hay código para deduplicar). */
function claveDeDuplicado(array$aviso): ?string
{
$evento = (string) ($aviso['event'] ?? '');
$d = $aviso['data'] ?? [];
// Un mismo cobro puede recibir transaction.in_review y después confirmed o refunded: la clave lleva el evento.if (strpos($evento, 'transaction.') === 0) {
returnisset($d['id']) ? $evento . ':' . $d['id'] : null;
}
if (strpos($evento, 'withdrawal.') === 0) {
returnisset($d['coelsa_id']) ? $evento . ':' . $d['coelsa_id'] : null;
}
returnnull;
}
/** Devuelve true la primera vez que ve la clave. En producción, usá tu base con un índice único. */
function primeraVez(string $clave): bool
{
// Fuera de la carpeta pública del servidor web: si quedara adentro, se podría descargar.$archivo = sys_get_temp_dir() . '/lucky-cobros-avisos-procesados.txt';
$fp = fopen($archivo, 'c+');
flock($fp, LOCK_EX);
$vistos = array_filter(explode("\n", (string) stream_get_contents($fp)));
$nueva = !in_array($clave, $vistos, true);
if ($nueva) {
fseek($fp, 0, SEEK_END);
fwrite($fp, $clave . "\n");
}
flock($fp, LOCK_UN);
fclose($fp);
return$nueva;
}
function procesar(array$aviso): void
{
$d = $aviso['data'] ?? [];
switch ($aviso['event']) {
case'transaction.confirmed':
// Ya está en tu saldo: acreditá $d['monto'] al jugador con CUIT $d['contraparte_cuil_cuit'].
error_log(sprintf('[cobro] %s · acreditar $ %s de %s (%s) · código %s', $aviso['event'], $d['monto'],
$d['contraparte_nombre'] ?? '', $d['contraparte_cuil_cuit'] ?? '', $d['codigo_coelsa'] ?? ''));
break;
case'transaction.in_review':
case'transaction.refunded':
// No acredites. in_review: queda retenido hasta que tu equipo lo resuelva en el panel (después llega// confirmed o refunded con el mismo data.id). refunded: se le devolvió a quien transfirió.
error_log(sprintf('[cobro] %s · no se acredita · $ %s de %s · motivo %s', $aviso['event'], $d['monto'],
$d['contraparte_nombre'] ?? '', $d['motivo'] ?? '-'));
break;
case'withdrawal.completed':
case'withdrawal.pending':
case'withdrawal.failed':
// Buscá tu retiro abierto por cbu + monto (o por coelsa_id) y actualizá su estado.
error_log(sprintf('[retiro] %s · status %s · $ %s a %s', $aviso['event'], $d['status'], $d['monto'], $d['cbu']));
break;
default:
error_log('[aviso] evento nuevo, lo ignoramos: ' . $aviso['event']);
}
}
$ruta = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || $ruta !== RUTA) {
http_response_code(404);
exit;
}
$cuerpoCrudo = (string) file_get_contents('php://input');
$firma = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
header('Content-Type: application/json');
if (!firmaValida($secreto, $cuerpoCrudo, $firma)) {
http_response_code(401);
echo'{"ok":false,"motivo":"firma inválida"}';
exit;
}
$aviso = json_decode($cuerpoCrudo, true);
if (!is_array($aviso) || !isset($aviso['event'])) {
http_response_code(400);
echo'{"ok":false,"motivo":"JSON inválido"}';
exit;
}
// 1) Respondé 2xx rápido.
http_response_code(200);
echo'{"ok":true}';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // con PHP-FPM, corta la respuesta acá y sigue procesando
} else {
flush();
}
// 2) Procesá después, sin repetir.$clave = claveDeDuplicado($aviso);
if ($clave !== null && !primeraVez($clave)) {
error_log('[aviso] repetido, no se procesa de nuevo: ' . $clave);
exit;
}
procesar($aviso);
"""Lucky Cobros · servidor mínimo que recibe los avisos (webhooks) y verifica la firma.
Uso (Python 3.8 o superior, solo biblioteca estándar):
WEBHOOK_SECRET="el-mismo-secreto-que-configuraste" python verificar-firma.py
Escucha en http://localhost:3000/webhooks/cobros (cambiá el puerto con PORT=8080).
(En Windows PowerShell: $env:WEBHOOK_SECRET="..."; python verificar-firma.py)
Reglas que sigue este ejemplo:
1. La firma se calcula sobre el cuerpo CRUDO (bytes). No lo parsees ni lo vuelvas a serializar antes.
2. La comparación es en tiempo constante (hmac.compare_digest).
3. Responde 200 enseguida y procesa después.
4. Deduplica: el mismo aviso no se procesa dos veces.
"""import hashlib
import hmac
import json
import os
import re
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRETO = os.environ.get("WEBHOOK_SECRET", "")
PUERTO = int(os.environ.get("PORT", "3000"))
RUTA = "/webhooks/cobros"ifnot SECRETO:
sys.exit("Falta WEBHOOK_SECRET: usá el mismo secreto que configuraste con PUT /external/webhook.")
HEX64 = re.compile(r"^[0-9a-f]{64}$")
def firma_valida(secreto: str, cuerpo_crudo: bytes, encabezado) -> bool:
"""True si el encabezado X-Webhook-Signature corresponde a este cuerpo y este secreto."""ifnot encabezado:
returnFalse
recibida = re.sub(r"^sha256=", "", encabezado.strip(), flags=re.IGNORECASE).lower()
ifnot HEX64.match(recibida):
returnFalse
esperada = hmac.new(secreto.encode("utf-8"), cuerpo_crudo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, recibida)
def clave_de_duplicado(aviso: dict):
"""Clave para no procesar dos veces el mismo aviso (None = no hay código para deduplicar)."""
evento = aviso.get("event", "")
d = aviso.get("data") or {}
# Un mismo cobro puede recibir transaction.in_review y después confirmed o refunded: la clave lleva el evento.if evento.startswith("transaction."):
returnf"{evento}:{d['id']}"if d.get("id") elseNoneif evento.startswith("withdrawal."):
returnf"{evento}:{d['coelsa_id']}"if d.get("coelsa_id") elseNonereturnNone# En producción guardá las claves en tu base, con un índice único. Acá alcanza con memoria.
procesados = set()
candado = threading.Lock()
def procesar(aviso: dict) -> None:
evento, d = aviso["event"], aviso.get("data") or {}
if evento == "transaction.confirmed":
# Ya está en tu saldo: acreditá d["monto"] al jugador con CUIT d["contraparte_cuil_cuit"].
print(f"[cobro] {evento} · acreditar $ {d.get('monto')} de {d.get('contraparte_nombre')} "f"({d.get('contraparte_cuil_cuit')}) · código {d.get('codigo_coelsa')}", flush=True)
elif evento in ("transaction.in_review", "transaction.refunded"):
# No acredites. in_review: queda retenido hasta que tu equipo lo resuelva en el panel (después llega# confirmed o refunded con el mismo data.id). refunded: se le devolvió a quien transfirió.
print(f"[cobro] {evento} · no se acredita · $ {d.get('monto')} de {d.get('contraparte_nombre')} "f"· motivo {d.get('motivo') or '-'}", flush=True)
elif evento.startswith("withdrawal."):
# Buscá tu retiro abierto por cbu + monto (o por coelsa_id) y actualizá su estado.
print(f"[retiro] {evento} · status {d.get('status')} · $ {d.get('monto')} a {d.get('cbu')}", flush=True)
else:
print(f"[aviso] evento nuevo, lo ignoramos: {evento}", flush=True)
def procesar_sin_repetir(aviso: dict) -> None:
clave = clave_de_duplicado(aviso)
if clave:
with candado:
if clave in procesados:
print(f"[aviso] repetido, no se procesa de nuevo: {clave}", flush=True)
return
procesados.add(clave)
procesar(aviso)
class Receptor(BaseHTTPRequestHandler):
def _responder(self, codigo: int, cuerpo: dict) -> None:
datos = json.dumps(cuerpo, ensure_ascii=False).encode("utf-8")
self.send_response(codigo)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(datos)))
self.end_headers()
self.wfile.write(datos)
def _leer_cuerpo(self) -> bytes:
"""Cuerpo crudo, venga con Content-Length o en partes (Transfer-Encoding: chunked)."""if"chunked"in (self.headers.get("Transfer-Encoding") or"").lower():
partes = []
whileTrue:
tam = int(self.rfile.readline().split(b";")[0].strip() orb"0", 16)
if tam == 0:
while self.rfile.readline() notin (b"\r\n", b"\n", b""):
pass# encabezados finales, si los hayreturnb"".join(partes)
partes.append(self.rfile.read(tam))
self.rfile.readline() # fin de línea de cada partereturn self.rfile.read(int(self.headers.get("Content-Length") or0))
def do_GET(self): # los avisos siempre llegan por POST
self._responder(404, {"ok": False})
def do_POST(self):
if self.path.split("?")[0] != RUTA:
self._responder(404, {"ok": False})
return
cuerpo_crudo = self._leer_cuerpo()
ifnot firma_valida(SECRETO, cuerpo_crudo, self.headers.get("X-Webhook-Signature")):
self._responder(401, {"ok": False, "motivo": "firma inválida"})
returntry:
aviso = json.loads(cuerpo_crudo.decode("utf-8"))
except ValueError:
self._responder(400, {"ok": False, "motivo": "JSON inválido"})
return# 1) Respondé 2xx rápido. 2) Procesá después, sin repetir.
self._responder(200, {"ok": True})
threading.Thread(target=procesar_sin_repetir, args=(aviso,), daemon=True).start()
def log_message(self, formato, *args): # silencia el log por pedidopassif __name__ == "__main__":
print(f"Esperando avisos en http://localhost:{PUERTO}{RUTA}", flush=True)
ThreadingHTTPServer(("0.0.0.0", PUERTO), Receptor).serve_forever()
Entregas y buenas prácticas
Reglas de entrega
Tema
Cómo funciona hoy
Éxito
Cualquier respuesta 2xx. El cuerpo de tu respuesta se ignora.
Reintentos
Cada aviso se envía una vez. Si tu endpoint no responde 2xx, ese aviso no se vuelve a mandar. Los reintentos automáticos están en Próximamente.
Redirecciones
Se siguen. Igual configurá la URL final para no depender de eso.
Orden
No está garantizado entre eventos distintos. Un retiro puede recibir withdrawal.pending y después withdrawal.completed.
Identificador del aviso
No hay un ID de evento: deduplicá por event + data.id en los cobros y por event + data.coelsa_id en los retiros.
Respondé 2xx en menos de 2 segundos. Guardá el aviso y procesalo en segundo plano. El aviso de un retiro se envía antes de que termine la respuesta de /transfer, así que tu demora también demora esa respuesta.
Verificá la firma en cada aviso y descartá los que no coinciden.
Deduplicá. Cobros: por event + data.id, porque un mismo cobro puede recibir transaction.in_review y después transaction.confirmed o transaction.refunded. Retiros: por event + data.coelsa_id cuando viene; si no viene, aplicá el cambio de estado solo si tu retiro sigue abierto.
Conciliá todos los días con GET /external/transactions. Como hoy no hay reintentos, es la forma de recuperar un aviso que no te llegó (por ejemplo, si tu servidor estaba caído).
Acreditá solo con transaction.confirmed, y siempre por data.monto, no por el monto esperado.
Ignorá los eventos que no conocés y respondé 2xx igual: vamos a sumar eventos nuevos sin cambiar de versión.
08
Guías paso a paso
Las tres integraciones que hace todo operador.
Integrar cobros con depósito esperado
Los pasos 5 y 6 dependen de la acreditación automática
La acreditación automática de cobros y los avisos transaction.* están en Próximamente. Podés construir y probar todo el flujo ahora; hasta que te confirmemos que está activa en tu cuenta, ningún cobro se acredita ni te avisa.
Configurá el webhook con tu URL y un secreto (PUT /external/webhook) y publicá el receptor.
Pedí el CUIT del jugador una sola vez, en su registro o en su primer depósito. Tiene que ser el titular de la cuenta desde la que va a transferir.
Cuando el jugador quiera depositar, creá un depósito esperado con el monto, el CUIT y el tipo de tráfico.
Mostrale el CVU que te devolvió la API, el monto y un aviso claro: "Transferí desde una cuenta a tu nombre".
Acreditá solo al recibir transaction.confirmed, siempre por data.monto.
Con transaction.in_review, no acredites: mostrale al jugador que su depósito está en revisión. Cuando tu equipo lo resuelva en el panel, te llega transaction.confirmed o transaction.refunded con el mismo data.id.
Conciliá cada noche con GET /external/transactions para recuperar cualquier aviso que no te haya llegado.
// 1) El jugador pide depositar $ 25.000 en tu sitioconst respuesta = await fetch('https://api.luckycobros.com.ar/api/v1/external/deposit/create', {
method: 'POST',
headers: {
'X-API-Key': process.env.LUCKY_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
monto: 25000,
contraparte_cuil_cuit: jugador.cuit, // 11 dígitos, sin guiones
traffic_type: jugador.yaDeposito ? 'std' : 'ftd',
}),
});
const deposito = await respuesta.json(); // hoy solo llegan id, cvu y expires_at// 2) Guardalo y mostrale el CVU al jugadorawait db.depositos.insert({
id: deposito.id, jugadorId: jugador.id, cuit: jugador.cuit,
monto: 25000, cvu: deposito.cvu, estado: 'esperando',
});
mostrar('Transferí $ 25.000,00 al CVU ' + deposito.cvu + ' desde una cuenta a tu nombre.');
import os
import requests
# 1) El jugador pide depositar $ 25.000 en tu sitio
deposito = requests.post(
"https://api.luckycobros.com.ar/api/v1/external/deposit/create",
headers={"X-API-Key": os.environ["LUCKY_API_KEY"]},
json={
"monto": 25000,
"contraparte_cuil_cuit": jugador["cuit"], # 11 dígitos, sin guiones"traffic_type": "std"if jugador["ya_deposito"] else"ftd",
},
timeout=30,
).json() # hoy solo llegan id, cvu y expires_at# 2) Guardalo y mostrale el CVU al jugador
db.depositos.insert(id=deposito["id"], jugador_id=jugador["id"], cuit=jugador["cuit"],
monto=25000, cvu=deposito["cvu"], estado="esperando")
Node.js · al recibir el aviso
// 3) En tu receptor de avisos (después de verificar la firma y responder 200)asyncfunction alRecibirCobro(aviso) {
const cobro = aviso.data;
const clave = aviso.event + ':' + cobro.id; // un cobro puede pasar por in_review antesif (await db.avisos.existe(clave)) return; // ya procesadoawait db.transaccion(async (tx) => {
await tx.avisos.insert({ clave });
if (aviso.event === 'transaction.in_review') {
// Retenido: NO acredites. Mostrale al jugador que su depósito está en revisión.await tx.revision.insert({ cobroId: cobro.id, motivo: cobro.motivo, monto: cobro.monto });
return;
}
if (aviso.event === 'transaction.refunded') {
await tx.revision.cerrar(cobro.id, 'devuelto'); // se devolvió: no se acreditareturn;
}
if (aviso.event !== 'transaction.confirmed') return; // evento que no conocés: ignoralo// transaction.confirmed: ya está en tu saldo. Si venía de revisión, cerrala.await tx.revision.cerrar(cobro.id, 'acreditado');
// Misma regla que usa Lucky Cobros: el depósito pendiente más viejo de ese CUIT.// No des por vencidos tus depósitos por expires_at: para nosotros siguen pendientes.const deposito = await tx.depositos.masViejoPendiente({ cuit: cobro.contraparte_cuil_cuit });
if (deposito) {
await tx.depositos.actualizar(deposito.id, { estado: 'acreditado', recibido: cobro.monto });
await tx.jugadores.acreditar(deposito.jugadorId, cobro.monto); // siempre data.monto
} else {
await tx.pendientesDeAsignar.insert({ cobroId: cobro.id, cuit: cobro.contraparte_cuil_cuit, monto: cobro.monto });
}
});
}
Integrar retiros
Generá una clave por retiro y guardala con el retiro en tu base antes de llamar a la API. Usá el ID del retiro: RT-000398.
Validá el destino con /transfer/verify y, si viene el titular, mostralo para confirmar.
Interpretá la respuesta con la tabla de resultados: done, pending, error, duplicate, 400, 409 o 500.
Escuchá los avisoswithdrawal.completed, withdrawal.pending y withdrawal.failed para cerrar los que quedaron abiertos.
Ante cualquier duda, reintentá con la misma clave, nunca con una nueva. Si a los 10 minutos sigue sin confirmarse, escribinos con la clave.
Node.js · flujo completo de un retiro
// Flujo recomendado para un retiro (pseudocódigo en Node.js)asyncfunction retirar(jugador, monto, cbu) {
// 1) Validá el destino (opcional) y mostrale el titular a quien pide el retiro// 2) Creá el retiro en TU base primero, con su ID estableconst retiro = await db.retiros.insert({ id: 'RT-000398', jugadorId: jugador.id, cbu, monto, estado: 'pedido' });
// 3) Pedilo a la API (ver pedirRetiro() en la referencia de POST /external/transfer)const resultado = await pedirRetiro({ id: retiro.id, cbu, monto, concepto: 'Retiro de premios' });
// El aviso suele llegar ANTES que esta respuesta: guardá el resultado solo si nadie cerró el retiroawait db.retiros.actualizarSiEstado(retiro.id, 'pedido', { estado: resultado });
// 4) Si quedó 'en_duda' o 'consultar', NO generes otro retiro: reintentá más tarde con el MISMO id// o esperá withdrawal.completed. Si pasa a requiere_revision no llega aviso: escribinos a soporte.
}
// 5) En tu receptor de avisosasyncfunction alRecibirRetiro(aviso) {
const d = aviso.data;
// Por cbu + monto entre los abiertos ('pedido', 'pendiente' o 'en_duda'): todavía no tenés el coelsa_idconst retiro = await db.retiros.abierto({ cbu: d.cbu, monto: d.monto });
if (!retiro) return; // ya cerrado: es un aviso repetidoif (aviso.event === 'withdrawal.completed') await db.retiros.actualizar(retiro.id, { estado: 'confirmado', coelsaId: d.coelsa_id });
if (aviso.event === 'withdrawal.failed') await db.retiros.actualizar(retiro.id, { estado: 'fallido' }); // la plata volvióif (aviso.event === 'withdrawal.pending' && d.status === 'unknown') await db.retiros.actualizar(retiro.id, { estado: 'en_duda' });
}
Probar webhooks localmente
Primero probá la firma en tu máquina, sin plata ni API de por medio. Después, con un túnel, recibí un aviso real.
Terminal · prueba sin conexión
# 1) Levantá el receptor de ejemplo con un secreto de prueba
export WEBHOOK_SECRET="whsec_prueba_123"
node ejemplos/verificar-firma.js # o: php -S localhost:3000 ejemplos/verificar-firma.php# o: python ejemplos/verificar-firma.py# 2) En otra terminal, firmá un aviso de prueba y mandalo
BODY='{"event":"transaction.confirmed","data":{"id":"0f5c1e7a-3b2d-4e8f-9a6c-1d2e3f4a5b6c","codigo_coelsa":"MP36D4CPEC9G","monto":25000,"contraparte_nombre":"MICAELA SOSA","contraparte_cuil_cuit":"27402187354","status":"confirmada"}}'
FIRMA=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //')
curl -i"http://localhost:3000/webhooks/cobros" \
-H"Content-Type: application/json" \
-H"X-Webhook-Event: transaction.confirmed" \
-H"X-Webhook-Timestamp: 2026-10-28T17:03:11.482Z" \
-H"X-Webhook-Signature: sha256=$FIRMA" \
--data-raw"$BODY"# → HTTP/1.1 200 OK {"ok":true}# Mandalo dos veces: la segunda vez el receptor avisa que está repetido.# Cambiá un dígito del monto en BODY sin recalcular la firma: tiene que responder 401.
Terminal · aviso real con un túnel
# 3) Exponé tu puerto con un túnel HTTPS (por ejemplo ngrok o cloudflared)
ngrok http 3000
# → https://a1b2-203-0-113-24.ngrok-free.app# 4) Apuntá el webhook a esa URL con el MISMO secreto que usa tu receptor
curl -X PUT "https://api.luckycobros.com.ar/api/v1/external/webhook" \
-H"X-API-Key: $LUCKY_API_KEY" \
-H"Content-Type: application/json" \
-d'{
"callback_url": "https://a1b2-203-0-113-24.ngrok-free.app/webhooks/cobros",
"webhook_secret": "whsec_prueba_123"
}'# 5) Generá un evento real: un retiro de $ 1,00 a una cuenta propia externa# (por ejemplo, la cuenta bancaria de tu empresa, no una de tus cuentas CVU de cobro)# y mirá llegar withdrawal.completed en la consola del receptor.# 6) Al terminar, volvé a configurar tu URL y tu secreto de producción.
Si usás Windows
En PowerShell, definí el secreto con $env:WEBHOOK_SECRET="whsec_prueba_123". Para firmar el aviso de prueba, usá Git Bash o WSL, que traen openssl.
09
Próximamente
Lo que estamos construyendo. Nada de esto está disponible todavía: no lo uses en tu integración.
PróximamenteAcreditación automática de cobrosCada transferencia que entra a tus cuentas CVU se concilia por CUIT con tu depósito esperado: si coincide, se acredita y te llega transaction.confirmed; si no, queda retenida y te llega transaction.in_review. Los depósitos esperados ya se pueden crear.
PróximamenteRevisión de cobrosAcreditar o devolver desde el panel un cobro retenido, con doble control. Al resolverlo te llega transaction.confirmed o transaction.refunded.
PróximamenteEntorno de pruebasUna URL aparte con plata ficticia para probar cobros, retiros y avisos de punta a punta.
PróximamenteRotación por tope mensualCada cuenta CVU deja de asignarse al acercarse a su tope del mes y el reparto sigue entre las demás. Si todas tus cuentas CVU llegaron al tope del mes, GET /external/cbu y POST /external/deposit/create responden 400 con codeALL_ACCOUNTS_AT_LIMIT y no se crea el depósito: mostrale al jugador otro medio de pago o pedí una cuenta CVU nueva. Te avisamos antes, cuando una cuenta llega al 90 %.
PróximamenteVencimiento de depósitos esperadosUn depósito pendiente vence en expires_at y deja de conciliar cobros. Un cobro que entra después de expires_at no se acredita solo: te llega transaction.in_review con motivo sin_deposito, y tu equipo lo acredita o lo devuelve desde A revisar. La vigencia por defecto se va a poder fijar en Integración.
PróximamenteDepósito esperado completoLa respuesta de POST /external/deposit/create va a sumar alias, el titular de la cuenta CVU, monto_esperado, traffic_type y status (pendiente), para que le muestres al jugador a quién le transfiere. Además, un campo opcional jugador_nombre que se ve en el panel (A revisar y Transacciones) y ayuda a tu equipo a resolver un cobro retenido. No cambia cómo se concilia.
PróximamenteCobrar en una cuenta CVU puntualUn campo opcional cuenta_id en POST /external/deposit/create para que el jugador transfiera a una cuenta CVU puntual (por ejemplo, la de su sucursal). Si no lo mandás, sigue la rotación automática. Si la cuenta no es tuya responde 404 (ACCOUNT_NOT_FOUND); si está pausada o llegó al tope del mes, 400 (ACCOUNT_NOT_OPERATIVE) y no se crea el depósito.
PróximamenteReintentos de avisosHasta 6 intentos con espera creciente y un ID único por evento para deduplicar.
PróximamenteFirma con horaLa hora de envío dentro de la firma, para rechazar avisos viejos repetidos.
PróximamenteAvisos más completos
Todos los campos se suman a los actuales, sin quitar ninguno.
Cobros (transaction.*):deposito_id, monto_esperado, traffic_type, cuenta_cvu y cuenta_alias de la cuenta CVU que recibió la plata, y la fecha de la transferencia. motivo sigue como ya está definido.
Retiros (withdrawal.*): el id del retiro, tu idempotency_key, la fecha de pedido y la de cierre, el titular de destino y la cuenta CVU de origen. En withdrawal.failed, además, el motivo (code, por ejemplo DESTINATION_REJECTED) con un texto legible.
Origen: campo origen con api o panel (y la sucursal, si salió del panel), para que tu sistema no le descuente al jugador un retiro manual que hizo tu equipo, o lo concilie aparte.
Rechazo posterior: si un retiro aceptado (withdrawal.pending, statuspending) después lo rechaza la entidad de destino, la plata vuelve a tu saldo y te llega withdrawal.failed con el mismo id y motivo DESTINATION_REJECTED.
Con deposito_id e idempotency_key, los receptores de ejemplo van a cruzar cada aviso directo con tu pedido, en vez de buscar el depósito pendiente más viejo de ese CUIT o el retiro por CBU y monto.
PróximamenteURL de aviso por sucursalUna URL de webhook propia para cada sucursal: los avisos de los cobros y retiros de sus cuentas CVU van a esa URL en vez de la de tu empresa. Una sucursal sin URL propia sigue usando la general. Se configura en Integración › Webhook.
PróximamentePausar avisosPausar el webhook desde Integración sin perder avisos: mientras está pausado no se envía nada, y lo que quedó sin enviar se puede reenviar durante 30 días cuando lo reactivás. También vas a poder quitar la URL.
PróximamenteSecreto generado por la plataformaCambio de seguridad: el secreto de firma se va a generar en Integración › Webhook y se muestra una sola vez. GET /external/webhook lo va a devolver enmascarado y PUT /external/webhook solo va a cambiar la URL, que tiene que ser https y pública. Mientras tanto, generalo vos como indica Configuración.
PróximamenteComisiones y saldo netoGET /external/balance va a sumar comisiones_pendientes por cuenta CVU y en total, con saldo_neto = saldo_real − comisiones_pendientes. Los retiros se validan contra el saldo neto: es lo que podés retirar. Los retiros no tienen costo.
PróximamenteConsulta de retirosGET /external/transfers/{id} y GET /external/transfers?idempotency_key=RT-000398, con status, monto, destino, titular, cuenta CVU de origen, fecha de pedido y de cierre, y código de la transferencia. Además, POST /external/transfer va a devolver el id del retiro y tu idempotency_key. Cuando salga, en Retiros en duda vas a consultar el retiro así, en vez de buscarlo en /transactions.
PróximamenteElegir la cuenta de origen de un retiroUn campo opcional cuenta_id en POST /external/transfer para que el retiro salga de una cuenta CVU puntual (por ejemplo, la de una sucursal). Si no lo mandás, sale de la de mayor saldo, como hoy. Si la cuenta no es tuya responde 404ACCOUNT_NOT_FOUND; si no está operativa, 400ACCOUNT_NOT_OPERATIVE.
PróximamenteRetiros repartidosUn retiro que toma saldo de varias cuentas CVU cuando ninguna alcanza sola. Usás una sola idempotency_key para el total; la respuesta trae el reparto (cuenta CVU y monto de cada parte) y lo consultás con GET /external/transfers/{id}, con el estado del total y de cada parte. withdrawal.completed sale cuando se completó el total.
PróximamenteRetiro solo al titularCampos opcionales titular_cuit y titular_nombre en POST /external/transfer. Se guardan con el retiro y vuelven en la consulta del retiro y en los avisos withdrawal.*. Cuando la validación del destino confirme el titular (ver Retiros a un alias), si el CUIT del destino no coincide con el que mandaste, el retiro no sale y responde 422DESTINATION_HOLDER_MISMATCH, sin mover plata. Así un premio se paga solo a la cuenta del propio jugador.
PróximamenteRetiros a un aliasMandar el destino como alias, además de CBU/CVU de 22 dígitos, en POST /external/transfer y POST /external/transfer/verify. La validación también te dice la entidad de destino y si es CBU o CVU, y confirma que la cuenta existe (no solo el formato). Se va a poder usar con una key de solo lectura.
PróximamenteRetiros a tus propias cuentas CVUUn retiro cuyo destino es uno de tus CVU de cobro va a responder 400SELF_TRANSFER («el destino es una de tus cuentas CVU de cobro»), sin mover plata. Para tus pruebas de hoy, usá una cuenta externa (ver Cómo probar mientras tanto).
PróximamenteCuentas CVU por APIGET /external/accounts (filtro por estado, paginado) y GET /external/accounts/{id}, con CVU, alias, nombre interno, titular, saldo, tope del mes, acumulado del mes, disponible hasta el tope y estado (operativa, pausada o al tope).
PróximamenteValidación de datosRespuestas 400 que indican qué campo corregir en un retiro o en un depósito esperado.
PróximamenteFiltros y resúmenes
Filtros de GET /external/transactions:tipo (cobro o pago), status, from y to, cuenta_id y q para buscar por CUIT o CUIL, nombre, CBU, código de la transferencia o tu idempotency_key, con los mismos nombres que los avisos.
Tope de paginación:limit máximo 100 (si pedís más, se usa 100) y 400INVALID_PAGINATION para valores no enteros o negativos.
Resumen del período:GET /external/transactions/summary?from&to&cuenta_id → cobros {total, cantidad}, retiros {total, cantidad}, comisiones {total} y neto.
Movimiento por día:GET /external/transactions/daily?from&to, con los días sin movimiento en cero (por defecto, los últimos 30 días).
PróximamenteTransacciones v2
Las filas de GET /external/transactions en snake_case y con los mismos nombres que los avisos: id, codigo_coelsa, referencia, contraparte_nombre, contraparte_cuil_cuit, contraparte_cbu, monto, monto_esperado, traffic_type y status, más comision, concepto, cuenta {cvu, alias}, origen (api o panel), motivo y fecha, sin identificadores internos.
Se va a publicar como /api/v2 o con un parámetro de formato, sin romper a quien ya integró el formato actual.
PróximamenteCódigos de error y límites
Límites de pedidos por key:
Límites de pedidos previstos
Pedidos
Límite
Consultas
1.000 pedidos por minuto por key
Retiros y depósitos esperados
100 pedidos por minuto (POST /external/transfer y POST /external/deposit/create)
Cada respuesta va a traer X-RateLimit-Limit y X-RateLimit-Remaining. Si te pasás, recibís 429 con codeRATE_LIMITED y Retry-After: esperá lo que indica Retry-After; en un retiro, reintentá con la misma idempotency_key.
Códigos de error: un campo code que se suma a los cuerpos actuales, sin romper la v1 (message, statusCode y error siguen igual). Hoy el único que existe es IP_NOT_WHITELISTED.
Códigos de error previstos
code
HTTP
Qué hacer
MISSING_API_KEY
401
Mandá el encabezado X-API-Key.
INVALID_API_KEY
401
La key no existe o fue revocada: usá una vigente.
IP_NOT_WHITELISTED
403
Ya existe hoy. Pedí desde una IP habilitada o sumala a la lista de la key.
INSUFFICIENT_PERMISSIONS
403
La key no tiene el permiso del endpoint (read o write).
INVALID_AMOUNT
400
El monto tiene que ser un número mayor a cero.
INVALID_DESTINATION
400
El destino no es un CBU/CVU de 22 dígitos ni un alias válido. Corregilo.
INVALID_CUIT
400
El CUIT o CUIL tiene que tener 11 dígitos, sin guiones.
INVALID_PAGINATION
400
page y limit tienen que ser enteros positivos (limit hasta 100).
MISSING_IDEMPOTENCY_KEY
400
Todo retiro lleva idempotency_key: generala y guardala antes de llamar.
DUPLICATE_REQUEST
409
Ya existe un retiro con esa idempotency_key. Consultá el original; no lo repitas con otra clave.
WITHDRAWAL_IN_PROGRESS
409
Hay otro retiro saliendo desde esa cuenta CVU. Esperá unos segundos y reintentá con la misma idempotency_key.
NO_ACTIVE_ACCOUNTS
400
No tenés cuentas CVU operativas: reactivá una o pedí una nueva.
ALL_ACCOUNTS_AT_LIMIT
400
Todas tus cuentas CVU llegaron al tope del mes: ofrecele al jugador otro medio de pago o pedí una cuenta CVU nueva.
ACCOUNT_NOT_FOUND
404
El cuenta_id no es de una cuenta CVU tuya. Revisalo.
ACCOUNT_NOT_OPERATIVE
400
La cuenta CVU elegida está pausada o al tope: elegí otra o no mandes cuenta_id.
INSUFFICIENT_BALANCE
400
No alcanza el saldo neto para ese retiro: retirá menos o esperá nuevos cobros.
SELF_TRANSFER
400
El destino es una de tus cuentas CVU de cobro: elegí una cuenta externa.
DESTINATION_HOLDER_MISMATCH
422
El CUIT del titular del destino no coincide con titular_cuit. No se movió plata: revisá el destino con el jugador.
RATE_LIMITED
429
Esperá lo que indica Retry-After; en un retiro, reintentá con la misma idempotency_key.
DESTINATION_REJECTED
422
La entidad de destino rechazó el retiro y la plata volvió a tu saldo. Revisá el destino antes de pedirlo de nuevo.
OUTCOME_UNKNOWN
500 · 504
Retiro en duda: consultalo antes de reintentar y, si reintentás, usá la misma idempotency_key. Ver Retiros en duda.
INTERNAL_ERROR
500
Error de nuestro lado. En consultas, reintentá con espera (1 s, 5 s, 30 s).
PróximamenteIPs por rangoCargar un rango en notación CIDR (por ejemplo 203.0.113.0/28) además de IPs sueltas, para servidores que salen por más de una IP. Mientras tanto, cargá cada IP exacta (ver IPs permitidas).
PróximamenteSesiones del panelRecordar un equipo de confianza para no pedir el código en cada ingreso, y un código por correo como respaldo de la app de autenticación.
PróximamenteDocumentación en inglésLa misma referencia, guías y ejemplos en inglés, para equipos técnicos de otros países.
Cuando algo de esta lista salga, lo vas a ver en el changelog. Los cambios que rompan compatibilidad van en una versión nueva de la URL (/api/v2), con aviso previo.
10
Changelog
v1.0 · primera versión pública
28/10/2026
Autenticación por X-API-Key con permisos read y write e IPs permitidas.
Saldos y cuentas CVU: GET /external/balance y GET /external/cbu con rotación de CVU.
Cobros: POST /external/deposit/create con tipo de tráfico FTD/STD. La acreditación automática y la conciliación por CUIT están en Próximamente.
Retiros: POST /external/transfer con idempotency_key y POST /external/transfer/verify.
Movimientos: GET /external/transactions paginado.
Webhooks firmados con HMAC-SHA256: 3 eventos de retiro. Los 3 de cobro (transaction.confirmed, transaction.in_review y transaction.refunded) ya están definidos y se envían con la acreditación automática. Configuración con GET y PUT /external/webhook.
Especificación OpenAPI 3.1, colección Postman y receptores de ejemplo en Node.js, PHP y Python.