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. 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. 2

    El usuario pide su retiro

    En tu aplicación elige el monto y pega su billetera. Tú descuentas las fichas.

  3. 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. 4

    Recibes el resultado

    El webhook payout.completed confirma el pago con su transacción. Si llega payout.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_pending y 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 un approve.

Pagar a un usuario#

  1. 1

    Descuenta las fichas

    En tu base de datos, antes de llamar a la API. Guarda la operación con un id propio.

  2. 2

    Crea el retiro

    POST /v1/payouts con token, amount (las fichas, 1 = 1 USD), to (la billetera del usuario) y external_id (tu id de la operación). Respuesta 201: aceptado y reservado.

  3. 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. 4

    Si responde 4xx, no se movió nada

    Devuelve las fichas al usuario y muéstrale el motivo (error). El campo code te dice cuál fue.

  5. 5

    Espera el webhook

    payout.completed: pagado, con tx_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#

EstadoQué significaQué hacer
pendingAceptado y en cola. El saldo ya está reservado.Mostrar "procesando".
sentLa transacción está enviada a la red.Esperar (normalmente un par de segundos).
completedEl usuario ya tiene el dinero. Es definitivo.Marcar pagado y guardar tx_hash.
failedNo 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 recibeSale de tu cajaComisiónGas
merchant (tu empresa)500505.00841950.008419
customer (el usuario)494.99158150050.008419
platform50050550 (lo absorbe la plataforma)
Fórmula
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 customer la comisión y el gas se comen el monto, la API responde 422 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ímiteQué controla
max_per_payoutLo máximo que puede recibir un usuario en un retiro. En 0, los retiros de ese token están apagados.
daily_limitLo 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_today en el saldo ya descuenta los retiros aceptados que aún no salieron.

Idempotencia#

  • Cada retiro necesita una clave: el campo external_id o la cabecera Idempotency-Key. Sin ninguna de las dos, 400.
  • Mismo external_id con los mismos datos: devuelve el retiro existente (200, created: false) sin crear otro.
  • Mismo external_id con 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.

cURL
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`HTTPCuándo
insufficient_balance422El total que saldría de la caja supera lo disponible (available). Fondea o espera a que se acredite el fondeo.
payouts_disabled422Los retiros de ese token están apagados: faltan los límites.
above_max_per_payout422Lo que recibiría el usuario supera max_per_payout.
above_daily_limit422No queda tanto límite diario hoy (remaining_today).
below_minimum422El monto es menor que el mínimo por retiro (min_payout, 1 USD por defecto).
invalid_recipient422Destino no permitido (ver Seguridad).
amount_too_small422Con gas_paid_by: customer, la comisión y el gas se comen el monto.
idempotency_conflict409Ya 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_balanceLa caja ya no tenía saldo suficiente al pagar.
payouts_disabledLos límites del token se pusieron en 0 después de aceptar el retiro.
above_max_per_payout / above_daily_limitLos límites bajaron después de aceptar el retiro.
invalid_recipientEl contrato rechazó el destino.
fee_too_high / gas_above_capLa comisión o el gas superan lo que permite el contrato.
unknown_merchant / relayer_not_authorizedProblema de configuración de la plataforma.
send_failedLa 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}