Payouts — qué devuelve Liqfy cuando uno no se concreta
Este documento cierra el contrato de fallos de payout: qué recibes, cuándo y qué hacer en cada caso. Impleméntalo una vez; no deberías tener que volver a revisarlo.
Hay dos momentos en los que un payout puede fallar, y se comportan de forma muy distinta. No los manejes en la misma ruta de código. Un tercer caso es menos frecuente: un payout que fue pagado y luego devuelto por la institución receptora (ver sección 4).
#1. Rechazo inmediato — el saldo de tu cuenta no alcanza
Ocurre de forma síncrona, en la respuesta de POST /v1/withdrawals. No se crea ningún payout,
no sale dinero de tu billetera y tu idempotencyKey NO se consume.
POST /v1/withdrawals
HTTP/1.1 400 Bad Request
{
"error": {
"type": "invalid_request_error",
"code": "INSUFFICIENT_BALANCE",
"message": "INSUFFICIENT_BALANCE",
"details": { "available": 1000, "requested": 100005, "currency": "BRL" }
},
"request_id": "req_..."
}Ramifica según error.code. El message es para tus logs; el código es el
contrato.
| Campo | Qué es |
|---|---|
error.code | Siempre INSUFFICIENT_BALANCE aquí. Constante estable. |
details.available | Saldo disponible para gastar, en la unidad menor de la moneda (centavos para BRL). |
details.requested | Lo que saldría de la billetera: amount + comisión. No el amount que enviaste. |
details.currency | ISO 4217 de la billetera que se evaluó. |
#Tres cosas que te ahorran un ticket de soporte
La comisión se cobra ADEMÁS del monto. Para retirar amount, la billetera necesita
amount + fee (amountType tiene como valor predeterminado "gross"). Por eso requested
viene ya sumado — sin eso verías "pedí 1.000, tengo 1.000" y el
rechazo parecería un error. (El modo heredado amountType: "net" todavía se
acepta en POST /v1/withdrawals — ver Crear el payout:
el destinatario recibe amount − fee, y ahí requested es solo el amount.
No se acepta para retiros CRYPTO.)
Solo cuenta available. Es el saldo disponible para gastar. pending (liquidado pero
aún retenido) no cuenta para un payout y nunca lo cubre. Si la
cuenta tiene más de una billetera en la misma moneda, available es el
mayor saldo individual, no la suma — la reserva sale de una sola billetera.
Puedes fijar la billetera con el campo opcional walletId; si lo omites, se usa la
billetera predeterminada de la moneda cuando tiene saldo; de lo contrario, cualquier billetera
disponible con saldo suficiente.
Un rechazo no quema el idempotencyKey. Puedes recargar y reenviar con
la MISMA clave; el payout se procesa normalmente. (Esto ya se cumplía para el rechazo
habitual y, desde el 2026-08-19, también se cumple para la rara condición de carrera en la que el saldo
desaparece entre la verificación y la reserva.)
#Cómo evitar llegar a esto
GET /v1/withdrawals/payout-info?currency= — sin efectos secundarios, hecho para
esto. Devuelve { currency, available, pendingAdjustment, fee: { fixed, variablePct, minimum }, maxWithdrawable }: available es el saldo
disponible para gastar, fee es el conjunto de parámetros aplicado hoy, y maxWithdrawable es
el mayor monto bruto que puedes solicitar con la comisión ya considerada —
el número correcto para un botón de "retirar todo". Contrato completo y ejemplo:
Cotización, comisión y saldo retirable.
#Otros rechazos inmediatos
La misma regla se aplica a todo 4xx síncrono: no se crea nada y nada sale de la billetera.
| HTTP | Código / mensaje | Cuándo |
|---|---|---|
| 400 | errores de validación | El cuerpo no cumple el esquema (p. ej., type fuera de PIX, TED, CRYPTO; amount no es un entero positivo). |
| 400 | Idempotency-Key must be at most 255 characters | El encabezado Idempotency-Key tiene más de 255 caracteres. |
| 4xx | TENANT_KYC_REQUIRED | El registro (KYC) de la cuenta no está aprobado. |
| 4xx | rechazo por regla de retiro | Una regla de retiro configurada para la cuenta rechazó la solicitud. El rechazo puede incluir ruleId y reasonKey (withdrawal.rule.pixKeyTypeNotAllowed o withdrawal.rule.notAllowed). |
| 403 | CRYPTO_PANEL_ONLY | Un retiro CRYPTO enviado con una API key — cripto es solo desde el panel. |
| 400 | INVALID_CRYPTO_WITHDRAWAL | Un retiro CRYPTO fuera de sus reglas (solo BRL, mínimo R$ 5.000,00, sin net, se requieren dirección de billetera y estimación). |
Todo rechazo 4xx deja rastro. Se registra con monto, motivo y
origen (API o panel) y aparece en la pantalla de retiros y en el registro de
auditoría (WITHDRAWAL_REJECTED). La misma solicitud repetida con la misma
clave de idempotencia y el mismo código se registra como máximo una vez por hora. Un 5xx es
un fallo nuestro, no un rechazo, y no se registra de esta forma.
#2. Rechazo posterior — el payout fue aceptado y falló en el procesamiento
Aquí el POST devolvió 201 con status: "PENDING", y el dinero ya
salió de tu billetera (está reservado). El resultado llega después, de dos formas:
- mediante el webhook
payout.failed; - o consultando periódicamente
GET /v1/withdrawals/{id}(po_...o el id sin prefijo). La listaGET /v1/withdrawals?status=failedacepta las mismas palabras en minúsculas que los webhookspayout.*:pending,processing,paid,failed(failed=FAILED,REJECTEDyCANCELLED). Un valor desconocido devuelve 400.
Los campos que importan:
| Campo | Qué es |
|---|---|
status | FAILED, REJECTED o CANCELLED (failed en el payout canónico) |
failure_code | Código estable, en minúsculas y legible por máquina. Ramifica tu flujo según este. Presente cuando el payout está FAILED. |
failure_message | Frase neutra en portugués para ese código. Segura para mostrar a tu usuario final. |
rejectionReason | Texto libre para logs/soporte (reason en el webhook heredado). En FAILED el valor es el failure_message público (desde el 2026-10-21); en REJECTED/CANCELLED todavía puede traer el texto técnico de la institución receptora. No escribas ifs basados en él. |
legs[].failureCode | Solo payouts divididos: el motivo de cada parte. Vocabulario cerrado: INVALID_PIX_KEY, ACCOUNT_CLOSED, ACCOUNT_BLOCKED, KYC_REJECTED, LIMIT_REJECTED, DESTINATION_UNAVAILABLE, DESTINATION_NOT_ALLOWED, INSUFFICIENT_BALANCE, TIMEOUT, PROVIDER_CANCELLED, PROVIDER_REJECTED, UNKNOWN. |
Dónde aparece cada uno:
| Superficie | failure_code / failure_message | Texto libre |
|---|---|---|
GET /v1/withdrawals/{id} (y la lista) | failure_code, failure_message en el nivel superior | rejectionReason |
payout.failed | data.object.failure_code, data.object.failure_message | nunca |
{
"id": "evt_...",
"object": "event",
"type": "payout.failed",
"data": {
"object": {
"id": "po_...",
"object": "payout",
"status": "failed",
"failure_code": "destination_unavailable",
"failure_message": "O banco de destino não confirmou o PIX a tempo. Tente novamente mais tarde."
}
}
}#Valores de failure_code
failure_code | failure_message | ¿Vale la pena reintentar? |
|---|---|---|
invalid_pix_key | A chave PIX de destino é inválida ou não foi encontrada. | Solo con otra clave |
account_blocked | A conta de destino está bloqueada para receber PIX. | Solo con otra cuenta |
account_closed | A conta de destino está encerrada ou inativa. | Solo con otra cuenta |
limit_rejected | O valor excede um limite permitido para esta transferência. | Sí, con un monto menor |
kyc_rejected | A transferência foi recusada na verificação cadastral do destinatário. | No |
destination_unavailable | O banco de destino não confirmou o PIX a tempo. Tente novamente mais tarde. | Sí, más tarde: el banco receptor no liquidó a tiempo |
destination_not_allowed | O provedor não aceita esse tipo de destino; use outra chave Pix. | Solo con otro tipo de clave o destino |
payout_cancelled | A transferência foi cancelada antes de ser concluída. | Sí |
provider_rejected | A transferência PIX foi recusada e não foi concluída. | Sí, pero investiga primero |
provider_rejected es el código genérico: todo lo que no podemos atribuir con más
precisión cae ahí. Trata un failure_code desconocido como provider_rejected
— pueden añadirse códigos nuevos, pero nunca se renombran ni se eliminan.
Regla general: un payout FAILED es definitivo y el dinero se
reembolsa automáticamente a tu billetera; no necesitas solicitar nada.
Cuándo llega payout.failed depende de una configuración de la cuenta:
- Solo fallos definitivos (recomendado si reembolsas a tus usuarios al
recibir
payout.failed): todopayout.failedllega con el reembolso ya registrado, así que puedes reembolsar a tu usuario en cuanto lo recibes. Si el reembolso necesita confirmación con el banco,payout.failedespera hasta que se registre; si el PIX salió, llegapayout.paiden su lugar. Pide al soporte que la active. - Predeterminado:
payout.failedse envía de inmediato. Casi siempre el reembolso ya está registrado. Cuando no se puede comprobar que el PIX nunca salió, el reembolso queda retenido hasta confirmarlo con el banco:failure_messagees "Estamos confirmando com o banco; você será avisado." ylegs[].refundedesfalse. No reembolses a tu usuario todavía: consultaGET /v1/withdrawals/{id}o el extracto de la billetera hasta que se registre. Liberarlo no envía otro webhook.
(failure_message no promete un reembolso — el movimiento de la billetera sí.)
#Payouts divididos en tramos
Cada PIX tiene un tope (R$ 15.000,00 por defecto). Un payout que lo supera se divide en varias transferencias PIX — los tramos —, cada uno completado hasta el tope, con el resto en el último (R$ 110.000,00 → 7 × R$ 15.000,00 + 1 × R$ 5.000,00). Cada tramo tiene su propio E2E y puede fallar por sí solo.
Cuando algunos tramos se pagan y otros fallan, el payout termina
PARTIALLY_COMPLETED — paid en el vocabulario público — y los montos de los tramos fallidos
vuelven a tu billetera cuando se registra el reembolso. Revisa legs[] para saber
exactamente qué pasó:
| Campo | Qué es |
|---|---|
legIndex | Número de tramo, empezando en 1. |
amount | Monto del tramo, en centavos. |
status | PENDING, PROCESSING, COMPLETED, FAILED o REVERSED. |
endToEndId | El E2E del tramo, para conciliar con el extracto bancario. |
failureCode | Vocabulario cerrado de arriba; null cuando el tramo no falló. |
refunded | Solo tramo FAILED: true cuando su reembolso ya se registró en la billetera. |
reversalCredited | Solo tramo REVERSED: true cuando el monto devuelto se te acreditó (sección 4). |
reversalEndToEndId, reversedAmount, reversedAt | Datos de la devolución, en un tramo REVERSED. |
Un tramo reemplazado por un reenvío nunca aparece en legs[]; su monto ya está
en el tramo que lo reemplazó.
#Cancelar un payout que todavía está PENDING
Mientras el payout está PENDING (todavía no se envió nada — por ejemplo, esperando
aprobación), puedes cancelarlo tú mismo:
POST /v1/withdrawals/{id}/cancel
{ "reason": "optional, up to 500 characters" }Devuelve 200 con el payout como CANCELLED, y el monto vuelve al
saldo. Fuera de PENDING devuelve 409 WITHDRAWAL_NOT_CANCELLABLE. Desde
el panel, solo el titular de la cuenta o un administrador puede cancelar.
#3. Liquidez del procesador
Cuando el procesador que enviaría el PIX no tiene saldo para pagarlo, no es
culpa tuya. Hoy el payout te llega como FAILED con
failure_code: "provider_rejected", y el dinero se reembolsa automáticamente
a tu billetera cuando se registra el movimiento de la billetera (aquí también aplica el reembolso retenido —
ver la regla general de arriba). No hay reintento automático ni ventana de espera:
si quieres, vuelve a solicitar el payout — preferiblemente un poco más tarde.
Qué hacer: mientras un payout está PROCESSING, no repitas la solicitud. Un
nuevo POST crearía un segundo payout y volvería a debitar la billetera. Muestra a tu usuario
final algo como "pago en proceso" y espera el webhook final.
PROCESSING puede durar más de lo habitual cuando todavía no se conoce el resultado del
envío del PIX. En ese caso el payout permanece PROCESSING mientras Liqfy
confirma el resultado con el proveedor: nunca se marca como fallido ni se reembolsa solo
porque pasó el tiempo — únicamente con evidencia positiva de que el PIX no salió.
#4. Pagado y luego devuelto (REVERSED)
Un PIX que ya fue pagado puede ser devuelto por la institución receptora. El
payout (o el tramo) pasa entonces a REVERSED. Este estado no tiene palabra en el
vocabulario público de payout.*: para listarlo, filtra con ?status=REVERSED.
El dinero devuelto no va directamente a tu saldo. Una vez confirmada la devolución
en el extracto del proveedor, entra en una bandeja de reversiones Liqfy
y se te acredita automáticamente solo cuando la política de la marca lo permite y
la devolución no está marcada como fraude o sospecha; de lo contrario, una persona decide —
acreditártelo o retenerlo. Hasta entonces, legs[].reversalCredited es false:
trátalo como "todavía no volvió a tu saldo".
#Resumen de implementación
POST /v1/withdrawals
├── 400 INSUFFICIENT_BALANCE ....... your wallet doesn't cover it. Nothing created.
│ → check /v1/withdrawals/payout-info first
├── other 4xx ...................... refused at entry. Nothing created.
└── 201 PENDING .................... accepted, funds reserved
├── still PENDING .............. you can cancel: POST /v1/withdrawals/{id}/cancel
├── stays PROCESSING ........... do NOT retry. Wait for the webhook.
├── PARTIALLY_COMPLETED (paid) . some legs paid, failed legs refunded
│ (legs[].refunded tells which already landed)
├── REVERSED ................... paid and returned; credit only when
│ legs[].reversalCredited is true
└── webhook payout.failed
└── failure_code = invalid_pix_key | account_* | limit_rejected |
kyc_rejected | destination_unavailable | destination_not_allowed |
payout_cancelled |
provider_rejected (generic / unknown)
→ definitive; the refund is automatic once the wallet
movement lands. If failure_message is "Estamos confirmando
com o banco; você será avisado." the refund is held
(legs[].refunded is false): check the payout or the
statement until it lands. Does not happen with
"final failures only".
Retry only after fixing the cause (e.g. the PIX key), or later
for destination_unavailable.Idempotencia: envía siempre el encabezado Idempotency-Key en
POST /v1/withdrawals (el campo del cuerpo idempotencyKey es una alternativa; el encabezado
prevalece; como máximo 255 caracteres). Es tu protección contra un payout duplicado
en cualquier reintento de red — reenviar la misma clave devuelve el payout original
en lugar de crear otro. Si una solicitud agotó el tiempo de espera y no sabes si
el payout se creó, búscalo por clave: GET /v1/withdrawals/by-idempotency-key/{key} or GET /v1/withdrawals?idempotency_key={key}.