Retiros
Retiros: de fichas a USDT/USDC
Paga a tus usuarios en USDT o USDC desde una caja que tú fondeas: el camino inverso a un depósito.
Los depósitos llevan dinero de tus usuarios a tu empresa. Los retiros hacen lo contrario: tu empresa paga a un usuario en USDT o USDC, a la billetera que él elija. El caso típico: un usuario tiene 500 fichas en tu plataforma (1 ficha = 1 USD) y pide cambiarlas por USDC. Tú descuentas las fichas en tu sistema y llamas a la API; en unos segundos el usuario tiene 500 USDC en su billetera.
- 1
Fondeas tu caja
Envías USDT o USDC a tu dirección de fondeo, desde cualquier billetera o exchange. Es como un depósito, pero al revés: ese dinero queda en tu caja de retiros.
- 2
El usuario pide su retiro
En tu aplicación elige el monto y pega su billetera. Tú descuentas las fichas.
- 3
Llamas a la API
Con Crear retiro envías el token, el monto, la billetera y tu
external_id. El saldo queda reservado al instante. - 4
Recibes el resultado
El webhook
payout.completedconfirma el pago con su transacción. Si llegapayout.failed, no se movió dinero: devuelve las fichas.
Los retiros se activan por token
Hasta que se configuran los límites de un token, sus retiros están apagados y la API responde 422 payouts_disabled. Pide a la plataforma que los fije (o fíjalos tú si tienes billetera administradora propia: ver Límites).
Cómo funciona por dentro#
- Hay un solo contrato de retiros para toda la pasarela, con una caja separada por empresa. El saldo de una empresa no se mezcla con el de otra: el contrato lleva la cuenta de cada una.
- La plataforma solo firma la transacción del pago. El contrato comprueba, en cada retiro, el saldo de tu caja, tus límites y que la comisión no supere la configurada.
- Tu dinero solo puede salir de tu caja de dos formas: un retiro a un usuario (dentro de tus límites) o una devolución a tu tesorería. No existe ninguna otra salida.
- Cada retiro lleva un id en la blockchain que el contrato no acepta dos veces: ni un reintento ni un fallo de red pueden pagar el mismo retiro dos veces.
Fondear la caja#
Consulta tu dirección de fondeo con Ver saldo de retiros (funding_address) o en el panel, en Retiros. Envía ahí USDT o USDC en la red Arbitrum. No hace falta avisar a nadie: el servidor revisa las direcciones de fondeo cada pocos segundos, pasa el dinero a tu caja y te envía el webhook balance.funded.
curl https://api.pasarela.melenashair.com/v1/payouts/balance -H "x-api-key: sk_test_4f9c…"- Mientras el fondeo se acredita, aparece en
funding_pendingy ya cuenta como disponible. - Se recoge a partir de 1 USD por token. Montos menores se quedan en la dirección hasta que se junte 1 USD: así nadie puede hacer gastar gas enviando migajas.
- La dirección de fondeo es permanente y solo sirve para tu empresa. Si por error llega ETH, va entero a tu tesorería.
- Si prefieres fondear desde un contrato o una billetera propia, también puedes llamar a
fund(merchantId, token, amount)del contrato tras unapprove.
Pagar a un usuario#
- 1
Descuenta las fichas
En tu base de datos, antes de llamar a la API. Guarda la operación con un id propio.
- 2
Crea el retiro
POST /v1/payoutscontoken,amount(las fichas, 1 = 1 USD),to(la billetera del usuario) yexternal_id(tu id de la operación). Respuesta201: aceptado y reservado. - 3
Si la llamada falla por red, repítela
Con el mismo
external_id. Si ya se había creado, recibes el mismo retiro (200,created: false). - 4
Si responde 4xx, no se movió nada
Devuelve las fichas al usuario y muéstrale el motivo (
error). El campocodete dice cuál fue. - 5
Espera el webhook
payout.completed: pagado, contx_hash.payout.failed: no se pagó y el saldo vuelve a estar disponible; devuelve las fichas.
1# 1. Cuánto recibiría y cuánto sale de tu caja (opcional)2curl -X POST https://api.pasarela.melenashair.com/v1/payouts/quote \3 -H "x-api-key: sk_test_4f9c…" \4 -H "content-type: application/json" \5 -d '{"token": "USDT", "amount": "500"}'6 7# 2. Pagar. external_id es tu id de la operación: repetirlo nunca paga dos veces8curl -X POST https://api.pasarela.melenashair.com/v1/payouts \9 -H "x-api-key: sk_test_4f9c…" \10 -H "content-type: application/json" \11 -d '{12 "token": "USDT",13 "amount": "500",14 "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68",15 "external_id": "retiro-000981",16 "customer_id": "jugador-77",17 "metadata": {"fichas": 500}18 }'19 20# 3. Consultar por tu propio id21curl https://api.pasarela.melenashair.com/v1/payouts/ext:retiro-000981 -H "x-api-key: sk_test_4f9c…"1{2 "payout": {3 "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11",4 "external_id": "retiro-000981",5 "customer_ref": "jugador-77",6 "status": "pending",7 "chain_id": 421614,8 "token": "USDT",9 "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183",10 "decimals": 6,11 "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68",12 "requested": "500",13 "amount": "500",14 "platform_fee": "5",15 "gas_fee": "0.008419",16 "debit": "505.008419",17 "gas_paid_by": "merchant",18 "tx_hash": null,19 "error": null,20 "metadata": {21 "fichas": 50022 },23 "created_at": "2026-10-14T13:20:05.114Z",24 "sent_at": null,25 "completed_at": null,26 "failed_at": null27 },28 "created": true29}Estados#
| Estado | Qué significa | Qué hacer |
|---|---|---|
pending | Aceptado y en cola. El saldo ya está reservado. | Mostrar "procesando". |
sent | La transacción está enviada a la red. | Esperar (normalmente un par de segundos). |
completed | El usuario ya tiene el dinero. Es definitivo. | Marcar pagado y guardar tx_hash. |
failed | No se pagó nada. El saldo reservado vuelve a la caja. | Devolver las fichas al usuario. |
Un retiro va de pending a completed en unos 3 a 5 segundos. Si una transacción se pierde en la red, el servidor mira en el contrato si el retiro ya se pagó: si sí, lo cierra como completed; si no, lo vuelve a enviar con el mismo id, así que nunca se paga dos veces.
Quién paga comisión y gas#
La plataforma puede cobrar una comisión por retiro (payout_fee_bps, en puntos básicos) y el pago en sí cuesta gas. Quién lo paga lo define gas_paid_by de tu configuración. Ejemplo: el usuario cambia 500 fichas, comisión 1 %, gas 0.008419 USDT.
| `gas_paid_by` | El usuario recibe | Sale de tu caja | Comisión | Gas |
|---|---|---|---|---|
merchant (tu empresa) | 500 | 505.008419 | 5 | 0.008419 |
customer (el usuario) | 494.991581 | 500 | 5 | 0.008419 |
platform | 500 | 505 | 5 | 0 (lo absorbe la plataforma) |
1comisión = ⌊ pedido × payout_fee_bps / 10000 ⌋2 3merchant: recibe = pedido sale de la caja = pedido + comisión + gas4customer: recibe = pedido − comisión − gas sale de la caja = pedido5platform: recibe = pedido sale de la caja = pedido + comisión (gas = 0)- Comisión y gas van a la billetera de la plataforma en la misma transacción del pago.
- El gas se estima con margen al crear el retiro y queda fijo en ese retiro (
gas_fee). Nunca supera el tope por token del contrato (0.5 USD). - El contrato tiene guardada tu comisión de retiros y rechaza cualquier pago que cobre más: el servidor no puede subirla por su cuenta.
- Si con
customerla comisión y el gas se comen el monto, la API responde422 amount_too_small. - Para saber los números antes de pagar, usa Cotizar retiro.
Límites#
Cada token tiene dos límites, guardados en el contrato. Protegen tu caja: aunque alguien se hiciera con la clave del relayer de la plataforma, no podría sacar más que eso por día.
| Límite | Qué controla |
|---|---|
max_per_payout | Lo máximo que puede recibir un usuario en un retiro. En 0, los retiros de ese token están apagados. |
daily_limit | Lo máximo que pueden recibir todos tus usuarios juntos en un día (00:00 a 23:59 UTC). Se renueva solo cada día. |
- Si la plataforma administra tu empresa (no pusiste billetera administradora propia), pídele que los fije o los cambie. Ella usa Fijar límites de retiro.
- Si tienes billetera administradora propia, la plataforma no puede tocarlos: los fijas tú llamando a
setLimits(merchantId, token, maxPerPayout, dailyLimit)del contrato de retiros desde esa billetera. Los montos van en la unidad mínima del token. remaining_todayen el saldo ya descuenta los retiros aceptados que aún no salieron.
Idempotencia#
- Cada retiro necesita una clave: el campo
external_ido la cabeceraIdempotency-Key. Sin ninguna de las dos,400. - Mismo
external_idcon los mismos datos: devuelve el retiro existente (200,created: false) sin crear otro. - Mismo
external_idcon otro token, monto o destino:409 idempotency_conflict. - Además, en la blockchain cada retiro tiene su propio id y el contrato lo rechaza si ya se pagó (
AlreadyPaid). Son dos capas: tu clave evita crear dos retiros y el contrato evita pagar dos veces el mismo.
Seguridad y responsabilidades#
Una transferencia en blockchain no se puede deshacer
La billetera de destino la elige tu usuario y es responsabilidad de cada cliente cuidar su seguridad y escribirla bien. Si envía a una dirección equivocada, nadie (ni tú ni la plataforma) puede recuperar ese dinero. Pídele que la confirme y, si puedes, que pegue la dirección en lugar de escribirla.
- Se rechazan como destino (
422 invalid_recipient): la dirección cero, tu dirección de fondeo, los contratos de la pasarela y cualquier dirección de depósito de la pasarela (el dinero volvería a entrar como un depósito). - Se aceptan billeteras normales (MetaMask, Trust, Rabby), billeteras de exchanges y billeteras inteligentes (Safe y similares), siempre en la red Arbitrum.
- Protege tu clave de API: con ella se pueden crear retiros dentro de tus límites. Por eso conviene fijar límites ajustados a tu operación y fondear la caja con lo que necesitas, no con toda tu tesorería.
Devolver saldo a tu tesorería#
Lo que sobre en la caja puede volver a tu tesorería cuando quieras con Devolver saldo a la tesorería. El contrato solo permite enviarlo a tu tesorería, nunca a otro destino. No se puede devolver el saldo reservado por retiros en curso.
1curl -X POST https://api.pasarela.melenashair.com/v1/payouts/return \2 -H "x-api-key: sk_test_4f9c…" \3 -H "content-type: application/json" \4 -d '{"token": "USDT", "amount": "200"}'Si la plataforma desaparece#
Tu saldo no depende de que la plataforma siga operando. Si tienes billetera administradora propia, llamas a withdraw(merchantId, token, amount) del contrato de retiros desde esa billetera y el dinero va a tu tesorería. Si la plataforma administra tu empresa, también puede devolverlo por ti, y solo a tu tesorería. Ver Sin custodia.
Errores#
Los rechazos de retiros traen, además de error, un campo code estable para que tu código decida sin leer el texto: { "error": "El máximo por retiro es 600 USDT", "code": "above_max_per_payout" }.
| `code` | HTTP | Cuándo |
|---|---|---|
insufficient_balance | 422 | El total que saldría de la caja supera lo disponible (available). Fondea o espera a que se acredite el fondeo. |
payouts_disabled | 422 | Los retiros de ese token están apagados: faltan los límites. |
above_max_per_payout | 422 | Lo que recibiría el usuario supera max_per_payout. |
above_daily_limit | 422 | No queda tanto límite diario hoy (remaining_today). |
below_minimum | 422 | El monto es menor que el mínimo por retiro (min_payout, 1 USD por defecto). |
invalid_recipient | 422 | Destino no permitido (ver Seguridad). |
amount_too_small | 422 | Con gas_paid_by: customer, la comisión y el gas se comen el monto. |
idempotency_conflict | 409 | Ya hay un retiro con ese external_id y datos distintos. |
Si el retiro se aceptó pero el contrato lo rechaza al pagarlo (por ejemplo, porque apagaste tus límites entre medias), llega payout.failed con error.code:
| `error.code` en `payout.failed` | Motivo |
|---|---|
insufficient_balance | La caja ya no tenía saldo suficiente al pagar. |
payouts_disabled | Los límites del token se pusieron en 0 después de aceptar el retiro. |
above_max_per_payout / above_daily_limit | Los límites bajaron después de aceptar el retiro. |
invalid_recipient | El contrato rechazó el destino. |
fee_too_high / gas_above_cap | La comisión o el gas superan lo que permite el contrato. |
unknown_merchant / relayer_not_authorized | Problema de configuración de la plataforma. |
send_failed | La red no aceptó la transacción tras varios intentos. |
Webhooks de retiros#
Llegan a la misma webhook_url que los depósitos, firmados igual. Ver Webhooks.
1{2 "id": "d5b2f8e3-6c4a-4f9b-8d1e-2a3b4c5d6e7f",3 "type": "payout.completed",4 "created_at": "2026-10-14T13:20:08.951Z",5 "data": {6 "payout": {7 "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11",8 "external_id": "retiro-000981",9 "customer_ref": "jugador-77",10 "status": "completed",11 "chain_id": 421614,12 "token": "USDT",13 "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183",14 "decimals": 6,15 "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68",16 "requested": "500",17 "amount": "500",18 "platform_fee": "5",19 "gas_fee": "0.008419",20 "debit": "505.008419",21 "gas_paid_by": "merchant",22 "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c",23 "error": null,24 "metadata": {25 "fichas": 50026 },27 "created_at": "2026-10-14T13:20:05.114Z",28 "sent_at": "2026-10-14T13:20:06.302Z",29 "completed_at": "2026-10-14T13:20:08.947Z",30 "failed_at": null31 }32 }33}