# Pasarela — documentación > Pasarela de pagos con USDT y USDC en Arbitrum, sin custodia. URL base de la API: https://api.pasarela.melenashair.com ## Índice ### Primeros pasos - Introducción: Qué es Pasarela, cómo funciona un cobro de punta a punta y cómo recibir el primer depósito en minutos. - Entornos: Pruebas y producción: red, tokens, prefijo de las claves y cómo conseguir saldo de prueba. - Ciclo de una petición: Lo que comparten todas las llamadas a la API: formato, autenticación, errores, repetición y paginación. - Autenticación: Cada petición lleva la clave de API de la empresa en la cabecera x-api-key. - Peticiones repetidas: Qué pasa si envías dos veces lo mismo y cómo evitar acreditar dos veces un depósito. - Ids de petición: Cada respuesta lleva una cabecera x-request-id que identifica esa petición. - Errores: Formato de los errores, códigos de estado y cuándo conviene reintentar. - Paginación: Los listados se recorren con limit y offset; los eventos, con un cursor por número de secuencia. - Webhooks: Avisos firmados que tu servidor recibe en cada cambio de un depósito: eventos, reintentos y verificación de la firma. - Flujo de integración: El recorrido completo para integrar la pasarela, desde la clave de API hasta la conciliación diaria. ### Pagos - Flujo de un depósito: De la transferencia del usuario al dinero en tu tesorería: detectado, confirmado y liquidado. - Comisiones: Las dos comisiones de cada depósito, sobre qué base se calculan y cómo queda el reparto. - Gas: Quién paga el costo de mover cada depósito, cómo se estima y qué límites lo acotan. - Cotizaciones: Cuánto llegará de un depósito y cuánto pedirle al usuario para cubrir gas y comisiones. - Página de pago: La página alojada con el QR del cliente, el QR como imagen y cómo armar tu propia pantalla. ### 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. ### Seguridad - Sin custodia: Qué garantiza el contrato, quién puede hacer qué y cómo saca la empresa su dinero sin depender de la plataforma. - Claves de API: Cómo crear, rotar y revocar claves sin cortar el servicio, y cómo manejar el secreto de webhooks. ### Referencias - Redes y tokens: La red, los tokens aceptados, sus contratos y decimales. - Estados de un depósito: Los cinco valores de status, qué significa cada uno y qué evento lo acompaña. - Objeto depósito: Cada campo del depósito, tal como viene en la API y en los webhooks. - Objeto retiro: Todos los campos de un retiro, tal como llegan en la API y en los webhooks. - Límites: Tamaños máximos, valores por defecto y lo que todavía no está implementado. ### Desarrollo con IA - llms.txt: Toda la documentación en un solo archivo de texto, pensado para asistentes de código. ### Referencia de API - Clientes: POST /v1/customers, GET /v1/customers, GET /v1/customers/{id}, GET /v1/customers/{id}/deposits - Depósitos: GET /v1/deposits, GET /v1/deposits/{id} - Cotizaciones: POST /v1/quotes - Retiros: GET /v1/payouts/balance, POST /v1/payouts/quote, POST /v1/payouts, GET /v1/payouts, GET /v1/payouts/{id}, GET /v1/payouts/fundings, POST /v1/payouts/return, GET /admin/payouts, PATCH /admin/merchants/{id}/payouts, PUT /admin/merchants/{id}/payouts/limits, POST /admin/merchants/{id}/payouts/return - Eventos y webhooks: GET /v1/events, GET /v1/events/{id}, POST /v1/events/{id}/resend, POST /v1/webhook/test, GET /v1/webhook/secret, POST /v1/webhook/secret/rotate - Cuenta: GET /v1/me, PATCH /v1/me, GET /v1/stats - Claves de API: GET /v1/api-keys, POST /v1/api-keys, DELETE /v1/api-keys/{id} - Página de pago: GET /pay/{id}, GET /pay/{id}/status, GET /pay/{id}/qr.svg - Plataforma: POST /admin/merchants, GET /admin/merchants, GET /admin/merchants/{id}, PATCH /admin/merchants/{id}, POST /admin/merchants/{id}/api-keys, GET /admin/stats, GET /admin/deposits, GET /admin/gas, GET /admin/system - Sistema: GET /health --- # Guías # Introducción Qué es Pasarela, cómo funciona un cobro de punta a punta y cómo recibir el primer depósito en minutos. Pasarela es una pasarela de cobros solo en cripto y **sin custodia**. Cada usuario de tu empresa recibe una dirección de depósito permanente en Arbitrum que acepta **USDT** y **USDC**. Cuando deposita, el servidor lo detecta, te avisa con un webhook firmado y mueve el dinero a tu billetera en una sola transacción, que reparte comisiones y gas. La plataforma nunca guarda tus fondos. > **Hoy opera en la red de pruebas.** La pasarela funciona en **Arbitrum Sepolia** (chain id 421614) con tokens de prueba. Todo lo que integres ahí funciona igual en producción. Ver [Entornos](/docs/guias/entornos). ## Cómo funciona 1. **Creas un cliente.** Con [Crear cliente](/docs/referencia/crear-cliente) envías tu identificador del usuario (`external_id`) y recibes su dirección de depósito, una página de pago (`pay_url`) y un QR (`qr_url`). La dirección es suya para siempre. 2. **El usuario deposita.** Envía USDT o USDC a su dirección desde cualquier billetera o exchange que opere en Arbitrum. Puede depositar las veces que quiera, por el monto que quiera. 3. **Recibes el aviso.** El servidor ve la transferencia (`deposit.detected`) y, tras unos bloques, la confirma (`deposit.confirmed`). Ahí acreditas el saldo en tu sistema. 4. **El dinero llega a tu billetera.** El depósito se barre en una transacción que paga la comisión de la plataforma, tu comisión, el gas si corresponde, y envía el resto a tu tesorería. Recibes `deposit.settled` con el reparto definitivo. ## Por dónde seguir - [Flujo de integración](/docs/guias/flujo-de-integracion): El paso a paso completo, desde la clave de API hasta la conciliación. - [Webhooks](/docs/guias/webhooks): Eventos, reintentos y cómo verificar la firma en Node, Python y PHP. - [Comisiones](/docs/guias/comisiones): Cómo se reparte cada depósito, con ejemplos numéricos. - [Gas](/docs/guias/gas): Quién lo paga, cómo se estima y qué queda garantizado. - [Sin custodia](/docs/guias/sin-custodia): Qué garantiza el contrato y quién puede hacer qué. - [Retiros](/docs/guias/retiros): Paga a tus usuarios en USDT/USDC desde tu caja: fichas a cripto. - [Referencia de la API](/docs/referencia/crear-cliente): Todas las rutas, con parámetros y respuestas de ejemplo. ## Inicio rápido **1. Crea un cliente.** Usa la clave de API de tu empresa en la cabecera `x-api-key`. ```bash title="cURL" curl -X POST https://api.pasarela.melenashair.com/v1/customers \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"external_id": "TARJETA-000123"}' ``` ```js title="Node" const r = await fetch('https://api.pasarela.melenashair.com/v1/customers', { method: 'POST', headers: { 'x-api-key': process.env.PASARELA_API_KEY, 'content-type': 'application/json' }, body: JSON.stringify({ external_id: 'TARJETA-000123' }), }); const { customer } = await r.json(); console.log(customer.address, customer.pay_url); ``` ```python title="Python" import os, requests r = requests.post( "https://api.pasarela.melenashair.com/v1/customers", headers={"x-api-key": os.environ["PASARELA_API_KEY"]}, json={"external_id": "TARJETA-000123"}, ) customer = r.json()["customer"] print(customer["address"], customer["pay_url"]) ``` ```php title="PHP" true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('PASARELA_API_KEY'), 'content-type: application/json'], CURLOPT_POSTFIELDS => json_encode(['external_id' => 'TARJETA-000123']), ]); $customer = json_decode(curl_exec($ch), true)['customer']; echo $customer['address'], ' ', $customer['pay_url']; ``` ```json title="Respuesta" { "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": {}, "created_at": "2026-10-13T14:58:41.463Z" } } ``` **2. Muestra el QR.** Abre `pay_url` en el navegador del usuario o inserta `qr_url` como imagen en tu propia pantalla. El QR sin parámetros contiene la dirección; con `?token=USDT&amount=10` abre la transferencia con el monto ya puesto. Ver [Página de pago](/docs/guias/pagina-de-pago). **3. Recibe el webhook.** Configura tu URL con [Actualizar cuenta](/docs/referencia/actualizar-cuenta) y acredita cuando llegue `deposit.confirmed`. Verifica siempre la firma: ver [Webhooks](/docs/guias/webhooks). ```bash title="Configurar webhook" curl -X PATCH https://api.pasarela.melenashair.com/v1/me \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"webhook_url": "https://tu-servidor.com/webhooks/pagos"}' ``` ```json title="deposit.confirmed" { "id": "87f9bb94-70af-4879-a8e0-a2c03e974a8e", "type": "deposit.confirmed", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "confirmed", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": false, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.008923", "net": "96.991077" }, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": null } } } ``` # Entornos Pruebas y producción: red, tokens, prefijo de las claves y cómo conseguir saldo de prueba. Hay dos entornos, cada uno con su red y sus claves. Una clave de pruebas solo ve datos de pruebas. La integración es la misma en los dos: cambian la URL base, la clave y los valores de `chain_id` y `tokens` que devuelve la API. | | Pruebas | Producción | | --- | --- | --- | | Estado | Disponible | Aún no abierta | | Red | Arbitrum Sepolia | Arbitrum One | | Chain id | `421614` | `42161` | | Prefijo de la clave | `sk_test_` | `sk_live_` | | Dinero | Tokens de prueba, sin valor | USDT y USDC reales | | `webhook_url` | Acepta `http://` y `https://` | Solo `https://` | | Espera para cambiar la tesorería | Ninguna | 48 horas | | Explorador | [sepolia.arbiscan.io](https://sepolia.arbiscan.io) | [arbiscan.io](https://arbiscan.io) | ## Tokens de pruebas | Token | Dirección del contrato | Decimales | | --- | --- | --- | | USDT | `0xdc190461C20DE36fFB305b0190985B4023D7D183` | 6 | | USDC | `0x928C3C521F85790c4B9b8c37251f4e22186610aE` | 8 | > **El USDC de pruebas tiene 8 decimales.** En Arbitrum One el USDC tiene 6. No escribas los decimales fijos en tu código: léelos de `tokens[].decimals` en el cliente o de `decimals` en el depósito. Si trabajas con los montos como texto decimal (`amount`), no necesitas los decimales para nada. Las direcciones de producción se publicarán al abrir ese entorno. En cualquier caso, la fuente de verdad es la API: cada cliente trae en `tokens` los contratos que acepta su dirección. ## Cómo conseguir saldo de prueba 1. **Prepara una billetera en Arbitrum Sepolia.** Cualquier billetera compatible con Ethereum sirve (MetaMask, Rabby). Agrega la red Arbitrum Sepolia con chain id `421614`. 2. **Consigue ETH de prueba.** La billetera que envía el depósito paga el gas de su transferencia en ETH de Arbitrum Sepolia. Se obtiene gratis en los grifos públicos de esa red. 3. **Pide USDT y USDC de prueba.** Los tokens de prueba son contratos propios del entorno (los de la tabla), no los de un grifo público. Pídelos al equipo de Pasarela indicando la dirección de tu billetera. 4. **Deposita.** Envía los tokens a la dirección de un cliente y sigue el depósito en `pay_url`, en tus webhooks o con [Listar depósitos](/docs/referencia/listar-depositos). > Para saber en qué entorno estás, consulta [Ver cuenta](/docs/referencia/ver-cuenta): devuelve `chain_id`, `network` y la dirección del contrato (`factory`). # Ciclo de una petición Lo que comparten todas las llamadas a la API: formato, autenticación, errores, repetición y paginación. La API es HTTP con JSON. Todas las rutas siguen las mismas convenciones; esta sección las reúne para que no haya sorpresas al integrar. | Aspecto | Convención | | --- | --- | | Formato | Cuerpos de petición y respuesta en JSON (`content-type: application/json`). Las únicas excepciones son la página de pago (HTML) y el QR (SVG). | | Autenticación | Cabecera `x-api-key` en las rutas `/v1/...`. | | Montos | Siempre texto decimal (`"103.101981"`), nunca números de coma flotante. `amount_units` trae el mismo monto en la unidad mínima del token. | | Fechas | ISO 8601 en UTC: `2026-10-13T15:04:12.310Z`. | | Identificadores | UUID para clientes, depósitos, eventos y claves. Los clientes también se pueden buscar con `ext:`. | | Direcciones | Las de clientes y empresas vienen con mayúsculas de verificación (EIP-55); las de un depósito (`address`, `from`, `token_address`) vienen en minúsculas. Compáralas sin distinguir mayúsculas. | | Respuestas | El objeto viene dentro de una clave con su nombre: `{ "customer": … }`, `{ "deposits": […], "total": 16 }`. | | Tamaño | El cuerpo de una petición no puede superar 64 KB. | - [Autenticación](/docs/guias/autenticacion): La cabecera x-api-key y quién usa cada tipo de ruta. - [Peticiones repetidas](/docs/guias/peticiones-repetidas): Qué rutas se pueden repetir y cómo no acreditar dos veces. - [Ids de petición](/docs/guias/ids-de-peticion): La cabecera x-request-id, para tus logs y para soporte. - [Errores](/docs/guias/errores): Formato, códigos de estado y cuándo reintentar. - [Paginación](/docs/guias/paginacion): limit y offset en listados; after y before en eventos. ```http title="Petición" GET https://api.pasarela.melenashair.com/v1/customers/ext:TARJETA-000123 x-api-key: sk_test_4f9c… ``` ```http title="Respuesta" HTTP/1.1 200 OK x-request-id: req_8ff0fe1d66b02b3d7653a3ba content-type: application/json; charset=utf-8 { "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": {}, "created_at": "2026-10-13T14:58:41.463Z" } } ``` # Autenticación Cada petición lleva la clave de API de la empresa en la cabecera x-api-key. Las rutas `/v1/...` se autentican con la clave de API de la empresa en la cabecera `x-api-key`. No hay sesiones ni tokens temporales: la misma clave sirve hasta que la revoques. ```bash title="cURL" curl https://api.pasarela.melenashair.com/v1/me \ -H "x-api-key: sk_test_4f9c…" ``` ```js title="Node" const r = await fetch('https://api.pasarela.melenashair.com/v1/me', { headers: { 'x-api-key': process.env.PASARELA_API_KEY }, }); const { merchant } = await r.json(); ``` ```python title="Python" import os, requests r = requests.get("https://api.pasarela.melenashair.com/v1/me", headers={"x-api-key": os.environ["PASARELA_API_KEY"]}) merchant = r.json()["merchant"] ``` ```php title="PHP" true, CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('PASARELA_API_KEY')], ]); $merchant = json_decode(curl_exec($ch), true)['merchant']; ``` | Rutas | Quién | Cabecera | | --- | --- | --- | | `/v1/...` | La empresa | `x-api-key: sk_test_…` o `sk_live_…` | | `/admin/...` | La plataforma | `x-admin-token` | | `/pay/...` y `/health` | Cualquiera | Ninguna | Si la clave falta, es incorrecta o está revocada, la respuesta es `401` con `{ "error": "Clave de API inválida" }`. En las rutas de la plataforma el mensaje es `No autorizado`. > **La clave va solo en tu servidor.** Quien tenga la clave puede cambiar tu webhook y tus comisiones y ver todos tus clientes. No la pongas en una app móvil, en JavaScript del navegador ni en el repositorio. Lo que sí puede ver el usuario son las rutas públicas `/pay/...`. La plataforma puede actuar en nombre de una empresa enviando `x-admin-token` junto con `x-merchant-id: ` en lugar de `x-api-key`. Así funciona el panel de administración. Cómo crear, rotar y revocar claves está en [Claves de API](/docs/guias/claves-de-api). # Peticiones repetidas Qué pasa si envías dos veces lo mismo y cómo evitar acreditar dos veces un depósito. Las redes fallan: una petición puede llegar al servidor y perderse la respuesta, o un webhook puede entregarse dos veces. La API está pensada para que repetir sea seguro en los dos sentidos. > **Idempotency-Key solo en retiros.** La cabecera `Idempotency-Key` solo la acepta [Crear retiro](/docs/referencia/crear-retiro), donde la idempotencia es obligatoria. En el resto de rutas, la protección contra duplicados viene de cómo está hecha cada una, como se explica abajo. ## De tu servidor a la API | Ruta | ¿Se puede repetir? | Qué pasa | | --- | --- | --- | | `POST /v1/customers` | Sí | Es idempotente por `external_id`: la primera vez responde `201`; las siguientes, `200` con el mismo cliente y la misma dirección. | | Cualquier `GET` | Sí | Solo lee. | | `POST /v1/quotes` | Sí | No crea cobros. Cada llamada recalcula con el gas del momento. | | `POST /v1/payouts` | Sí, con la misma clave | Idempotente por `external_id` (o `Idempotency-Key`): repetir devuelve el mismo retiro con `200`. Ver [Retiros](/docs/guias/retiros#idempotencia). | | `POST /v1/payouts/quote` | Sí | No crea nada ni reserva saldo. | | `POST /v1/payouts/return` | No | Cada llamada devuelve otra vez ese monto a la tesorería. Si no viste la respuesta, consulta el saldo antes de repetir. | | `PATCH /v1/me` | Sí | Deja el mismo valor final. | | `POST /v1/events/{id}/resend` | Con cuidado | Cada llamada pone en cola un envío más del mismo evento. | | `POST /v1/webhook/test` | Con cuidado | Cada llamada crea un evento de prueba nuevo. | | `POST /v1/api-keys` | No | Cada llamada crea otra clave. Si no viste la respuesta, lista las claves y revoca la que sobre. | | `POST /v1/webhook/secret/rotate` | No | Cada llamada genera un secreto distinto e invalida el anterior. | ## De la API a tu servidor Cada evento tiene un `id` estable: el mismo aviso lleva siempre el mismo `id`, en el primer envío, en cada reintento y en los reenvíos manuales. También viene en la cabecera `X-Pasarela-Event-Id`. 1. Guarda el `id` de cada evento que proceses, con una restricción de unicidad en tu base de datos. 2. Si llega un `id` que ya tienes, responde `200` y no hagas nada más. 3. Como segunda defensa, acredita por `data.deposit.id`: un depósito se acredita una sola vez, aunque te lleguen varios eventos sobre él. > Un depósito tampoco se duplica del lado de la pasarela: se identifica por la transacción y su posición dentro de ella (`tx_hash` + `log_index`), así que la misma transferencia nunca genera dos depósitos. # Ids de petición Cada respuesta lleva una cabecera x-request-id que identifica esa petición. Todas las respuestas de la API, también las de error y las de rutas públicas, incluyen la cabecera `x-request-id` con un identificador único: `req_` seguido de 24 caracteres hexadecimales. ```http title="Respuesta" HTTP/1.1 200 OK x-request-id: req_f96b8763d193e6a199ab9d99 content-type: application/json; charset=utf-8 ``` ```js title="Node" const r = await fetch('https://api.pasarela.melenashair.com/v1/deposits', { headers: { 'x-api-key': process.env.PASARELA_API_KEY }, }); if (!r.ok) { const { error, details } = await r.json(); console.error('Fallo la pasarela', { status: r.status, requestId: r.headers.get('x-request-id'), error, details }); } ``` - Regístralo en tus logs junto con el código de estado, sobre todo cuando la respuesta no es `2xx`. - Inclúyelo al escribir a soporte: identifica la petición exacta. - El id lo genera el servidor en cada petición. No se puede enviar uno propio y no sirve para deduplicar: repetir la petición da un id distinto. > Los webhooks no llevan `x-request-id`: su identificador es el `id` del evento, que viaja en el cuerpo y en `X-Pasarela-Event-Id`. # Errores Formato de los errores, códigos de estado y cuándo conviene reintentar. La API usa los códigos de estado HTTP habituales. Cuando algo falla, el cuerpo es un JSON con `error` (un mensaje en español) y, si el problema está en los datos enviados, `details`: una lista con una línea por campo, en la forma `campo: motivo`. ```json title="Datos inválidos" { "error": "Datos inválidos", "details": [ "limit: Too big: expected number to be <=200" ] } ``` ```json title="Regla de negocio" { "error": "Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito" } ``` ```json title="Sin autorización" { "error": "Clave de API inválida" } ``` ## Códigos de estado | Código | Significado | Ejemplos de `error` | ¿Reintentar? | | --- | --- | --- | --- | | `200` / `201` | Correcto. `201` cuando se creó algo. | — | — | | `400` | Los datos enviados no son válidos o rompen una regla. | `Datos inválidos`, `Token no soportado. Usa: USDT, USDC`, `Configura primero webhook_url` | No. Corrige la petición. | | `401` | Falta la clave, es incorrecta o está revocada. | `Clave de API inválida`, `No autorizado` | No. Revisa la clave. | | `404` | El recurso no existe o no es de tu empresa. | `Usuario no encontrado`, `Depósito no encontrado`, `Evento no encontrado`, `Clave no encontrada` | No. | | `409` | La operación choca con el estado actual. | `Es la única clave activa: crea otra antes de revocar esta` | No, hasta resolver el conflicto. | | `413` | El cuerpo supera 64 KB. | Mensaje del servidor web | No. Envía menos datos. | | `500` | Fallo del servidor o de la red blockchain. | `Error interno` | Sí, con espera creciente. | - Los mensajes de `details` los genera el validador y están en inglés (`Invalid UUID`, `Too big: …`). Úsalos para depurar, no para mostrarlos al usuario ni para tomar decisiones en el código. - Decide por el código de estado, no por el texto de `error`: los textos pueden cambiar. - Un recurso de otra empresa responde `404`, igual que uno que no existe. - Una ruta que no existe responde `404` con el formato del servidor web: `{ "message": "Route GET:/x not found", "error": "Not Found", "statusCode": 404 }`. ## Cómo reintentar Reintenta solo los `500` y los fallos de conexión, esperando cada vez más (por ejemplo 1 s, 2 s, 4 s, hasta 30 s). Las rutas que cambian condiciones (`PATCH /v1/me`) escriben primero en la blockchain: si responden `500`, no cambió nada y se pueden repetir. Qué rutas se pueden repetir sin riesgo está en [Peticiones repetidas](/docs/guias/peticiones-repetidas). Anota siempre el [id de petición](/docs/guias/ids-de-peticion). > **Aún no hay límite de peticiones.** La API todavía no devuelve `429`. Está previsto un límite por clave, así que conviene tratar un `429` futuro como reintentable. Ver [Límites](/docs/guias/limites). # Paginación Los listados se recorren con limit y offset; los eventos, con un cursor por número de secuencia. Hay dos formas de paginar, según la ruta. | Rutas | Parámetros | Orden | Trae `total` | | --- | --- | --- | --- | | `GET /v1/customers` | `limit` (1 a 200, por defecto 50), `offset` | Del más nuevo al más antiguo | Sí | | `GET /v1/deposits` | `limit` (1 a 200, por defecto 50), `offset`, `before` | Del más reciente al más antiguo | Sí | | `GET /v1/events` | `after`, `before`, `order`, `limit` (1 a 200, por defecto 100) | Por `seq`, ascendente por defecto | No | | `GET /v1/customers/{id}/deposits` | Ninguno | Los últimos 100 | No | ## Listados con limit y offset `total` es el número de registros que cumplen los filtros, no el tamaño de la página. Sigue pidiendo mientras `offset + limit < total`. ```bash title="cURL" curl "https://api.pasarela.melenashair.com/v1/deposits?limit=50&offset=50&status=settled" \ -H "x-api-key: sk_test_4f9c…" ``` ```js title="Node" // Recorre todos los depósitos liquidados. "before" fija el corte: los depósitos // que entren mientras paginas no desplazan las páginas. const corte = new Date().toISOString(); const todos = []; for (let offset = 0; ; offset += 200) { const url = 'https://api.pasarela.melenashair.com/v1/deposits?status=settled&limit=200&offset=' + offset + '&before=' + corte; const r = await fetch(url, { headers: { 'x-api-key': process.env.PASARELA_API_KEY } }); const { deposits, total } = await r.json(); todos.push(...deposits); if (offset + deposits.length >= total || deposits.length === 0) break; } ``` > Como el orden es del más reciente al más antiguo, un depósito nuevo empuja a los demás una posición. Usa `before` (fecha ISO 8601 en UTC, terminada en `Z`) para congelar el conjunto mientras lo recorres. ## Eventos por secuencia Cada evento tiene un `seq` creciente. Guarda el último que procesaste y pide lo que sigue con `after`. Cuando la respuesta trae menos eventos que `limit`, ya estás al día. `seq` es único en toda la plataforma, así que para tu empresa puede tener saltos: no cuentes con que sea consecutivo. ```bash title="cURL" curl "https://api.pasarela.melenashair.com/v1/events?after=34&limit=200" \ -H "x-api-key: sk_test_4f9c…" ``` ```js title="Node" let ultimo = await leerUltimoSeq(); // 0 la primera vez for (;;) { const r = await fetch('https://api.pasarela.melenashair.com/v1/events?limit=200&after=' + ultimo, { headers: { 'x-api-key': process.env.PASARELA_API_KEY }, }); const { events } = await r.json(); for (const e of events) { await procesar(e); // mismo código que el webhook: e.id, e.type, e.data ultimo = e.seq; } await guardarUltimoSeq(ultimo); if (events.length < 200) break; } ``` Para ver lo más reciente primero (por ejemplo en una pantalla de actividad) usa `order=desc` y avanza con `before=`. # Webhooks Avisos firmados que tu servidor recibe en cada cambio de un depósito: eventos, reintentos y verificación de la firma. Un webhook es una petición `POST` con JSON que Pasarela envía a tu servidor cada vez que pasa algo con un depósito. Es la forma recomendada de enterarte: no hace falta consultar la API en bucle. Cada aviso va firmado con HMAC-SHA256 para que puedas comprobar que viene de la pasarela. ```http title="Lo que recibe tu servidor" POST /webhooks/pagos HTTP/1.1 content-type: application/json user-agent: Pasarela-Webhooks/1.0 x-pasarela-signature: t=1791903853,v1=5f2b9c…e41a x-pasarela-event-id: 87f9bb94-70af-4879-a8e0-a2c03e974a8e x-pasarela-event-type: deposit.confirmed {"id":"87f9bb94-70af-4879-a8e0-a2c03e974a8e","type":"deposit.confirmed","created_at":"2026-10-13T15:04:13.289Z","data":{"deposit":{…}}} ``` | Campo del cuerpo | Qué es | | --- | --- | | `id` | Id del evento. Es estable: el mismo en reintentos y reenvíos. Úsalo para no procesar dos veces. | | `type` | Tipo de evento, por ejemplo `deposit.confirmed`. | | `created_at` | Cuándo ocurrió el evento (no cuándo se envió). | | `data.deposit` | En eventos `deposit.*`: el depósito tal como estaba al ocurrir el evento. Ver [Objeto depósito](/docs/guias/objeto-deposito). | | `data.payout` | En eventos `payout.*`: el retiro. Ver [Objeto retiro](/docs/guias/objeto-retiro). | | `data.funding` | En `balance.funded`: token, monto acreditado, saldo de la caja y transacción. | ## Configurar 1. **Publica una ruta que acepte POST.** Debe leer el cuerpo sin transformarlo (la firma se calcula sobre los bytes tal cual llegan) y responder `2xx` en menos de 10 segundos. 2. **Registra la URL.** Envía `webhook_url` con [Actualizar cuenta](/docs/referencia/actualizar-cuenta). En producción debe ser `https://`. Con `null` se dejan de enviar avisos. 3. **Guarda el secreto.** Se entrega al crear la empresa y se puede consultar con [Ver secreto de webhooks](/docs/referencia/ver-secreto-de-webhooks). Empieza por `whsec_`. 4. **Prueba.** Con [Enviar webhook de prueba](/docs/referencia/probar-webhook) llega un evento `webhook.test` firmado igual que los reales. Revisa el resultado con [Obtener evento](/docs/referencia/obtener-evento). > Hay una sola `webhook_url` por empresa y recibe todos los tipos de evento. Los eventos se guardan aunque no tengas URL configurada: siempre puedes leerlos con [Listar eventos](/docs/referencia/listar-eventos). ## Política de reintentos Un envío se da por entregado cuando tu servidor responde con un código `2xx`. Cualquier otra respuesta (incluidas las redirecciones `3xx`, que no se siguen), un error de conexión o tardar más de 10 segundos cuentan como fallo y el aviso se reintenta. | Intento | Espera desde el fallo anterior | Tiempo acumulado | | --- | --- | --- | | 1 | Inmediato | 0 | | 2 | 10 s | 10 s | | 3 | 30 s | 40 s | | 4 | 2 min | 2 min 40 s | | 5 | 10 min | 12 min 40 s | | 6 | 30 min | 42 min 40 s | | 7 | 1 h | 1 h 43 min | | 8 | 2 h | 3 h 43 min | | 9 | 4 h | 7 h 43 min | | 10 | 8 h | 15 h 43 min | | 11 | 12 h | 27 h 43 min | | 12 | 24 h | 51 h 43 min | | 13 | 24 h | 75 h 43 min (unos 3 días) | - Si el intento 13 también falla, el envío queda en estado `dead` y no se reintenta más. Se puede reenviar a mano con [Reenviar evento](/docs/referencia/reenviar-evento). - Cada reintento lleva una firma nueva (con la hora del intento) y el mismo `id` de evento. - El orden de llegada no está garantizado: un `deposit.settled` puede llegar antes que un `deposit.confirmed` que se está reintentando. Guíate por `data.deposit.status` y por el `seq` de [Listar eventos](/docs/referencia/listar-eventos), no por el orden de llegada. - El estado de cada envío (`pending`, `delivered`, `dead`), los intentos y el último error se ven en [Obtener evento](/docs/referencia/obtener-evento). ## Eventos | Evento | Cuándo ocurre | Qué hacer | | --- | --- | --- | | `deposit.detected` | La transferencia se vio en la red, aún sin los bloques de espera. | Mostrar "pago recibido, confirmando". **No acreditar.** | | `deposit.confirmed` | Pasaron los bloques de espera y el monto llega al mínimo. | **Acreditar aquí.** | | `deposit.below_minimum` | El depósito se confirmó, pero es menor que el mínimo o no alcanza para comisiones y gas. | No acreditar. Avisar al usuario. | | `deposit.settled` | El dinero ya está en tu tesorería. El reparto es definitivo. | Conciliar con los montos finales. | | `deposit.reverted` | La transferencia desapareció de la red antes de confirmarse. | Quitar el aviso de "pago recibido". | | `payout.created` | Se aceptó un retiro y su saldo quedó reservado. | Mostrar "procesando". | | `payout.completed` | El usuario ya recibió el retiro. Es definitivo. | Marcar pagado y guardar `tx_hash`. | | `payout.failed` | El retiro no se pagó; no se movió dinero. | Devolver las fichas al usuario. | | `balance.funded` | Llegó un fondeo a tu caja de retiros. | Nada obligatorio: actualizar el saldo que muestres. | | `webhook.test` | Lo pediste con la ruta de prueba. | Nada: sirve para comprobar la conexión y la firma. | ### deposit.detected Primer aviso de un depósito. Aún puede anularse, y `breakdown` viene en `null` porque el reparto todavía no se ha calculado. ```json title="deposit.detected" { "id": "021f882c-8cbe-4d2f-b847-0cbd4f3238e9", "type": "deposit.detected", "created_at": "2026-10-13T15:04:12.312Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "detected", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": null, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": null, "settled_at": null } } } ``` ### deposit.confirmed El depósito es firme: **este es el momento de acreditar**. `breakdown` trae el reparto cotizado (`final: false`). El gas que aparece aquí es el máximo que se descontará; el definitivo llega en `deposit.settled` y nunca es mayor. ```json title="deposit.confirmed" { "id": "87f9bb94-70af-4879-a8e0-a2c03e974a8e", "type": "deposit.confirmed", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "confirmed", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": false, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.008923", "net": "96.991077" }, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": null } } } ``` ### deposit.settled El barrido terminó y el dinero está en tu tesorería. `breakdown.final` es `true`, los montos salen del evento del contrato y `sweep_tx_hash` es la transacción que movió el dinero. Si varios depósitos del mismo cliente se barrieron juntos, llega un `deposit.settled` por cada uno, con el mismo `sweep_tx_hash`. ```json title="deposit.settled" { "id": "efc2eed0-3d0b-4c82-b37a-61a4f0500dcc", "type": "deposit.settled", "created_at": "2026-10-13T15:04:23.407Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z" } } } ``` ### deposit.below_minimum El depósito se confirmó pero no se acredita: es menor que `min_deposit`, o las comisiones y el gas se lo llevan entero. El dinero se queda en la dirección del cliente y se barre junto con su próximo depósito válido del mismo token; en ese momento llega su `deposit.settled`. ```json title="deposit.below_minimum" { "id": "eb66c429-16e5-4fa1-831c-cb2943ac756c", "type": "deposit.below_minimum", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "below_minimum", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "0.1", "amount_units": "100000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": false, "gas_paid_by": "merchant", "platform_fee": "0.001", "merchant_fee": "0.002", "gas_fee": "0.008923", "net": "0.088077" }, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": null } } } ``` ### deposit.reverted La transferencia que se había detectado ya no está en la red (una reorganización de bloques) o su transacción falló. Solo puede ocurrir **antes** de `deposit.confirmed`: un depósito confirmado no se anula. ```json title="deposit.reverted" { "id": "06998d7a-342c-4743-bfe7-c559b64cf0c2", "type": "deposit.reverted", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "reverted", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": null, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": null, "settled_at": null } } } ``` ### payout.created, payout.completed y payout.failed Avisos de [Retiros](/docs/guias/retiros). `data.payout` es el retiro tal como estaba en ese momento (ver [Objeto retiro](/docs/guias/objeto-retiro)). En `payout.failed`, `data.payout.error` trae `code` y `message`. ```json title="payout.created" { "id": "c4a1e7d2-5b3f-4e8a-9c0d-1f2e3a4b5c6d", "type": "payout.created", "created_at": "2026-10-14T13:20:05.120Z", "data": { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "pending", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": null, "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": null, "completed_at": null, "failed_at": null } } } ``` ```json title="payout.completed" { "id": "d5b2f8e3-6c4a-4f9b-8d1e-2a3b4c5d6e7f", "type": "payout.completed", "created_at": "2026-10-14T13:20:08.951Z", "data": { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null } } } ``` ```json title="payout.failed" { "id": "e6c3a9f4-7d5b-4a0c-9e2f-3b4c5d6e7f80", "type": "payout.failed", "created_at": "2026-10-14T13:20:06.124Z", "data": { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "failed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": null, "error": { "code": "payouts_disabled", "message": "Los retiros de este token están apagados (sin límites configurados)" }, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": null, "completed_at": null, "failed_at": "2026-10-14T13:20:06.120Z" } } } ``` ### balance.funded Llegó dinero a tu dirección de fondeo y ya está en tu caja de retiros. `amount` es lo acreditado y `balance` el saldo de la caja después. ```json title="balance.funded" { "id": "f7d4b0a5-8e6c-4b1d-8f3a-4c5d6e7f8091", "type": "balance.funded", "created_at": "2026-10-14T13:15:42.880Z", "data": { "funding": { "token": "USDT", "amount": "1000", "balance": "1000", "tx_hash": "0x3e8b1d6f0a2c4e7b9d1f3a5c7e9b0d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b" } } } ``` ### webhook.test ```json title="webhook.test" { "id": "a136ae86-5793-4df5-bebe-d8a8e91043f1", "type": "webhook.test", "created_at": "2026-10-13T15:10:02.118Z", "data": { "message": "Aviso de prueba" } } ``` ## Verificar la firma Cada aviso lleva la cabecera `X-Pasarela-Signature` con la forma `t=,v1=`. `t` es la hora del envío en segundos y `v1` es el HMAC-SHA256, en hexadecimal, del texto `.` usando tu secreto de webhooks como clave. 1. **Lee el cuerpo sin tocar.** Usa los bytes tal cual llegaron. Si tu framework convierte el JSON en objeto y lo vuelves a escribir, el orden de las claves o los espacios cambian y la firma deja de coincidir. 2. **Separa t y v1.** Parte la cabecera por la coma y cada trozo por el signo igual. 3. **Rechaza los avisos viejos.** Si `t` difiere de tu reloj en más de 300 segundos (5 minutos), recházalo. Así un aviso capturado no se puede repetir más tarde. 4. **Calcula y compara.** Calcula el HMAC-SHA256 de `t`, un punto y el cuerpo, y compáralo con `v1` usando una comparación de tiempo constante. 5. **Responde.** `2xx` si lo aceptaste (aunque sea un repetido). Si la firma no coincide, responde `400` y no proceses nada. ```js title="Node" import express from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; const SECRETO = process.env.WEBHOOK_SECRET; // whsec_… const app = express(); function firmaValida(cabecera, cuerpoCrudo) { const partes = Object.fromEntries(String(cabecera).split(',').map((p) => p.split('='))); const t = Number(partes.t); if (!t || !partes.v1) return false; if (Math.abs(Date.now() / 1000 - t) > 300) return false; // más de 5 minutos: se rechaza const esperado = createHmac('sha256', SECRETO).update(t + '.' + cuerpoCrudo).digest(); const recibido = Buffer.from(partes.v1, 'hex'); return esperado.length === recibido.length && timingSafeEqual(esperado, recibido); } // express.raw: el cuerpo llega sin tocar, que es sobre lo que se calcula la firma app.post('/webhooks/pagos', express.raw({ type: 'application/json' }), async (req, res) => { const crudo = req.body.toString('utf8'); if (!firmaValida(req.get('x-pasarela-signature'), crudo)) return res.status(400).send('firma inválida'); const evento = JSON.parse(crudo); if (await yaProcesado(evento.id)) return res.sendStatus(200); // repetido: no acreditar otra vez if (evento.type === 'deposit.confirmed') { const d = evento.data.deposit; await acreditar(d.customer.external_id, d.amount, d.token, evento.id); } res.sendStatus(200); }); ``` ```python title="Python" import hashlib, hmac, json, os, time from flask import Flask, request SECRETO = os.environ["WEBHOOK_SECRET"].encode() # whsec_… app = Flask(__name__) def firma_valida(cabecera: str, crudo: bytes) -> bool: partes = dict(p.split("=", 1) for p in cabecera.split(",") if "=" in p) try: t = int(partes["t"]) recibido = partes["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > 300: # más de 5 minutos: se rechaza return False esperado = hmac.new(SECRETO, str(t).encode() + b"." + crudo, hashlib.sha256).hexdigest() return hmac.compare_digest(esperado, recibido) @app.post("/webhooks/pagos") def webhook(): crudo = request.get_data() # el cuerpo tal cual llegó if not firma_valida(request.headers.get("X-Pasarela-Signature", ""), crudo): return "firma inválida", 400 evento = json.loads(crudo) if ya_procesado(evento["id"]): # repetido: no acreditar otra vez return "", 200 if evento["type"] == "deposit.confirmed": d = evento["data"]["deposit"] acreditar(d["customer"]["external_id"], d["amount"], d["token"], evento["id"]) return "", 200 ``` ```php title="PHP" 300 || !hash_equals($esperado, $partes['v1'] ?? '')) { http_response_code(400); exit('firma inválida'); } $evento = json_decode($crudo, true); if (ya_procesado($evento['id'])) { // repetido: no acreditar otra vez http_response_code(200); exit; } if ($evento['type'] === 'deposit.confirmed') { $d = $evento['data']['deposit']; acreditar($d['customer']['external_id'], $d['amount'], $d['token'], $evento['id']); } http_response_code(200); ``` > **Si la firma nunca coincide.** Casi siempre es una de estas tres cosas: el cuerpo se está volviendo a generar en vez de usar el original; el secreto es de otro entorno o se rotó; o el reloj del servidor está desajustado más de 5 minutos. ## Buenas prácticas - **Acredita solo con `deposit.confirmed`.** `deposit.detected` es informativo y `deposit.settled` llega después, cuando el dinero ya se movió. - **No acredites dos veces.** Guarda el `id` del evento y el `id` del depósito con restricción de unicidad. Ver [Peticiones repetidas](/docs/guias/peticiones-repetidas). - **Responde rápido.** Verifica, guarda el evento y responde `200`; el trabajo pesado hazlo después, en segundo plano. A los 10 segundos el envío se da por fallido. - **Usa el monto como texto.** `amount` es un decimal en texto; no lo conviertas a coma flotante. - **Concilia.** Una vez al día, o tras una caída, recorre [Listar eventos](/docs/referencia/listar-eventos) desde tu último `seq` para recuperar lo que se haya perdido. - **Vigila los envíos muertos.** `webhooks.dead` en [Ver resumen](/docs/referencia/ver-resumen) debería ser 0. # Flujo de integración El recorrido completo para integrar la pasarela, desde la clave de API hasta la conciliación diaria. Una integración típica tiene tres piezas en tu lado: una llamada para crear el cliente, una pantalla que muestra su QR y una ruta que recibe webhooks. Lo demás es opcional. 1. **Recibe tus credenciales.** Al dar de alta tu empresa, la plataforma te entrega una clave de API (`sk_test_…`) y un secreto de webhooks (`whsec_…`). En el alta se fijan tu tesorería, tu billetera de comisiones y, si la tienes, tu billetera de administración. Ver [Sin custodia](/docs/guias/sin-custodia). 2. **Revisa tu configuración.** Con [Ver cuenta](/docs/referencia/ver-cuenta) compruebas la red, tus billeteras, las comisiones y quién paga el gas. Ajusta tu comisión, el mínimo y el webhook con [Actualizar cuenta](/docs/referencia/actualizar-cuenta). 3. **Monta la ruta de webhooks.** Verifica la firma, guarda el `id` del evento y responde `200`. Pruébala con [Enviar webhook de prueba](/docs/referencia/probar-webhook). Ver [Webhooks](/docs/guias/webhooks). 4. **Crea un cliente por usuario.** Llama a [Crear cliente](/docs/referencia/crear-cliente) con tu identificador como `external_id` cuando el usuario quiera pagar por primera vez (o al registrarse). Repetir la llamada devuelve el mismo cliente, así que no necesitas comprobar antes si existe. 5. **Muestra dónde pagar.** Redirige a `pay_url`, o dibuja tu propia pantalla con `address` y `qr_url`. Indica siempre la red: **Arbitrum**. Ver [Página de pago](/docs/guias/pagina-de-pago). 6. **Si necesitas un monto exacto, cotiza.** Los depósitos son de monto libre. Si quieres que a tu tesorería llegue una cifra concreta, pide una [cotización](/docs/guias/cotizaciones) y muestra al usuario cuánto debe enviar. 7. **Acredita al confirmar.** Cuando llegue `deposit.confirmed`, suma `data.deposit.amount` al saldo del usuario identificado por `data.deposit.customer.external_id`. Decide si acreditas el monto depositado o el neto (`breakdown.net`), según quién asuma las comisiones en tu negocio. 8. **Concilia al liquidar.** Con `deposit.settled` tienes el reparto definitivo y la transacción (`sweep_tx_hash`) que llevó el dinero a tu tesorería. Para cuadrar períodos usa [Listar depósitos](/docs/referencia/listar-depositos) y [Ver resumen](/docs/referencia/ver-resumen). 9. **Recupera lo que se pierda.** Si tu servidor estuvo caído, los webhooks se reintentan durante unos 3 días. Además, [Listar eventos](/docs/referencia/listar-eventos) con `after=<último seq>` devuelve todo lo ocurrido, en orden. ## Lo que ocurre en cada depósito | Momento | En la pasarela | En tu sistema | | --- | --- | --- | | El usuario envía USDT o USDC | Nada todavía | Pantalla de pago abierta | | La transferencia entra en un bloque | Depósito `detected` · evento `deposit.detected` | Mostrar "recibido, confirmando" | | Pasan los bloques de espera | Depósito `confirmed` · evento `deposit.confirmed` con reparto cotizado | **Acreditar el saldo** | | El barrido se confirma | Depósito `settled` · evento `deposit.settled` con reparto definitivo | Registrar el neto y la transacción | ```bash title="1. Crear cliente" curl -X POST https://api.pasarela.melenashair.com/v1/customers \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"external_id": "TARJETA-000123"}' ``` ```bash title="2. Cotizar (opcional)" curl -X POST https://api.pasarela.melenashair.com/v1/quotes \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"token": "USDT", "amount": "100", "customer_id": "ext:TARJETA-000123"}' ``` ```bash title="3. Consultar depósitos" curl "https://api.pasarela.melenashair.com/v1/customers/ext:TARJETA-000123/deposits" \ -H "x-api-key: sk_test_4f9c…" ``` ```bash title="4. Conciliar eventos" curl "https://api.pasarela.melenashair.com/v1/events?after=0&limit=200" \ -H "x-api-key: sk_test_4f9c…" ``` ## Antes de salir a producción - La firma de los webhooks se verifica sobre el cuerpo sin tocar y se rechazan avisos de más de 5 minutos. - Un mismo evento o un mismo depósito no acredita dos veces. - Solo `deposit.confirmed` acredita saldo. - Tu código lee los decimales de la API en vez de darlos por fijos (el USDC de pruebas tiene 8). - La clave de API está solo en el servidor, en una variable de entorno o un gestor de secretos. - La pantalla de pago dice claramente la red (Arbitrum) y los tokens aceptados. - Tu empresa tiene su propia billetera de administración. Ver [Sin custodia](/docs/guias/sin-custodia). > **Lo que todavía no existe.** No hay cobros con monto fijo y vencimiento: una dirección acepta cualquier monto en cualquier momento. Si necesitas asociar un pago a un pedido concreto, hazlo en tu sistema a partir del cliente, el monto y la fecha del depósito. # Flujo de un depósito De la transferencia del usuario al dinero en tu tesorería: detectado, confirmado y liquidado. Un depósito es una transferencia de USDT o USDC hacia la dirección de un cliente. El servidor lee la red bloque a bloque y lleva cada depósito por tres estados, avisándote en cada cambio. 1. **Detectado (detected).** La transferencia aparece en un bloque. Se crea el depósito y sale `deposit.detected`. Todavía no es firme: no acredites. 2. **Confirmado (confirmed).** Cuando el bloque del depósito tiene encima los bloques de espera de tu empresa (`confirmations`, 4 por defecto), el servidor comprueba que la transferencia sigue en la red, calcula el reparto con tus condiciones de ese momento y emite `deposit.confirmed`. Aquí se acredita. 3. **Liquidado (settled).** El servidor envía el barrido: una transacción que saca el saldo de la dirección y lo reparte entre la plataforma, tu billetera de comisiones y tu tesorería. Al confirmarse, el depósito pasa a `settled` con el reparto definitivo y sale `deposit.settled`. ## Tiempos típicos | Desde que el usuario envía la transferencia | Tiempo típico en pruebas | | --- | --- | | Hasta `deposit.confirmed` | Unos 6 segundos | | Hasta `deposit.settled` | Unos 11 segundos | Son medidas en Arbitrum Sepolia con 4 bloques de espera, no una garantía: dependen de la red y de la carga. Contado desde que el usuario pulsa enviar, de los cuales unos 3 segundos son de su billetera y de la red. El servidor revisa la red cada segundo, aproximadamente. Si un barrido falla, se reintenta solo a los 30 segundos. ## Mínimos Cada empresa tiene un depósito mínimo (`min_deposit`, en USD, 0.5 por defecto; se trata 1 token como 1 USD). Un depósito se confirma como `below_minimum`, y **no debe acreditarse**, en dos casos: si es menor que el mínimo, o si las comisiones más el gas se lo llevarían entero. - El dinero de un depósito `below_minimum` no se pierde: se queda en la dirección del cliente. - No se barre por sí solo. Se barre junto con el siguiente depósito del mismo cliente y token que sí se confirme; entonces pasa a `settled` y recibes su `deposit.settled`. - A esos depósitos no se les carga gas: el gas del barrido se reparte entre los depósitos confirmados. ## Reversión Antes de confirmar, el servidor verifica que la transacción sigue en la red y en el mismo bloque. Si desapareció (una reorganización de bloques) o falló, el depósito pasa a `reverted` y sale `deposit.reverted`. Si la misma transferencia reaparece en otro bloque, simplemente se vuelve a esperar desde ahí. Un depósito ya confirmado no se revierte: por eso se acredita en `deposit.confirmed` y no antes. ## Casos que conviene conocer - **Varios depósitos seguidos.** El barrido mueve todo el saldo del token en la dirección. Si hay varios depósitos pendientes del mismo cliente, se liquidan en una sola transacción y el reparto se atribuye a cada uno en proporción a su monto. Todos comparten `sweep_tx_hash`. - **El reparto final puede diferir del cotizado.** El gas definitivo nunca supera el cotizado, así que normalmente el neto final es igual o un poco mayor. Las comisiones las calcula el contrato con las condiciones vigentes al barrer: si cambian entre la confirmación y el barrido, manda el reparto final. - **Barrido directo de la empresa.** Si el `admin` de tu empresa barre por su cuenta antes de que el servidor confirme, el depósito se confirma y se liquida a la vez: llegan `deposit.confirmed` y `deposit.settled` seguidos. - **USDT y USDC van por separado.** Cada token tiene su propio barrido. > La lista completa de estados está en [Estados de un depósito](/docs/guias/estados-de-un-deposito) y cada campo del objeto en [Objeto depósito](/docs/guias/objeto-deposito). # Comisiones Las dos comisiones de cada depósito, sobre qué base se calculan y cómo queda el reparto. Cada depósito se reparte en la misma transacción que lo mueve. Hay dos comisiones, independientes entre sí, expresadas en puntos básicos (100 = 1 %). | Concepto | Campo | Quién lo fija | A dónde va | | --- | --- | --- | --- | | Comisión de la plataforma | `platform_fee_bps` | La plataforma | Billetera de la plataforma | | Comisión de la empresa a su usuario | `merchant_fee_bps` | La empresa (o la plataforma desde el panel) | Billetera de comisiones de la empresa (`fee_wallet`); si no tiene, su tesorería | | Base de la comisión de la empresa | `merchant_fee_base` | Igual que la anterior | `gross`: se calcula sobre el depósito completo · `net`: sobre lo que queda después de la comisión de la plataforma | | Gas del barrido | `gas_mode` | La plataforma | Ver [Gas](/docs/guias/gas) | | El resto | — | — | Tesorería de la empresa (`treasury`) | ## Ejemplos: 100 USDT con 1 % de plataforma | Comisión de la empresa | Base | Gas | Plataforma | Comisión empresa | Tesorería | | --- | --- | --- | --- | --- | --- | | 0 % | — | Lo paga la plataforma | 1 | 0 | **99** | | 2 % | `gross` | Lo paga la plataforma | 1 | 2 | **97** | | 2 % | `net` | Lo paga la plataforma | 1 | 1.98 | **97.02** | | 2 % | `gross` | 0.10, lo paga la empresa | 1 + 0.10 de gas | 2 | **96.90** | La comisión de la empresa es dinero de la empresa: en los ejemplos, entre la tesorería y la billetera de comisiones la empresa recibe 99 (o 98.90 si paga el gas). Separarla sirve para distinguir, en la propia blockchain, lo que es recarga del usuario de lo que es ingreso por comisión. ## Fórmula ```text title="Reparto" comisión_plataforma = ⌊ monto × platform_fee_bps / 10000 ⌋ base = monto si merchant_fee_base = gross = monto − comisión_plataforma si merchant_fee_base = net comisión_empresa = ⌊ base × merchant_fee_bps / 10000 ⌋ neto = monto − comisión_plataforma − comisión_empresa − gas ``` Todo se calcula en enteros, en la unidad mínima del token, redondeando cada comisión hacia abajo. Es la misma cuenta en el servidor y en el contrato, así que el reparto cotizado y el que ocurre en la blockchain coinciden al céntimo. ## Sin tope Las comisiones no tienen un máximo propio: cada una puede ir de 0 a 10000 puntos básicos (0 a 100 %). La única regla es que juntas no se lleven más que el depósito. - Con base `gross`, si `platform_fee_bps + merchant_fee_bps` supera 10000, la API responde `400` con `Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito`. - Con base `net` la suma siempre cabe, porque la segunda comisión se calcula sobre lo que dejó la primera. - Si las comisiones más el gas superan un depósito concreto, ese depósito queda como `below_minimum` y el contrato no lo barre. ## Cambiar tu comisión ```bash title="cURL" curl -X PATCH https://api.pasarela.melenashair.com/v1/me \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"merchant_fee_bps": 200, "merchant_fee_base": "net"}' ``` > El cambio se escribe en el contrato (tarda unos segundos) y vale para los barridos que ocurran después. Para ver cómo quedaría un depósito con las condiciones actuales, usa [Cotizaciones](/docs/guias/cotizaciones). # Gas Quién paga el costo de mover cada depósito, cómo se estima y qué límites lo acotan. Barrer un depósito es una transacción en Arbitrum y cuesta gas, que el servidor paga en ETH. `gas_mode` decide quién asume ese costo. Lo fija la plataforma para cada empresa. Cuando se descuenta, se cobra en el mismo token del depósito (USDT o USDC), nunca en ETH. | `gas_mode` | Quién lo paga | Qué pasa en el barrido | `breakdown.gas_fee` | | --- | --- | --- | --- | | `platform` | La plataforma | No se descuenta nada. | `"0"` | | `merchant` | La empresa | El gas se descuenta del barrido y se reembolsa a la plataforma. La tesorería recibe menos. | El gas descontado | | `customer` | El usuario final | En la blockchain es igual que `merchant`. La diferencia es de negocio: la empresa pide al usuario que deposite un poco más. | El gas descontado | En modo `customer`, [Cotizaciones](/docs/guias/cotizaciones) dice cuánto pedir: `to_cover_gas` (el monto más el gas) o `to_cover_all` (para que a la tesorería llegue el monto entero). Si el usuario deposita sin sumar el gas, el depósito se procesa igual y el gas sale del monto. ## Cómo se estima 1. **Con el depósito ya en la dirección** (al confirmar y al barrer): se le pide al nodo estimar el barrido real. 2. **Antes del depósito** (al cotizar): se simula el mismo barrido como si la dirección ya tuviera el saldo. Sale el gas exacto de ese cliente, incluido si hay que desplegar su contrato. 3. **Si el nodo no admite la simulación**: se usa la mediana de los últimos 50 barridos reales. ```text title="Costo" costo en token = gas × precio base del gas × precio del ETH en USD (1 token = 1 USD) gas cobrado = mínimo( costo × 1.10 , tope por barrido ) ``` - **Margen del 10 %.** A la estimación se le suma un 10 % (`buffer_bps: 1000`) para cubrir variaciones entre la estimación y la transacción. - **Precio del ETH.** Se toma de Chainlink en la propia red; si falla, de Coinbase. Se actualiza como mucho una vez por minuto. - **Tope por barrido: 0.50 USD.** El contrato rechaza un barrido que intente descontar más de 0.50 del token en gas. Si el gas real cuesta más, la diferencia la asume la plataforma. ## Lo que queda garantizado > **El gas descontado nunca supera lo cotizado.** El gas que aparece en `deposit.confirmed` es el máximo para ese depósito. Al barrer se vuelve a estimar y se descuenta el menor de los dos valores. Si el gas sube entre medias, lo asume la plataforma. Además, una cotización pedida con `customer_id` garantiza su gas durante **15 minutos** (`gas_guaranteed_until`): si el depósito de ese cliente se detecta antes de que venza, no se le descontará más gas que el cotizado, aunque la estimación al confirmar salga más alta. Una cotización sin `customer_id` es solo informativa. ## El primer depósito cuesta más La dirección de un cliente existe desde el primer momento, pero su contrato se despliega con el primer barrido. Ese despliegue gasta más gas, así que el primer depósito de cada cliente cuesta aproximadamente el doble que los siguientes. La cotización lo indica con `gas.first_deposit`. | Barrido | Gas típico en pruebas (USDT) | | --- | --- | | Primer depósito de un cliente | ≈ 0.0087 | | Depósitos siguientes | ≈ 0.0044 | > Son valores de Arbitrum Sepolia con el precio del gas y del ETH del día de la medición; cambian con la red. Para el valor de ahora, pide una cotización. La plataforma ve lo cobrado frente a lo que costó de verdad en [Gas cobrado frente a gas real](/docs/referencia/gas-cobrado-y-real). # Cotizaciones Cuánto llegará de un depósito y cuánto pedirle al usuario para cubrir gas y comisiones. Una cotización responde dos preguntas antes de que el usuario pague: si deposita este monto, ¿cómo se reparte?; y si quiero que llegue este monto, ¿cuánto debe depositar? Se calcula con las condiciones actuales de tu empresa y el gas de ese instante. No crea ningún cobro ni bloquea nada: el usuario puede depositar otro monto. ```bash title="cURL" curl -X POST https://api.pasarela.melenashair.com/v1/quotes \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"token": "USDT", "amount": "100", "customer_id": "ext:TARJETA-000123"}' ``` ```js title="Node" const r = await fetch('https://api.pasarela.melenashair.com/v1/quotes', { method: 'POST', headers: { 'x-api-key': process.env.PASARELA_API_KEY, 'content-type': 'application/json' }, body: JSON.stringify({ token: 'USDT', amount: '100', customer_id: 'ext:TARJETA-000123' }), }); const { quote } = await r.json(); console.log('Pide al usuario:', quote.to_cover_all.deposit, quote.token); ``` ```python title="Python" import os, requests r = requests.post( "https://api.pasarela.melenashair.com/v1/quotes", headers={"x-api-key": os.environ["PASARELA_API_KEY"]}, json={"token": "USDT", "amount": "100", "customer_id": "ext:TARJETA-000123"}, ) quote = r.json()["quote"] print("Pide al usuario:", quote["to_cover_all"]["deposit"], quote["token"]) ``` ```php title="PHP" true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('PASARELA_API_KEY'), 'content-type: application/json'], CURLOPT_POSTFIELDS => json_encode(['token' => 'USDT', 'amount' => '100', 'customer_id' => 'ext:TARJETA-000123']), ]); $quote = json_decode(curl_exec($ch), true)['quote']; echo 'Pide al usuario: ', $quote['to_cover_all']['deposit'], ' ', $quote['token']; ``` Respuesta real de una empresa con comisión de plataforma del 1 %, comisión propia del 2 % sobre `gross` y gas a cargo del usuario, para un cliente que ya había depositado antes: ```json title="Respuesta" { "quote": { "token": "USDT", "decimals": 6, "amount": "100", "terms": { "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_paid_by": "customer" }, "gas": { "fee": "0.004691", "estimate": "0.004691", "first_deposit": false, "gas_units": "85051", "gas_price_wei": "20000000", "eth_usd": 2506.55, "eth_usd_source": "chainlink", "buffer_bps": 1000, "cap": "0.5" }, "if_deposit_amount": { "deposit": "100", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.004691", "net": "96.995309" }, "to_cover_gas": { "deposit": "100.004691", "platform_fee": "1.000046", "merchant_fee": "2.000093", "gas_fee": "0.004691", "net": "96.999861" }, "to_cover_all": { "deposit": "103.097619", "platform_fee": "1.030976", "merchant_fee": "2.061952", "gas_fee": "0.004691", "net": "100" }, "gas_guaranteed_until": "2026-10-13T15:19:05.271Z" } } ``` ## Los tres escenarios | Bloque | El usuario deposita | Llega a la tesorería | Para qué sirve | | --- | --- | --- | --- | | `if_deposit_amount` | 100 | 96.995309 | Saber qué pasa si deposita exactamente `amount`. | | `to_cover_gas` | 100.004691 | 96.999861 | Que el gas no reduzca lo que llega. Es `amount` más el gas; las comisiones se siguen descontando. | | `to_cover_all` | 103.097619 | 100 | Que a la tesorería llegue `amount` entero: cubre gas y comisiones. | En `to_cover_gas` el neto no es exactamente el de las comisiones solas porque estas se calculan sobre el depósito, que ahora incluye el gas. `to_cover_all` es el depósito mínimo con el que el neto alcanza `amount`. ## El bloque gas | Campo | Qué es | | --- | --- | | `gas.fee` | Gas que se descontaría del depósito. `"0"` si lo paga la plataforma. | | `gas.estimate` | Costo estimado del barrido, con el margen incluido, lo pague quien lo pague. | | `gas.first_deposit` | `true` si el barrido tendría que desplegar el contrato del cliente (más caro). | | `gas.gas_units`, `gas.gas_price_wei` | Unidades de gas estimadas y precio base del gas usado. | | `gas.eth_usd`, `gas.eth_usd_source` | Precio del ETH usado y de dónde salió (`chainlink`, `coinbase` o `fijo`). | | `gas.buffer_bps` | Margen aplicado, en puntos básicos (1000 = 10 %). | | `gas.cap` | Tope de gas por barrido para ese token. | | `gas_guaranteed_until` | Hasta cuándo el gas cotizado es el máximo para ese cliente. `null` si no se envió `customer_id` o si el gas lo paga la plataforma. | ## Detalles a tener en cuenta - **Con o sin cliente.** Con `customer_id` el gas es el de ese cliente y queda garantizado 15 minutos. Sin él se cotiza el caso más caro: un primer depósito. - **Monto insuficiente.** Si el monto no cubre comisiones y gas, el bloque trae `{ "deposit": "0.001", "error": "El monto no alcanza para cubrir comisiones y gas" }` en lugar del reparto. La respuesta sigue siendo `200`. - **Comisiones del 100 %.** Si se llevan todo, `to_cover_all` es `{ "error": "Las comisiones se llevan todo el depósito: ningún monto alcanza" }`. - **Formato del monto.** Texto decimal mayor que cero, sin más decimales que el token. `"1.1234567"` en USDT responde `400` con `USDT admite hasta 6 decimales`. - **No es un cobro.** No hay vencimiento del monto ni se rechaza un depósito distinto al cotizado. Lo único que caduca es la garantía del gas. > Todos los parámetros y errores están en [Crear cotización](/docs/referencia/crear-cotizacion). # Página de pago La página alojada con el QR del cliente, el QR como imagen y cómo armar tu propia pantalla. Cada cliente trae dos enlaces públicos, que no piden clave: `pay_url`, una página lista para usar, y `qr_url`, el QR como imagen SVG. Puedes usar la página tal cual o construir tu propia pantalla con la dirección y el QR. | Ruta | Devuelve | Uso | | --- | --- | --- | | `/pay/{id}` | Página HTML | Redirigir al usuario o abrirla en un WebView. | | `/pay/{id}/qr.svg` | Imagen SVG | Insertar el QR en tu propia pantalla. | | `/pay/{id}/status` | JSON | Consultar el avance de los depósitos desde el navegador o la app. | ## La página alojada - Muestra el nombre de tu empresa, el QR de la dirección, la dirección con botón de copiar, los tokens aceptados y el depósito mínimo. - Advierte de la red: si el usuario envía por otra (Tron, Ethereum, BSC), el pago no llega. - Lista los últimos depósitos del cliente y se actualiza sola cada 3 segundos: "Recibido, confirmando…", "Confirmado", "Menor al mínimo" o "Anulado". - Se adapta a tema claro y oscuro y a pantallas de teléfono. > La página no tiene monto ni vencimiento: es la dirección permanente del cliente. El `id` del enlace es un UUID difícil de adivinar, pero quien lo tenga puede ver la dirección y los últimos depósitos (monto, token, estado, hash). No expone comisiones ni datos de `metadata`. ## El QR | URL | Contenido del QR | Cuándo usarlo | | --- | --- | --- | | `qr_url` | Solo la dirección | Siempre funciona: cualquier billetera o exchange lo lee. El usuario elige token, red y monto. | | `qr_url?token=USDT` | Enlace EIP-681 del token | Billeteras que lo entienden abren la transferencia de USDT ya en la red correcta. | | `qr_url?token=USDT&amount=10` | Enlace EIP-681 con monto | Además deja el monto puesto. Útil junto con una cotización. | ```text title="HTML" QR de pago ``` ```text title="Enlace EIP-681 generado" ethereum:0xdc190461C20DE36fFB305b0190985B4023D7D183@421614/transfer?address=0xeB1322F1C80462602A728Ca005f1026709A738ba&uint256=10000000 ``` - El enlace EIP-681 tiene la forma `ethereum:@/transfer?address=&uint256=`. - `amount` solo se aplica si también envías `token`. Un token desconocido responde `400` con `Token no soportado`. - No todas las billeteras ni los exchanges leen EIP-681. Muestra siempre también la dirección en texto, con la red y el token. - El monto del QR es una sugerencia para la billetera: el usuario puede cambiarlo y el depósito se procesa igual. ## Tu propia pantalla Con `address`, `tokens` y `qr_url` del cliente tienes todo para dibujarla. Para el avance en vivo, consulta `/pay/{id}/status` cada pocos segundos: devuelve los últimos 10 depósitos y no necesita clave, así que se puede llamar desde el navegador. ```bash title="cURL" curl https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/status ``` ```json title="Respuesta" { "deposits": [ { "amount": "100", "token": "USDT", "status": "confirmed", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "detected_at": "2026-10-13T15:04:12.310Z" } ] } ``` > **La pantalla informa, el webhook acredita.** Usa `/status` para lo que ve el usuario. El saldo acredítalo solo en tu servidor, al recibir `deposit.confirmed` con la firma verificada. # 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](/docs/referencia/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.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](#limites)). ## 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](/docs/referencia/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`. ```bash title="Ver saldo" curl https://api.pasarela.melenashair.com/v1/payouts/balance -H "x-api-key: sk_test_4f9c…" ``` ```json title="Respuesta" { "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` - 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. **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/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. **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 campo `code` te dice cuál fue. 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. ```bash title="cURL" # 1. Cuánto recibiría y cuánto sale de tu caja (opcional) curl -X POST https://api.pasarela.melenashair.com/v1/payouts/quote \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"token": "USDT", "amount": "500"}' # 2. Pagar. external_id es tu id de la operación: repetirlo nunca paga dos veces curl -X POST https://api.pasarela.melenashair.com/v1/payouts \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{ "token": "USDT", "amount": "500", "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "external_id": "retiro-000981", "customer_id": "jugador-77", "metadata": {"fichas": 500} }' # 3. Consultar por tu propio id curl https://api.pasarela.melenashair.com/v1/payouts/ext:retiro-000981 -H "x-api-key: sk_test_4f9c…" ``` ```js title="Node" const API = 'https://api.pasarela.melenashair.com'; const KEY = process.env.PASARELA_API_KEY; // sk_test_… / sk_live_… // El usuario pide cambiar 500 fichas por USDT a su billetera. export async function cambiarFichas(usuario, fichas, billetera) { // 1. Descuenta las fichas en TU base de datos y guarda la operación con un id propio. const op = await db.retiros.crear({ usuario: usuario.id, fichas, billetera, estado: 'en_curso' }); await db.usuarios.restarFichas(usuario.id, fichas); // 2. Pide el pago. Si la red falla, repite con el mismo external_id: nunca paga dos veces. const res = await fetch(`${API}/v1/payouts`, { method: 'POST', headers: { 'x-api-key': KEY, 'content-type': 'application/json' }, body: JSON.stringify({ token: 'USDT', amount: String(fichas), // 1 ficha = 1 USD to: billetera, external_id: `retiro-${op.id}`, customer_id: usuario.id, metadata: { fichas }, }), }); const body = await res.json(); if (res.status === 422) { // Rechazado sin mover dinero (saldo, límites, destino…): devuelve las fichas. await db.usuarios.sumarFichas(usuario.id, fichas); await db.retiros.actualizar(op.id, { estado: 'rechazado', motivo: body.code }); return { ok: false, motivo: body.error }; } if (!res.ok) throw new Error(body.error); // 5xx o red: reintenta más tarde con el mismo external_id return { ok: true, retiro: body.payout.id }; } // 3. El resultado llega por webhook (verifica la firma como en la guía de Webhooks). export async function alRecibirEvento(evento) { if (!evento.type.startsWith('payout.')) return; const p = evento.data.payout; const opId = p.external_id.replace('retiro-', ''); if (evento.type === 'payout.completed') await db.retiros.actualizar(opId, { estado: 'pagado', tx: p.tx_hash }); if (evento.type === 'payout.failed') { await db.retiros.actualizar(opId, { estado: 'fallido', motivo: p.error.code }); await db.usuarios.sumarFichas(p.customer_ref, Number(p.requested)); // no se movió dinero } } ``` ```json title="Respuesta 201" { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "pending", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": null, "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": null, "completed_at": null, "failed_at": null }, "created": true } ``` ```json title="Cotización" { "quote": { "token": "USDT", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant" } } ``` ## 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) | ```text title="Fórmula" comisión = ⌊ pedido × payout_fee_bps / 10000 ⌋ merchant: recibe = pedido sale de la caja = pedido + comisión + gas customer: recibe = pedido − comisión − gas sale de la caja = pedido platform: 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](/docs/referencia/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](/docs/referencia/limites-de-retiros). - **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](/docs/referencia/devolver-saldo-de-retiros). El contrato solo permite enviarlo a **tu tesorería**, nunca a otro destino. No se puede devolver el saldo reservado por retiros en curso. ```bash title="cURL" curl -X POST https://api.pasarela.melenashair.com/v1/payouts/return \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -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](/docs/guias/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](#seguridad-y-responsabilidades)). | | `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](/docs/guias/webhooks). ```json title="payout.completed" { "id": "d5b2f8e3-6c4a-4f9b-8d1e-2a3b4c5d6e7f", "type": "payout.completed", "created_at": "2026-10-14T13:20:08.951Z", "data": { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null } } } ``` ```json title="payout.failed" { "id": "e6c3a9f4-7d5b-4a0c-9e2f-3b4c5d6e7f80", "type": "payout.failed", "created_at": "2026-10-14T13:20:06.124Z", "data": { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "failed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": null, "error": { "code": "payouts_disabled", "message": "Los retiros de este token están apagados (sin límites configurados)" }, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": null, "completed_at": null, "failed_at": "2026-10-14T13:20:06.120Z" } } } ``` ```json title="balance.funded" { "id": "f7d4b0a5-8e6c-4b1d-8f3a-4c5d6e7f8091", "type": "balance.funded", "created_at": "2026-10-14T13:15:42.880Z", "data": { "funding": { "token": "USDT", "amount": "1000", "balance": "1000", "tx_hash": "0x3e8b1d6f0a2c4e7b9d1f3a5c7e9b0d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b" } } } ``` # Sin custodia Qué garantiza el contrato, quién puede hacer qué y cómo saca la empresa su dinero sin depender de la plataforma. Sin custodia significa que Pasarela no tiene las llaves del dinero. Los depósitos caen en direcciones controladas por un contrato en Arbitrum (`GatewayFactory`), y ese contrato solo sabe hacer una cosa con ellos: repartirlos hacia las billeteras configuradas para tu empresa. ## Qué garantiza el contrato - **La dirección de depósito no tiene clave privada.** Es un contrato mínimo que solo obedece a la fábrica que lo creó. El dinero solo sale mediante un barrido. - **Quien barre no elige el destino.** El barrido envía cada parte a las billeteras guardadas en la configuración de la empresa. Ni el servidor ni un atacante que robe su clave pueden desviar el neto a otra dirección. - **La plataforma no puede cambiar tu tesorería ni tu billetera de comisiones.** Solo la billetera `admin` de la empresa. - **La dirección se conoce antes de desplegar nada.** Se calcula con CREATE2 a partir del id de la empresa y del cliente, por eso es permanente y crear clientes no cuesta gas. - **El gas descontado tiene tope.** El contrato rechaza un barrido que descuente más del tope por token (0.50 USD), y solo permite descontar gas si la empresa está en un modo que lo paga. - **El contrato no es actualizable.** No hay un proxy que permita cambiar su lógica después. ## Quién puede qué | Acción | Plataforma | `admin` de la empresa | | --- | --- | --- | | Barrer un depósito hacia las billeteras configuradas | Sí (puede descontar gas, con tope) | Sí (sin descuento de gas) | | Elegir a dónde va el dinero de un barrido | No | No: sale de la configuración | | Cambiar la tesorería | No | Sí, con 48 horas de espera en producción | | Cambiar la billetera de comisiones | No | Sí | | Traspasar la administración a otra billetera | No | Sí | | Cambiar la comisión de la empresa y su base | Sí (desde el panel) | Sí | | Cambiar la comisión de la plataforma y si se descuenta gas | Sí | No | | Cambiar el tope de gas, los relayers y la billetera de la plataforma | Sí | No | > **Con franqueza: lo que sí depende de la plataforma.** La comisión de la plataforma no tiene tope en el contrato (puede ir de 0 a 100 %) y la fija la plataforma. Cada cambio queda registrado en la blockchain (evento `MerchantFeesChanged`) y se refleja en [Ver cuenta](/docs/referencia/ver-cuenta), pero el contrato no lo impide. Las condiciones comerciales son un acuerdo entre tu empresa y la plataforma. > **Usa tu propia billetera de administración.** Si al dar de alta la empresa no se indica una billetera `admin` propia, la administra la cuenta de la plataforma, y entonces la plataforma sí podría cambiar la tesorería. Comprueba `admin_address` en [Ver cuenta](/docs/referencia/ver-cuenta): debe ser una billetera que controles tú, idealmente una multifirma. ## Barrido directo por la empresa Si el servidor de la plataforma dejara de funcionar, el dinero no queda atrapado: la billetera `admin` de la empresa puede llamar al contrato y barrer cualquier dirección de sus clientes. El reparto es el mismo (comisiones incluidas) y `gasFee` debe ser 0; el gas de la transacción lo paga la propia billetera en ETH. Cuando el servidor vuelve a leer la red, ve ese barrido y marca los depósitos como liquidados con normalidad. ```solidity title="Contrato" // GatewayFactory — dirección en "factory" de GET /v1/me function sweep(bytes32 merchantId, bytes32 depositKey, IERC20 token, uint256 gasFee) external returns (uint256); // merchantId = keccak256(bytes()) p. ej. "01a01557-db25-4c0a-91da-7a46e3f6c163" // depositKey = keccak256(bytes()) p. ej. "3d8ed2f2-72d4-4c27-b606-a53b5a4db086" // token = contrato de USDT o USDC // gasFee = 0 cuando llama el admin de la empresa ``` ## Tokens enviados por error | Qué se envió a la dirección de depósito | ¿Se recupera? | Cómo | | --- | --- | --- | | ETH en Arbitrum | Sí | `sweepNative` lo envía entero a la tesorería. Lo puede llamar la plataforma o el `admin`. | | Otro token ERC-20 en Arbitrum | Sí | `sweep` con el contrato de ese token. Se reparte igual que un depósito, con las comisiones. No genera eventos ni webhooks. | | USDT o USDC por otra red compatible (Ethereum, Base, Polygon, BSC) | Hoy no | Requeriría desplegar el contrato en esa red en la misma dirección. No está hecho. | | Por Tron u otra red no compatible | No | La dirección no existe allí. | ## Estado de la auditoría > **Probados, aún sin auditoría externa.** Los contratos tienen pruebas automáticas y una prueba de punta a punta con depósitos reales en Arbitrum Sepolia, pero todavía **no han pasado por una auditoría externa**. Es un pendiente previo a producción. Hasta entonces, trátalos como software en pruebas. # Claves de API Cómo crear, rotar y revocar claves sin cortar el servicio, y cómo manejar el secreto de webhooks. Hay dos secretos por empresa: la **clave de API**, con la que tu servidor llama a la pasarela, y el **secreto de webhooks**, con el que compruebas que un aviso viene de la pasarela. Son independientes y se rotan por separado. | | Clave de API | Secreto de webhooks | | --- | --- | --- | | Formato | `sk_test_` o `sk_live_` + 48 caracteres hexadecimales | `whsec_` + 48 caracteres hexadecimales | | Cuántos a la vez | Varias activas | Uno | | Se puede volver a ver | No. Solo al crearla; el servidor guarda su huella SHA-256. | Sí, con `GET /v1/webhook/secret` | | Rotación | Crear otra, cambiar, revocar la vieja | Inmediata: el anterior deja de valer | ## Varias claves activas Una empresa puede tener varias claves a la vez, cada una con un nombre (`label`). Sirve para dar una clave distinta a cada servicio y para rotar sin corte. [Listar claves de API](/docs/referencia/listar-claves) muestra de cada una su prefijo (los primeros 12 caracteres), cuándo se creó, cuándo se usó por última vez y si está revocada. ## Rotar sin corte 1. **Crea la clave nueva.** Con [Crear clave de API](/docs/referencia/crear-clave). Guarda `api_key` en ese momento: no se vuelve a mostrar. 2. **Despliega la clave nueva.** Cámbiala en tus servidores. Mientras tanto, la vieja sigue funcionando. 3. **Comprueba que la vieja ya no se usa.** Mira `last_used_at` de la clave vieja en [Listar claves de API](/docs/referencia/listar-claves): debe dejar de avanzar. 4. **Revoca la vieja.** Con [Revocar clave de API](/docs/referencia/revocar-clave). Deja de funcionar de inmediato y no se puede reactivar. ```bash title="1. Crear" curl -X POST https://api.pasarela.melenashair.com/v1/api-keys \ -H "x-api-key: sk_test_4f9c…" \ -H "content-type: application/json" \ -d '{"label": "Rotación octubre"}' ``` ```bash title="2. Listar" curl https://api.pasarela.melenashair.com/v1/api-keys \ -H "x-api-key: sk_test_a81d…" ``` ```bash title="3. Revocar la vieja" curl -X DELETE https://api.pasarela.melenashair.com/v1/api-keys/ee9aa2e1-aadd-4af7-b4fd-bc965307bbe4 \ -H "x-api-key: sk_test_a81d…" ``` - No se puede revocar la única clave activa: la API responde `409` con `Es la única clave activa: crea otra antes de revocar esta`. - Si una clave se filtró, crea otra y revoca la filtrada enseguida, aunque eso corte algún servicio que aún la use. - Si perdiste todas tus claves, la plataforma puede crearte una nueva. ## Dónde guardarlas - En variables de entorno o en un gestor de secretos del servidor. Nunca en el código ni en el repositorio. - Nunca en una app móvil ni en JavaScript del navegador: cualquiera podría extraerlas. - No las escribas en logs. Para identificar una clave usa su prefijo (`sk_test_4f9c`). - Una clave distinta por entorno y por servicio, con un `label` que diga dónde vive. ## Secreto de webhooks Se entrega al crear la empresa y se consulta con [Ver secreto de webhooks](/docs/referencia/ver-secreto-de-webhooks). [Rotar secreto de webhooks](/docs/referencia/rotar-secreto-de-webhooks) genera uno nuevo y lo devuelve. > **La rotación del secreto es inmediata.** No hay un período en el que valgan los dos. Desde la rotación, todos los avisos (también los reintentos de eventos anteriores) van firmados con el secreto nuevo. Los avisos que tu servidor rechace mientras carga el secreto nuevo se reintentan solos (el primero a los 10 segundos), así que no se pierde ninguno; para no depender de eso, sigue estos pasos. 1. **Prepara tu servidor para dos secretos.** Haz que la verificación acepte una lista de secretos y dé por buena la firma si coincide con cualquiera. 2. **Rota y carga el nuevo.** Llama a la rotación y añade el secreto devuelto a la lista de tu servidor. 3. **Quita el viejo.** Cuando el nuevo esté desplegado, elimina el anterior de la lista. # Redes y tokens La red, los tokens aceptados, sus contratos y decimales. La pasarela trabaja en una sola red por entorno y acepta dos stablecoins. Una misma dirección de depósito recibe los dos tokens. | Entorno | Red | Chain id | Estado | | --- | --- | --- | --- | | Pruebas | Arbitrum Sepolia | `421614` | Disponible | | Producción | Arbitrum One | `42161` | Aún no abierta | ## Contratos en pruebas (Arbitrum Sepolia) | Contrato | Dirección | Decimales | | --- | --- | --- | | USDT | `0xdc190461C20DE36fFB305b0190985B4023D7D183` | 6 | | USDC | `0x928C3C521F85790c4B9b8c37251f4e22186610aE` | 8 | | Pasarela (`GatewayFactory`) | `0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F` | — | > **Los decimales cambian entre entornos.** El USDC de pruebas tiene 8 decimales; el de Arbitrum One tiene 6. Lee siempre `decimals` de la API (en `tokens` del cliente, en el depósito y en la cotización) o trabaja con los montos en texto decimal. ## Cómo vienen los montos | Campo | Ejemplo (USDT, 6 decimales) | Uso | | --- | --- | --- | | `amount` | `"103.101981"` | Monto en texto decimal. Para mostrar y para tu contabilidad. | | `amount_units` | `"103101981"` | El mismo monto en la unidad mínima del token (entero en texto). Para comparar con la blockchain. | | `decimals` | `6` | Relación entre los dos: `amount_units = amount × 10^decimals`. | - Los montos nunca vienen como número JSON, para no perder precisión. Guárdalos como texto o como decimal exacto. - Los símbolos se aceptan sin distinguir mayúsculas (`usdt` vale), y se devuelven en mayúsculas. - Para mínimos, gas y resúmenes se trata 1 token como 1 USD. No hay conversión a otras monedas. - Los contratos vigentes vienen en `tokens` de cada cliente y, para la plataforma, en [Estado del sistema](/docs/referencia/estado-del-sistema). ## Lo que no se acepta - **Otras redes.** Un envío de USDT o USDC por Ethereum, Tron, BSC, Polygon o Base a la misma dirección no llega y hoy no se puede recuperar. Indica siempre la red en tu pantalla de pago. - **Otros tokens.** Un token distinto en Arbitrum no genera depósito ni webhook. Se puede recuperar hacia la tesorería: ver [Sin custodia](/docs/guias/sin-custodia). - **ETH.** Tampoco genera depósito; se puede recuperar entero hacia la tesorería. - **Transferencias de monto cero.** Se ignoran. # Estados de un depósito Los cinco valores de status, qué significa cada uno y qué evento lo acompaña. | `status` | Significado | Evento | ¿Acreditar? | `breakdown` | | --- | --- | --- | --- | --- | | `detected` | La transferencia se vio en la red; faltan los bloques de espera. | `deposit.detected` | No | `null` | | `confirmed` | Firme y por encima del mínimo. Pendiente de barrer. | `deposit.confirmed` | **Sí** | Cotizado (`final: false`) | | `settled` | Barrido: el dinero ya está en la tesorería. | `deposit.settled` | Ya acreditado en `confirmed` | Definitivo (`final: true`) | | `below_minimum` | Confirmado, pero menor que el mínimo o no cubre comisiones y gas. | `deposit.below_minimum` | No | Cotizado (`final: false`) | | `reverted` | La transferencia desapareció de la red antes de confirmarse. | `deposit.reverted` | No | `null` | ## Transiciones ```text title="Estados" detected ──► confirmed ──────► settled │ ├──────► below_minimum ──► settled (cuando se barre junto con un depósito válido posterior) │ └──────► reverted ``` - `reverted` solo se alcanza desde `detected`. Un depósito `confirmed` no se anula. - `settled` es final. `reverted` también. - Un depósito `below_minimum` puede quedarse así indefinidamente si el cliente no vuelve a depositar ese token. - Si el `admin` de la empresa barre antes de la confirmación, el depósito pasa de `detected` a `settled` y se emiten `deposit.confirmed` y `deposit.settled` seguidos. ## Sobre settled `settled` no se guarda como un estado aparte: es un depósito `confirmed` (o `below_minimum`) cuyo barrido ya terminó. Para ti es un estado más, pero explica dos comportamientos de los filtros: - `status=confirmed` en [Listar depósitos](/docs/referencia/listar-depositos) devuelve solo los confirmados que **aún no** se han barrido. Normalmente son muy pocos, porque el barrido tarda segundos. - En [Ver resumen](/docs/referencia/ver-resumen), `totals` cuenta los depósitos acreditados, estén liquidados o no; `by_status` los separa. ## Estados de un envío de webhook | `delivery.status` | Significado | | --- | --- | | `pending` | En cola o esperando el siguiente reintento (`next_attempt_at`). | | `delivered` | Tu servidor respondió `2xx`. | | `dead` | Se agotaron los 13 intentos. Se puede reenviar a mano. | # Objeto depósito Cada campo del depósito, tal como viene en la API y en los webhooks. El depósito tiene la misma forma en todas partes: en [Obtener depósito](/docs/referencia/obtener-deposito), en los listados y dentro de `data.deposit` en cada webhook. En un webhook refleja el depósito en el momento del evento. ```json title="Depósito liquidado" { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z" } ``` ## Campos | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | uuid | Id del depósito. Estable durante toda su vida. | | `status` | enum | `detected`, `confirmed`, `settled`, `below_minimum` o `reverted`. Ver [Estados de un depósito](/docs/guias/estados-de-un-deposito). | | `customer.id` | uuid | Id del cliente en la pasarela. | | `customer.external_id` | string | Tu identificador del usuario. | | `address` | string | Dirección de depósito del cliente, en minúsculas. | | `chain_id` | integer | Red: `421614` (Arbitrum Sepolia) o `42161` (Arbitrum One). | | `token` | string | Símbolo: `USDT` o `USDC`. | | `token_address` | string | Contrato del token, en minúsculas. | | `decimals` | integer | Decimales del token. | | `amount` | string | Monto depositado, en texto decimal. | | `amount_units` | string | El mismo monto en la unidad mínima del token. | | `from` | string | Dirección que envió la transferencia, en minúsculas. Si viene de un exchange, es la del exchange. | | `tx_hash` | string | Transacción del depósito. | | `log_index` | integer | Posición de la transferencia dentro de la transacción. Con `tx_hash` identifica el depósito en la blockchain. | | `block_number` | integer | Bloque en el que entró la transferencia. | | `breakdown` | object o null | Reparto del depósito. `null` mientras está `detected` o si se revirtió. | | `sweep_tx_hash` | string o null | Transacción del barrido que llevó el dinero a la tesorería. `null` hasta que se barre. | | `detected_at` | date-time | Cuándo lo vio el servidor. | | `confirmed_at` | date-time o null | Cuándo se confirmó (también se llena en `below_minimum`). | | `settled_at` | date-time o null | Cuándo se registró el barrido. | ## breakdown | Campo | Tipo | Descripción | | --- | --- | --- | | `final` | boolean | `false`: reparto cotizado al confirmar. `true`: reparto definitivo, tomado del evento del contrato tras el barrido. | | `gas_paid_by` | enum | `platform`, `merchant` o `customer`: el `gas_mode` de la empresa cuando se confirmó el depósito. | | `platform_fee` | string | Comisión de la plataforma. | | `merchant_fee` | string | Comisión de la empresa a su usuario. Va a la billetera de comisiones. | | `gas_fee` | string | Gas descontado. `"0"` si lo paga la plataforma. Con `final: false` es el máximo; el definitivo nunca es mayor. | | `net` | string | Lo que llega a la tesorería: `amount − platform_fee − merchant_fee − gas_fee`. | ```json title="Cotizado (confirmed)" { "final": false, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.008923", "net": "96.991077" } ``` ```json title="Definitivo (settled)" { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" } ``` - Todos los montos de `breakdown` están en el token del depósito y en texto decimal. - Si varios depósitos se barrieron juntos, las comisiones y el gas del barrido se atribuyen a cada uno en proporción, sin perder ni una unidad: la suma de los `net` coincide con el neto del barrido. - En `/admin/deposits` el objeto trae además `merchant: { id, name }`. - En los eventos guardados, el orden de las claves del JSON puede variar. No dependas del orden. > `settled_at` puede ser anterior a `confirmed_at` cuando el `admin` de la empresa barrió por su cuenta antes de que el servidor confirmara el depósito. # Objeto retiro Todos los campos de un retiro, tal como llegan en la API y en los webhooks. Los montos son texto decimal en unidades del token (nunca números con coma flotante), igual que en los depósitos. | Campo | Tipo | Qué es | | --- | --- | --- | | `id` | uuid | Id del retiro en la pasarela. | | `external_id` | string | Tu clave de idempotencia (o la cabecera `Idempotency-Key`). | | `customer_ref` | string · null | Lo que enviaste en `customer_id`. | | `status` | enum | `pending`, `sent`, `completed` o `failed`. | | `chain_id` | integer | Red: 42161 (Arbitrum One) o 421614 (Arbitrum Sepolia). | | `token` / `token_address` / `decimals` | string / string / integer | Token pagado. | | `to` | string | Billetera del usuario. | | `requested` | string | Lo que pidió el usuario (sus fichas). | | `amount` | string | Lo que recibe el usuario. | | `platform_fee` | string | Comisión de la plataforma. | | `gas_fee` | string | Gas descontado (0 si lo paga la plataforma). | | `debit` | string | Lo que sale de tu caja: `amount + platform_fee + gas_fee`. | | `gas_paid_by` | enum | `merchant`, `customer` o `platform`, al momento de crear el retiro. | | `tx_hash` | string · null | Transacción del pago. Llega con `sent`. | | `error` | object · null | Solo en `failed`: `{ code, message }`. | | `metadata` | object | Tus datos libres. | | `created_at` / `sent_at` / `completed_at` / `failed_at` | date-time · null | Momentos de cada estado. | ```json title="Retiro completado" { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null } ``` # Límites Tamaños máximos, valores por defecto y lo que todavía no está implementado. > **Todavía no hay límite de peticiones.** La API no limita aún el número de peticiones por clave ni devuelve `429`. Está previsto antes de producción. Integra como si existiera: usa webhooks en lugar de consultar en bucle y trata un `429` como reintentable. ## Peticiones | Límite | Valor | | --- | --- | | Tamaño del cuerpo de una petición | 64 KB | | `external_id` | 1 a 200 caracteres | | `label` de una clave de API | Hasta 80 caracteres | | `name` de una empresa | 1 a 120 caracteres | | `search` en listados | Hasta 200 caracteres | | `type` al filtrar eventos | Hasta 60 caracteres | | `limit` en clientes y depósitos | 1 a 200 (por defecto 50) | | `limit` en eventos | 1 a 200 (por defecto 100) | | `days` en resúmenes | 1 a 365 (por defecto 30) | | Depósitos en `/v1/customers/{id}/deposits` | Los últimos 100, sin paginación | | Depósitos en `/pay/{id}/status` | Los últimos 10 | ## Condiciones de la empresa | Campo | Rango | Por defecto | | --- | --- | --- | | `platform_fee_bps` | 0 a 10000 | 100 (1 %) | | `merchant_fee_bps` | 0 a 10000 | 0 | | `merchant_fee_base` | `gross` o `net` | `gross` | | `gas_mode` | `platform`, `merchant` o `customer` | `platform` | | `min_deposit` | 0 o más (USD) | 0.5 | | `confirmations` | 0 a 10000 bloques | 4 | ## Depósitos y gas | Límite | Valor | | --- | --- | | Monto máximo de un depósito | Sin límite | | Monto mínimo | `min_deposit` de la empresa; por debajo queda `below_minimum` | | Decimales de un monto | Los del token (6 en USDT; 8 en el USDC de pruebas) | | Gas descontado por barrido | Hasta 0.50 USD por token | | Margen sobre el gas estimado | 10 % | | Garantía del gas de una cotización | 15 minutos, solo con `customer_id` | | Clientes por empresa | Sin límite | ## Webhooks | Límite | Valor | | --- | --- | | Tiempo máximo de respuesta de tu servidor | 10 segundos | | Intentos por envío | 13 (el primero y 12 reintentos), a lo largo de unos 3 días | | Antigüedad máxima de la firma que debes aceptar | 5 minutos | | Redirecciones | No se siguen: un `3xx` cuenta como fallo | | URL de webhooks por empresa | Una | ## Lo que aún no existe - **Límite de peticiones por clave.** Pendiente. - **Cabecera de idempotencia general.** No hay `Idempotency-Key`. Ver [Peticiones repetidas](/docs/guias/peticiones-repetidas). - **Cobros con monto fijo y vencimiento.** Las direcciones aceptan cualquier monto en cualquier momento. - **Filtrar qué eventos recibe el webhook** o tener varias URL. Llegan todos a la única URL. - **Cambiar las billeteras por la API.** La tesorería y la billetera de comisiones solo se cambian en el contrato, con la billetera `admin` de la empresa. - **Depósitos por otras redes.** Solo Arbitrum. - **Auditoría externa de los contratos.** Pendiente. Ver [Sin custodia](/docs/guias/sin-custodia). # llms.txt Toda la documentación en un solo archivo de texto, pensado para asistentes de código. En la ruta `/docs/llms.txt` de este sitio está toda esta documentación (guías, referencia de la API y novedades) en texto plano, en un solo archivo. Sale de la misma fuente que estas páginas, así que siempre dice lo mismo. ## Para qué sirve - Dárselo a un asistente de código (Claude, ChatGPT, Cursor, Copilot) para que escriba la integración con las rutas, los campos y los mensajes de error reales, en vez de inventarlos. - Buscar en toda la documentación de una vez con las herramientas de tu editor. - Guardar una copia de la documentación junto a tu código en una fecha concreta. ## Cómo usarlo 1. **Dale el archivo al asistente.** Pega la URL de `/docs/llms.txt` en la conversación, o descarga el archivo y añádelo al contexto del proyecto. 2. **Di qué quieres construir.** Indica tu lenguaje y tu framework, y qué parte necesitas: crear clientes, recibir webhooks, conciliar. 3. **Revisa lo que toca dinero.** Comprueba a mano la verificación de la firma, que solo se acredite con `deposit.confirmed` y que un evento repetido no acredite dos veces. ```bash title="Descargar" curl -O /docs/llms.txt ``` ```text title="Petición de ejemplo" Lee la documentación de la pasarela en /docs/llms.txt. Escribe en Node con Express: 1. Una función que cree el cliente con POST /v1/customers a partir del id de mi usuario. 2. La ruta POST /webhooks/pagos que verifique X-Pasarela-Signature sobre el cuerpo sin tocar, descarte eventos repetidos por su id y acredite el saldo solo con deposit.confirmed. 3. Una tarea diaria que recorra GET /v1/events desde el último seq guardado. La clave de API y el secreto de webhooks van en variables de entorno. ``` > **No pegues tus claves.** Nunca pongas la clave de API ni el secreto de webhooks en la conversación con un asistente. Usa valores de ejemplo (`sk_test_4f9c…`) y deja las claves reales en variables de entorno. --- # Referencia de API ## Crear cliente `POST /v1/customers` · cabecera x-api-key Crea un cliente (un usuario de tu sistema) y devuelve su dirección de depósito permanente, junto con `pay_url` y `qr_url`. La dirección se calcula al momento y no cuesta gas: el contrato del cliente se despliega solo con su primer barrido. > **Es seguro repetirla.** Si ya existe un cliente con ese `external_id`, la respuesta es `200` con el mismo cliente y la misma dirección. No se crea nada nuevo ni se actualiza `metadata`. La dirección sirve para USDT y USDC a la vez, y para todos los depósitos futuros del cliente. Guárdala o vuelve a pedirla cuando la necesites. **Parámetros del cuerpo** - `external_id` (string, obligatorio): Tu identificador del usuario (número de tarjeta, id de cuenta, correo). Entre 1 y 200 caracteres. Único por empresa. - `metadata` (object, por defecto {}): Datos libres que quieras guardar con el cliente. Se devuelven tal cual y entran en la búsqueda de [Listar clientes](/docs/referencia/listar-clientes). **201 — Creado** ```json { "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": { "nombre": "Rosa Quispe" }, "created_at": "2026-10-13T14:58:41.463Z" } } ``` **200 — Ya existía (mismo external_id)** ```json { "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": { "nombre": "Rosa Quispe" }, "created_at": "2026-10-13T14:58:41.463Z" } } ``` **400 — Datos inválidos** ```json { "error": "Datos inválidos", "details": [ "external_id: Invalid input: expected string, received undefined" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Listar clientes `GET /v1/customers` · cabecera x-api-key Devuelve los clientes de la empresa, del más nuevo al más antiguo, con un resumen de sus depósitos acreditados (`stats`). `total` es el número de clientes que cumplen el filtro, no el de la página. **Parámetros de consulta** - `limit` (integer, por defecto 50): Cuántos registros devolver. Entre 1 y 200. - `offset` (integer, por defecto 0): Cuántos registros saltar. Mínimo 0. - `search` (string): Texto a buscar dentro de `external_id`, la dirección de depósito o `metadata`. Máximo 200 caracteres. No distingue mayúsculas. **200 — Correcto** ```json { "customers": [ { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": { "nombre": "Rosa Quispe" }, "created_at": "2026-10-13T14:58:41.463Z", "stats": { "deposits": 2, "volume": "153.101981", "last_deposit_at": "2026-10-13T15:04:12.310Z" } } ], "total": 1 } ``` **400 — Parámetros inválidos** ```json { "error": "Datos inválidos", "details": [ "limit: Too big: expected number to be <=200" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Obtener cliente `GET /v1/customers/{id}` · cabecera x-api-key Devuelve un cliente por su id o por tu `external_id` (con el prefijo `ext:`). **Parámetros de ruta** - `id` (string, obligatorio): Id del cliente (UUID) o `ext:` para buscarlo por tu propio identificador. **200 — Correcto** ```json { "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123", "address": "0xeB1322F1C80462602A728Ca005f1026709A738ba", "chain_id": 421614, "network": "Arbitrum Sepolia", "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6 }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8 } ], "pay_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "qr_url": "https://api.pasarela.melenashair.com/pay/3d8ed2f2-72d4-4c27-b606-a53b5a4db086/qr.svg", "metadata": { "nombre": "Rosa Quispe" }, "created_at": "2026-10-13T14:58:41.463Z" } } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El cliente no existe** ```json { "error": "Usuario no encontrado" } ``` ## Depósitos de un cliente `GET /v1/customers/{id}/deposits` · cabecera x-api-key Devuelve los últimos 100 depósitos del cliente, del más reciente al más antiguo. No tiene paginación: para ir más atrás usa [Listar depósitos](/docs/referencia/listar-depositos) con `customer_id`. **Parámetros de ruta** - `id` (string, obligatorio): Id del cliente (UUID) o `ext:` para buscarlo por tu propio identificador. **200 — Correcto** ```json { "deposits": [ { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z" } ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El cliente no existe** ```json { "error": "Usuario no encontrado" } ``` ## Listar depósitos `GET /v1/deposits` · cabecera x-api-key Devuelve los depósitos de la empresa, del más reciente al más antiguo, cada uno con su reparto (`breakdown`). `total` es el número de depósitos que cumplen los filtros. El estado `settled` no se guarda como tal: es un depósito confirmado cuyo barrido ya terminó. Por eso `status=confirmed` devuelve solo los confirmados que aún no se han barrido. **Parámetros de consulta** - `limit` (integer, por defecto 50): Cuántos registros devolver. Entre 1 y 200. - `offset` (integer, por defecto 0): Cuántos registros saltar. Mínimo 0. - `before` (date-time): Solo depósitos detectados antes de esa fecha. ISO 8601 en UTC, terminado en `Z`. - `status` (enum): Solo depósitos en ese estado. Ver [Estados de un depósito](/docs/guias/estados-de-un-deposito). Valores: detected, confirmed, settled, below_minimum, reverted. - `token` (string): Símbolo del token (`USDT` o `USDC`). No distingue mayúsculas. - `customer_id` (string): Depósitos de un cliente. Acepta su id, su `external_id` o `ext:`. - `search` (string): Texto a buscar dentro del hash de la transacción, el `external_id`, la dirección de depósito o la dirección de origen. Máximo 200 caracteres. **200 — Correcto** ```json { "deposits": [ { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z" } ], "total": 1 } ``` **400 — Parámetros inválidos** ```json { "error": "Datos inválidos", "details": [ "status: Invalid option: expected one of \"detected\"|\"confirmed\"|\"settled\"|\"below_minimum\"|\"reverted\"" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Obtener depósito `GET /v1/deposits/{id}` · cabecera x-api-key Devuelve un depósito con su reparto. Cada campo está explicado en [Objeto depósito](/docs/guias/objeto-deposito). **Parámetros de ruta** - `id` (uuid, obligatorio): Id del depósito. **200 — Correcto** ```json { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z" } } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El depósito no existe** ```json { "error": "Depósito no encontrado" } ``` ## Crear cotización `POST /v1/quotes` · cabecera x-api-key Calcula cómo se repartiría un depósito con las condiciones actuales de la empresa y cuánto tendría que depositar el usuario para cubrir el gas (`to_cover_gas`) o para que llegue el monto entero a la tesorería (`to_cover_all`). No crea ningún cobro ni reserva nada. - Con `customer_id`, el gas se simula para la dirección de ese cliente: sabe si es su primer depósito (hay que desplegar su contrato) o no. - Sin `customer_id`, se cotiza el caso más caro: una dirección nueva (`first_deposit: true`). - Con `customer_id` y `gas_mode` distinto de `platform`, el gas cotizado queda como máximo garantizado durante 15 minutos (`gas_guaranteed_until`). En cualquier otro caso ese campo es `null`. - Si `gas_mode` es `platform`, `gas.fee` es `"0"` y `gas.estimate` informa lo que le costará a la plataforma. Si el monto no alcanza para comisiones y gas, la respuesta sigue siendo `200` y el bloque afectado trae `{ "deposit": "...", "error": "El monto no alcanza para cubrir comisiones y gas" }`. Más detalle en [Cotizaciones](/docs/guias/cotizaciones). **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `amount` (string, obligatorio): Monto como texto decimal (`"100"`, `"25.50"`), sin separador de miles ni signo. No puede tener más decimales que el token. - `customer_id` (string): Cliente para el que se cotiza. Acepta su id, su `external_id` o `ext:`. **200 — Correcto** ```json { "quote": { "token": "USDT", "decimals": 6, "amount": "100", "terms": { "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_paid_by": "customer" }, "gas": { "fee": "0.004691", "estimate": "0.004691", "first_deposit": false, "gas_units": "85051", "gas_price_wei": "20000000", "eth_usd": 2506.55, "eth_usd_source": "chainlink", "buffer_bps": 1000, "cap": "0.5" }, "if_deposit_amount": { "deposit": "100", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.004691", "net": "96.995309" }, "to_cover_gas": { "deposit": "100.004691", "platform_fee": "1.000046", "merchant_fee": "2.000093", "gas_fee": "0.004691", "net": "96.999861" }, "to_cover_all": { "deposit": "103.097619", "platform_fee": "1.030976", "merchant_fee": "2.061952", "gas_fee": "0.004691", "net": "100" }, "gas_guaranteed_until": "2026-10-13T15:19:05.271Z" } } ``` **400 — Token no soportado** ```json { "error": "Token no soportado. Usa: USDT, USDC" } ``` **400 — Demasiados decimales** ```json { "error": "USDT admite hasta 6 decimales" } ``` **400 — Monto con formato inválido** ```json { "error": "Datos inválidos", "details": [ "amount: Monto inválido" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El cliente no existe** ```json { "error": "Usuario no encontrado" } ``` ## Ver saldo de retiros `GET /v1/payouts/balance` · cabecera x-api-key Tu caja de retiros: la dirección de fondeo, cuánto hay por token, cuánto está reservado por retiros en curso, cuánto puedes pagar todavía y los límites vigentes. Los saldos se leen del contrato en el momento. | Campo de `tokens[]` | Qué es | | --- | --- | | `balance` | Saldo de la caja en el contrato. | | `funding_pending` | Lo que ya llegó a `funding_address` y se sumará a la caja en segundos. | | `reserved` | Total de retiros aceptados que todavía no salieron (`pending` y `sent`). | | `available` | `balance + funding_pending − reserved`: lo que puedes comprometer en retiros nuevos. | | `enabled` | `false` mientras no haya límites configurados: los retiros de ese token están apagados. | | `max_per_payout` | Máximo que puede recibir un usuario en un retiro. | | `daily_limit` / `remaining_today` | Tope de lo que pueden recibir todos tus usuarios juntos en el día UTC, y lo que queda de hoy (ya descontados los retiros en curso). | `settings.limits_managed_by` es `platform` si la plataforma administra tu empresa (te fija los límites a pedido) o `merchant` si tienes billetera administradora propia (los fijas tú en el contrato). Ver [Retiros](/docs/guias/retiros). **200 — Correcto** ```json { "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Cotizar retiro `POST /v1/payouts/quote` · cabecera x-api-key Calcula cuánto recibiría el usuario, la comisión, el gas y cuánto saldría de tu caja si pagaras ahora ese monto. No crea nada ni reserva saldo. El gas es una estimación con margen; el que se descuente al crear el retiro queda fijo en ese retiro. **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `amount` (string, obligatorio): Lo que pide el usuario (sus fichas, a 1 USD cada una) como texto decimal: `"500"`, `"12.50"`. No puede tener más decimales que el token. **200 — Correcto** ```json { "quote": { "token": "USDT", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant" } } ``` **422 — Comisión y gas se comen el monto** ```json { "error": "El monto no alcanza para cubrir la comisión y el gas", "code": "amount_too_small" } ``` **400 — Token no soportado** ```json { "error": "Token no soportado. Usa: USDT, USDC" } ``` **400 — Demasiados decimales** ```json { "error": "USDT admite hasta 6 decimales" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Crear retiro `POST /v1/payouts` · cabecera x-api-key Paga a un usuario desde tu caja de retiros. Si se acepta (`201`), el saldo queda reservado y el retiro sale en segundos; el resultado llega con los webhooks `payout.completed` o `payout.failed`. Es idempotente: repetir la misma petición con el mismo `external_id` devuelve el mismo retiro (`200`, `created: false`) y nunca paga dos veces. > **Descuenta las fichas antes de llamar.** Resta el saldo del usuario en tu sistema y luego crea el retiro con un `external_id` propio (por ejemplo, el id de la operación en tu base de datos). Si la llamada falla por red, repítela con el mismo `external_id`. Si llega `payout.failed`, devuelve las fichas. - En lugar de `external_id` puedes enviar la cabecera `Idempotency-Key`. Uno de los dos es obligatorio. - Mismo `external_id` con otro token, monto o destino: `409 idempotency_conflict`. - Se rechazan como destino 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 depósito). Las billeteras inteligentes (Safe y similares) se aceptan. - Las comprobaciones de saldo, límites y mínimo se hacen al crear y otra vez en el contrato al pagar. Si algo cambió entre medias (por ejemplo, apagaste tus límites), el retiro termina en `failed` sin mover dinero. **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `amount` (string, obligatorio): Lo que pide el usuario (sus fichas, a 1 USD cada una) como texto decimal: `"500"`, `"12.50"`. No puede tener más decimales que el token. - `to` (string, obligatorio): Billetera del usuario en Arbitrum (`0x` + 40 caracteres hexadecimales). La elige el usuario: una transferencia en blockchain no se puede deshacer. - `external_id` (string): Tu identificador del retiro, único por empresa. Máximo 200 caracteres. Obligatorio si no envías la cabecera `Idempotency-Key`. - `customer_id` (string): Tu identificador del usuario, para buscar y conciliar. Texto libre, máximo 200 caracteres. Se devuelve como `customer_ref`. - `metadata` (object, por defecto {}): Datos libres que se guardan con el retiro y vuelven en la API y los webhooks. **201 — Aceptado** ```json { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "pending", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": null, "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": null, "completed_at": null, "failed_at": null }, "created": true } ``` **200 — Ya existía (misma petición repetida)** ```json { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null }, "created": false } ``` **400 — Falta la clave de idempotencia** ```json { "error": "Falta external_id (o la cabecera Idempotency-Key): evita pagar dos veces el mismo retiro" } ``` **400 — Billetera mal escrita** ```json { "error": "Datos inválidos", "details": [ "to: Billetera inválida (0x + 40 caracteres)" ] } ``` **409 — Mismo external_id con otros datos** ```json { "error": "Ya existe un retiro con ese external_id y datos distintos", "code": "idempotency_conflict" } ``` **422 — Saldo insuficiente** ```json { "error": "Saldo de retiros insuficiente: hacen falta 505.008419 y hay 300 USDT disponibles", "code": "insufficient_balance" } ``` **422 — Retiros apagados** ```json { "error": "Los retiros en USDT están apagados: falta configurar los límites", "code": "payouts_disabled" } ``` **422 — Supera el máximo por retiro** ```json { "error": "El máximo por retiro es 600 USDT", "code": "above_max_per_payout" } ``` **422 — Supera el límite del día** ```json { "error": "Hoy solo quedan 180 USDT de límite diario", "code": "above_daily_limit" } ``` **422 — Menos que el mínimo** ```json { "error": "El mínimo por retiro es 1 USDT", "code": "below_minimum" } ``` **422 — Destino no permitido** ```json { "error": "Esa billetera es una dirección de depósito de la pasarela: el dinero volvería a entrar como depósito", "code": "invalid_recipient" } ``` **422 — Comisión y gas se comen el monto** ```json { "error": "El monto no alcanza para cubrir la comisión y el gas", "code": "amount_too_small" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Listar retiros `GET /v1/payouts` · cabecera x-api-key Tus retiros, del más reciente al más antiguo, con el total para paginar. **Parámetros de consulta** - `limit` (integer, por defecto 50): Cuántos registros devolver. Entre 1 y 200. - `offset` (integer, por defecto 0): Cuántos registros saltar. Mínimo 0. - `status` (enum): Solo retiros en ese estado. Valores: pending, sent, completed, failed. - `token` (string): Símbolo del token (`USDT` o `USDC`). No distingue mayúsculas. - `search` (string): Busca en `external_id`, `customer_ref`, la billetera de destino, el hash y el id. Máximo 200 caracteres. **200 — Correcto** ```json { "payouts": [ { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null } ], "total": 1 } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Obtener retiro `GET /v1/payouts/{id}` · cabecera x-api-key Un retiro por su id o por tu `external_id`. Útil para conciliar si se perdió algún webhook. **Parámetros de ruta** - `id` (string, obligatorio): Id del retiro (UUID) o `ext:`. **200 — Correcto** ```json { "payout": { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null } } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El retiro no existe** ```json { "error": "Retiro no encontrado" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Listar fondeos `GET /v1/payouts/fundings` · cabecera x-api-key Los últimos 50 fondeos que se acreditaron en tu caja desde la dirección de fondeo, con su transacción. **200 — Correcto** ```json { "fundings": [ { "id": "0b9d7e5c-3a1f-4e2d-8c6b-5a4f3e2d1c0b", "token": "USDT", "amount": "1000", "tx_hash": "0x3e8b1d6f0a2c4e7b9d1f3a5c7e9b0d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b", "created_at": "2026-10-14T13:15:42.871Z" } ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Devolver saldo a la tesorería `POST /v1/payouts/return` · cabecera x-api-key Saca saldo de tu caja de retiros y lo envía a **tu tesorería**. El contrato no permite otro destino. No puede tocar el saldo reservado por retiros en curso. Solo funciona si la plataforma administra tu empresa. Si tienes billetera administradora propia responde `409`: en ese caso llamas tú a `withdraw(merchantId, token, amount)` del contrato de retiros desde esa billetera (también vale si la plataforma deja de existir). **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `amount` (string, obligatorio): Monto a devolver, como texto decimal. **200 — Devuelto** ```json { "ok": true, "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` **409 — La empresa se administra sola** ```json { "error": "Tu empresa tiene billetera administradora propia (0x…): llama a withdraw en el contrato 0x… desde esa billetera" } ``` **422 — Saldo reservado** ```json { "error": "No puedes devolver saldo reservado para retiros en curso" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **503 — Retiros no activados en esta pasarela** ```json { "error": "Los retiros no están activados en esta pasarela" } ``` ## Retiros de todas las empresas `GET /admin/payouts` · cabecera x-admin-token Todos los retiros de la pasarela, con la empresa de cada uno. Mismos filtros que [Listar retiros](/docs/referencia/listar-retiros), más `merchant_id`. **Parámetros de consulta** - `limit` (integer, por defecto 50): Cuántos registros devolver. Entre 1 y 200. - `offset` (integer, por defecto 0): Cuántos registros saltar. Mínimo 0. - `merchant_id` (uuid): Solo los de esa empresa. - `status` (enum): Solo retiros en ese estado. Valores: pending, sent, completed, failed. - `token` (string): Símbolo del token (`USDT` o `USDC`). No distingue mayúsculas. - `search` (string): Busca en `external_id`, `customer_ref`, destino, hash e id. **200 — Correcto** ```json { "payouts": [ { "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11", "external_id": "retiro-000981", "customer_ref": "jugador-77", "status": "completed", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68", "requested": "500", "amount": "500", "platform_fee": "5", "gas_fee": "0.008419", "debit": "505.008419", "gas_paid_by": "merchant", "tx_hash": "0x9d2c41b07e3f5a6c8e1b0d4f7a2c9e6b3d8f1a0c5e7b2d4f6a8c0e1b3d5f7a9c", "error": null, "metadata": { "fichas": 500 }, "created_at": "2026-10-14T13:20:05.114Z", "sent_at": "2026-10-14T13:20:06.302Z", "completed_at": "2026-10-14T13:20:08.947Z", "failed_at": null, "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina" } } ], "total": 1 } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Configurar retiros de una empresa `PATCH /admin/merchants/{id}/payouts` · cabecera x-admin-token Comisión de la plataforma en retiros, quién paga el gas y mínimo por retiro. La comisión se escribe también en el contrato, que la usa como tope: el servidor nunca puede cobrar más. Devuelve el saldo de retiros de la empresa. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **Parámetros del cuerpo** - `payout_fee_bps` (integer): Comisión de la plataforma en puntos básicos (100 = 1 %). Entre 0 y 10000. - `gas_paid_by` (enum): Quién paga comisión y gas. Ver [Retiros](/docs/guias/retiros#quien-paga-comision-y-gas). Valores: merchant, customer, platform. - `min_payout` (number): Mínimo por retiro, en USD. Por defecto 1. **200 — Correcto** ```json { "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Fijar límites de retiro `PUT /admin/merchants/{id}/payouts/limits` · cabecera x-admin-token Máximo por retiro y límite diario de un token, escritos en el contrato. Con `max_per_payout` en `"0"` los retiros de ese token quedan apagados. Solo para empresas que administra la plataforma. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `max_per_payout` (string, obligatorio): Máximo que puede recibir un usuario en un retiro, como texto decimal. - `daily_limit` (string, obligatorio): Máximo que pueden recibir todos los usuarios juntos en un día UTC. No puede ser menor que `max_per_payout`. **200 — Correcto** ```json { "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` **400 — Máximo mayor que el diario** ```json { "error": "El máximo por retiro no puede superar el límite diario" } ``` **409 — La empresa se administra sola** ```json { "error": "La empresa tiene su propia billetera administradora: ella fija sus límites y devuelve su saldo desde el contrato" } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Devolver saldo de una empresa `POST /admin/merchants/{id}/payouts/return` · cabecera x-admin-token Envía saldo de la caja de una empresa a la tesorería de esa empresa. La plataforma puede hacerlo siempre porque el destino lo fija el contrato: es la salida si una empresa deja de operar. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **Parámetros del cuerpo** - `token` (string, obligatorio): Símbolo del token: `USDT` o `USDC`. No distingue mayúsculas. - `amount` (string, obligatorio): Monto a devolver, como texto decimal. **200 — Devuelto** ```json { "balance": { "vault": "0xfA36dDDc48ac0456844E08492cc3302d27fC7A93", "funding_address": "0x445324D8D40762C2dCE39515390596be8681B232", "chain_id": 421614, "network": "Arbitrum Sepolia", "settings": { "payout_fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1", "limits_managed_by": "platform", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d" }, "tokens": [ { "token": "USDT", "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "balance": "1000", "funding_pending": "0", "reserved": "0", "available": "1000", "enabled": true, "max_per_payout": "600", "daily_limit": "2000", "remaining_today": "2000" }, { "token": "USDC", "token_address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "balance": "0", "funding_pending": "250", "reserved": "0", "available": "250", "enabled": false, "max_per_payout": "0", "daily_limit": "0", "remaining_today": "0" } ] } } ``` **422 — Saldo reservado** ```json { "error": "No se puede devolver saldo reservado para retiros en curso" } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Listar eventos `GET /v1/events` · cabecera x-api-key Devuelve todos los eventos de la empresa en orden, con el estado de entrega del webhook de cada uno. Sirve para conciliar: guarda el último `seq` que procesaste y pide lo siguiente con `after`. - `seq` es un número creciente. Es único en toda la plataforma, así que para tu empresa puede tener saltos. - `delivery` muestra el envío más reciente del evento (si lo reenviaste, el reenvío). Es `null` si no había `webhook_url` configurada cuando ocurrió. - `delivery.next_attempt_at` solo tiene valor mientras el envío está `pending`. **Parámetros de consulta** - `after` (integer, por defecto 0): Solo eventos con `seq` mayor que este valor. - `before` (integer): Solo eventos con `seq` menor que este valor. Mínimo 1. - `order` (enum, por defecto asc): Orden por `seq`: `asc` del más antiguo al más nuevo, `desc` al revés. Valores: asc, desc. - `type` (string): Solo eventos de ese tipo exacto, por ejemplo `deposit.confirmed`. Máximo 60 caracteres. - `limit` (integer, por defecto 100): Cuántos eventos devolver. Entre 1 y 200. **200 — Correcto** ```json { "events": [ { "seq": 35, "id": "87f9bb94-70af-4879-a8e0-a2c03e974a8e", "type": "deposit.confirmed", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "confirmed", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": false, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.008923", "net": "96.991077" }, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": null } }, "delivery": { "status": "delivered", "attempts": 1, "last_status": 200, "last_error": null, "url": "https://tu-servidor.com/webhooks/pagos", "delivered_at": "2026-10-13T15:04:13.564Z", "next_attempt_at": null } } ] } ``` **400 — Parámetros inválidos** ```json { "error": "Datos inválidos", "details": [ "order: Invalid option: expected one of \"asc\"|\"desc\"" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Obtener evento `GET /v1/events/{id}` · cabecera x-api-key Devuelve un evento y todos sus envíos de webhook (el original y los reenvíos), del más reciente al más antiguo, con el resultado de cada uno. **Parámetros de ruta** - `id` (uuid, obligatorio): Id del evento. Es el mismo `id` que llega en el webhook y en la cabecera `X-Pasarela-Event-Id`. **200 — Correcto** ```json { "event": { "seq": 35, "id": "87f9bb94-70af-4879-a8e0-a2c03e974a8e", "type": "deposit.confirmed", "created_at": "2026-10-13T15:04:13.289Z", "data": { "deposit": { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "confirmed", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": false, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.008923", "net": "96.991077" }, "sweep_tx_hash": null, "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": null } } }, "deliveries": [ { "id": "db7bd9f5-f3cd-40db-8c8f-cecd661b6eaa", "url": "https://tu-servidor.com/webhooks/pagos", "status": "delivered", "attempts": 2, "last_status": 200, "last_error": null, "created_at": "2026-10-13T15:04:13.289Z", "delivered_at": "2026-10-13T15:04:23.911Z", "next_attempt_at": null } ] } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El evento no existe** ```json { "error": "Evento no encontrado" } ``` ## Reenviar evento `POST /v1/events/{id}/resend` · cabecera x-api-key Pone en cola un envío nuevo del evento hacia la `webhook_url` actual. El aviso lleva el mismo `id` de evento, así que tu servidor puede reconocerlo como repetido. Sirve para envíos que quedaron en `dead` o para volver a procesar un aviso. **Parámetros de ruta** - `id` (uuid, obligatorio): Id del evento. **200 — En cola** ```json { "ok": true } ``` **400 — Sin webhook configurado** ```json { "error": "Configura primero webhook_url" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — El evento no existe** ```json { "error": "Evento no encontrado" } ``` ## Enviar webhook de prueba `POST /v1/webhook/test` · cabecera x-api-key Crea un evento `webhook.test` y lo envía a tu `webhook_url`, firmado igual que los reales. Su `data` es `{ "message": "Aviso de prueba" }`. Consulta el resultado con [Obtener evento](/docs/referencia/obtener-evento). **200 — En cola** ```json { "event_id": "a136ae86-5793-4df5-bebe-d8a8e91043f1" } ``` **400 — Sin webhook configurado** ```json { "error": "Configura primero webhook_url" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Ver secreto de webhooks `GET /v1/webhook/secret` · cabecera x-api-key Devuelve el secreto con el que se firman los webhooks de la empresa. Se usa para [verificar la firma](/docs/guias/webhooks). **200 — Correcto** ```json { "webhook_secret": "whsec_3b1f…" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Rotar secreto de webhooks `POST /v1/webhook/secret/rotate` · cabecera x-api-key Genera un secreto nuevo y lo devuelve. El anterior deja de valer en el acto: todos los avisos siguientes, incluidos los reintentos pendientes, ya van firmados con el nuevo. > **No hay período de convivencia.** Entre la rotación y el momento en que tu servidor carga el secreto nuevo, las firmas no van a coincidir. Los avisos rechazados se reintentan solos; aun así, conviene que tu servidor acepte los dos secretos durante el cambio. Ver [Claves de API](/docs/guias/claves-de-api). **200 — Secreto nuevo** ```json { "webhook_secret": "whsec_9d27…" } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Ver cuenta `GET /v1/me` · cabecera x-api-key Devuelve la configuración de la empresa: red, contrato, billeteras, comisiones, quién paga el gas, mínimos y webhook. `min_deposit` se devuelve como texto con seis decimales (`"0.500000"`), aunque al cambiarlo se envía como número. **200 — Correcto** ```json { "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z" } } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Actualizar cuenta `PATCH /v1/me` · cabecera x-api-key Cambia lo que la empresa decide por su cuenta: su comisión y la base sobre la que se calcula, el depósito mínimo, los bloques de confirmación y la URL de webhooks. Envía solo los campos que quieras cambiar. - Cualquier otro campo (por ejemplo `platform_fee_bps` o `gas_mode`, que fija la plataforma) hace que la petición falle con `400`. - Si cambia `merchant_fee_bps` o `merchant_fee_base`, el cambio se envía primero al contrato y la respuesta tarda unos segundos. Si la transacción falla, no cambia nada. - Los cambios aplican a los depósitos que se confirmen después. Un depósito ya confirmado conserva el reparto con el que se cotizó. **Parámetros del cuerpo** - `merchant_fee_bps` (integer): Comisión que la empresa cobra a su usuario, en puntos básicos (200 = 2 %). Entre 0 y 10000. - `merchant_fee_base` (enum): Base de la comisión de la empresa: `gross` = sobre el depósito; `net` = sobre lo que queda tras la comisión de la plataforma. Valores: gross, net. - `min_deposit` (number): Depósito mínimo en USD (1 token = 1 USD). Por debajo, el depósito queda como `below_minimum` y no se acredita. Mínimo 0. - `confirmations` (integer): Bloques de espera antes de confirmar un depósito. Entre 0 y 10000. - `webhook_url` (string): URL que recibe los webhooks, o `null` para dejar de enviarlos. En producción debe empezar por `https://`; en pruebas también se acepta `http://`. **200 — Actualizada** ```json { "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z" } } ``` **400 — Comisiones mayores que el depósito** ```json { "error": "Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito" } ``` **400 — Datos inválidos** ```json { "error": "Datos inválidos", "details": [ "merchant_fee_bps: Too big: expected number to be <=10000" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Ver resumen `GET /v1/stats` · cabecera x-api-key Resumen de la empresa en los últimos `days` días: totales, depósitos por estado, por token y por día, número de clientes y estado de los webhooks. Los montos suman USDT y USDC como dólares (1 token = 1 USD). - `totals`, `by_token` y `daily` cuentan solo depósitos acreditados (confirmados, se hayan liquidado ya o no). - `by_status` cuenta todos los depósitos del período, incluidos los que no llegaron al mínimo y los anulados. - `customers` y `webhooks` son totales históricos: no dependen de `days`. **Parámetros de consulta** - `days` (integer, por defecto 30): Días hacia atrás que cubre el resumen. Entre 1 y 365. **200 — Correcto** ```json { "stats": { "range_days": 2, "totals": { "deposits": 2, "paying_customers": 1, "volume": "153.101981", "net": "148.495513", "platform_fee": "1.531019", "merchant_fee": "3.062039", "gas_fee": "0.01341" }, "by_status": { "detected": 0, "confirmed": 0, "settled": 2, "below_minimum": 0, "reverted": 0 }, "by_token": [ { "token": "USDT", "deposits": 2, "volume": "153.101981", "net": "148.495513" } ], "daily": [ { "day": "2026-10-12", "deposits": 0, "volume": "0", "net": "0", "platform_fee": "0" }, { "day": "2026-10-13", "deposits": 2, "volume": "153.101981", "net": "148.495513", "platform_fee": "1.531019" } ], "customers": 1, "webhooks": { "pending": 0, "delivered": 6, "dead": 0 } } } ``` **400 — Parámetros inválidos** ```json { "error": "Datos inválidos", "details": [ "days: Too big: expected number to be <=365" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Listar claves de API `GET /v1/api-keys` · cabecera x-api-key Devuelve las claves de la empresa, activas y revocadas, de la más nueva a la más antigua. Nunca incluye la clave completa: solo su `prefix` (los primeros 12 caracteres), su nombre y cuándo se usó por última vez. `last_used_at` se actualiza como mucho una vez por minuto. **200 — Correcto** ```json { "api_keys": [ { "id": "f80946e4-e26e-4ca8-802d-db3f52bb60b5", "prefix": "sk_test_a81d", "label": "Rotación octubre", "created_at": "2026-10-13T16:10:02.771Z", "last_used_at": null, "revoked_at": null }, { "id": "ee9aa2e1-aadd-4af7-b4fd-bc965307bbe4", "prefix": "sk_test_4f9c", "label": "Servidor de producción", "created_at": "2026-10-13T14:52:28.159Z", "last_used_at": "2026-10-13T15:03:58.120Z", "revoked_at": null } ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Crear clave de API `POST /v1/api-keys` · cabecera x-api-key Crea una clave adicional. Las claves anteriores siguen funcionando, así que se puede rotar sin cortar el servicio. El cuerpo es opcional. > **La clave completa se muestra una sola vez.** `api_key` solo viene en esta respuesta. El servidor guarda únicamente su huella, así que no se puede recuperar después: si la pierdes, crea otra. **Parámetros del cuerpo** - `label` (string): Nombre para distinguir la clave. Máximo 80 caracteres. Acepta `null`. **201 — Creada** ```json { "key": { "id": "f80946e4-e26e-4ca8-802d-db3f52bb60b5", "prefix": "sk_test_a81d", "label": "Rotación octubre", "created_at": "2026-10-13T16:10:02.771Z", "last_used_at": null, "revoked_at": null }, "api_key": "sk_test_a81d…" } ``` **400 — Datos inválidos** ```json { "error": "Datos inválidos", "details": [ "label: Too big: expected string to have <=80 characters" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` ## Revocar clave de API `DELETE /v1/api-keys/{id}` · cabecera x-api-key Revoca una clave: deja de funcionar de inmediato y no se puede reactivar. No se permite revocar la única clave activa. Revocar una clave ya revocada responde `200` sin cambios. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la clave (no la clave en sí). **200 — Revocada** ```json { "api_key": { "id": "ee9aa2e1-aadd-4af7-b4fd-bc965307bbe4", "prefix": "sk_test_4f9c", "label": "Servidor de producción", "created_at": "2026-10-13T14:52:28.159Z", "last_used_at": "2026-10-13T15:03:58.120Z", "revoked_at": "2026-10-13T16:12:40.018Z" } } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave inválida o ausente** ```json { "error": "Clave de API inválida" } ``` **404 — La clave no existe** ```json { "error": "Clave no encontrada" } ``` **409 — Es la última clave activa** ```json { "error": "Es la única clave activa: crea otra antes de revocar esta" } ``` ## Página de pago `GET /pay/{id}` · pública Página HTML lista para mostrar al usuario: QR de su dirección, la dirección con botón de copiar, la red, los tokens aceptados, el depósito mínimo y la lista de sus depósitos, que se actualiza sola cada 3 segundos. Es la `pay_url` del cliente. Responde `text/html` con `cache-control: no-store`. No pide clave: quien tenga el enlace puede verla. **Parámetros de ruta** - `id` (uuid, obligatorio): Id del cliente. Es el que viene en `pay_url`. **200 — Página HTML** ```json … (documento HTML) ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **404 — El cliente no existe** ```json { "error": "No encontrado" } ``` ## Estado de la página de pago `GET /pay/{id}/status` · pública Devuelve los últimos 10 depósitos del cliente con lo mínimo para mostrar el avance: monto, token, estado, hash y fecha. No incluye comisiones ni reparto. Es lo que consulta la página de pago; sirve también para una pantalla propia. **Parámetros de ruta** - `id` (uuid, obligatorio): Id del cliente. Es el que viene en `pay_url`. **200 — Correcto** ```json { "deposits": [ { "amount": "100", "token": "USDT", "status": "settled", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "detected_at": "2026-10-13T15:04:12.310Z" } ] } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **404 — El cliente no existe** ```json { "error": "No encontrado" } ``` ## QR de pago `GET /pay/{id}/qr.svg` · pública Imagen SVG con el QR del cliente. Sin parámetros contiene solo la dirección, que cualquier billetera puede leer. Con `token` contiene un enlace EIP-681 que abre la transferencia de ese token en la red correcta; con `amount`, además, con el monto ya puesto. Es la `qr_url` del cliente. `amount` solo se usa si también envías `token`. Más detalle en [Página de pago](/docs/guias/pagina-de-pago). **Parámetros de ruta** - `id` (uuid, obligatorio): Id del cliente. Es el que viene en `pay_url`. **Parámetros de consulta** - `token` (string): Símbolo del token (`USDT` o `USDC`) para generar un enlace EIP-681. - `amount` (string): Monto como texto decimal para dejarlo fijado en el enlace. **200 — Imagen SVG** ```json … ``` **400 — Token no soportado** ```json { "error": "Token no soportado" } ``` **400 — Monto con formato inválido** ```json { "error": "Datos inválidos", "details": [ "amount: Monto inválido" ] } ``` **404 — El cliente no existe** ```json { "error": "No encontrado" } ``` ## Crear empresa `POST /admin/merchants` · cabecera x-admin-token Da de alta una empresa: la registra en el contrato con sus billeteras y condiciones, y devuelve su primera clave de API y su secreto de webhooks. Incluye una transacción en la blockchain, así que tarda unos segundos. > **La clave se muestra una sola vez.** `api_key` solo aparece en esta respuesta. El secreto de webhooks sí se puede volver a consultar con [Ver secreto de webhooks](/docs/referencia/ver-secreto-de-webhooks). > **Billetera de administración.** Si no envías `admin_address`, la empresa queda administrada por la cuenta de la plataforma, que entonces sí podría cambiar su tesorería. Para que sea sin custodia de verdad, cada empresa debe tener su propia billetera `admin`. Ver [Sin custodia](/docs/guias/sin-custodia). **Parámetros del cuerpo** - `name` (string, obligatorio): Nombre de la empresa. Entre 1 y 120 caracteres. Se muestra en la página de pago. - `treasury` (string, obligatorio): Dirección que recibe el neto de cada depósito. - `fee_wallet` (string): Dirección que recibe la comisión de la empresa. Si se omite o es `null`, esa comisión va a la tesorería. - `admin_address` (string): Billetera de la empresa que administra su configuración en el contrato. Si se omite o es `null`, administra la plataforma. - `platform_fee_bps` (integer, por defecto 100): Comisión de la plataforma en puntos básicos (100 = 1 %). Entre 0 y 10000. - `merchant_fee_bps` (integer, por defecto 0): Comisión que la empresa cobra a su usuario, en puntos básicos (200 = 2 %). Entre 0 y 10000. - `merchant_fee_base` (enum, por defecto gross): Base de la comisión de la empresa: `gross` = sobre el depósito; `net` = sobre lo que queda tras la comisión de la plataforma. Valores: gross, net. - `gas_mode` (enum, por defecto platform): Quién paga el gas del barrido. Ver [Gas](/docs/guias/gas). Valores: platform, merchant, customer. - `min_deposit` (number, por defecto 0.5): Depósito mínimo en USD (1 token = 1 USD). Por debajo, el depósito queda como `below_minimum` y no se acredita. Mínimo 0. - `confirmations` (integer, por defecto 4): Bloques de espera antes de confirmar un depósito. Entre 0 y 10000. - `webhook_url` (string, por defecto null): URL que recibe los webhooks, o `null` para dejar de enviarlos. En producción debe empezar por `https://`; en pruebas también se acepta `http://`. **201 — Creada** ```json { "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z" }, "api_key": "sk_test_4f9c…", "webhook_secret": "whsec_3b1f…" } ``` **400 — Datos inválidos** ```json { "error": "Datos inválidos", "details": [ "treasury: Dirección inválida" ] } ``` **400 — Comisiones mayores que el depósito** ```json { "error": "Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito" } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Listar empresas `GET /admin/merchants` · cabecera x-admin-token Devuelve todas las empresas, de la más antigua a la más nueva, con un resumen histórico de cada una (`stats`): clientes, depósitos acreditados, volumen e ingresos de la plataforma. No tiene paginación. **200 — Correcto** ```json { "merchants": [ { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z", "stats": { "customers": 1, "deposits": 2, "volume": "153.101981", "platform_revenue": "1.531019", "last_deposit_at": "2026-10-13T15:04:12.310Z" } } ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Obtener empresa `GET /admin/merchants/{id}` · cabecera x-admin-token Devuelve una empresa con su resumen, su identificador dentro del contrato (`chain_merchant_id`) y sus claves de API (sin la clave completa). **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **200 — Correcto** ```json { "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z", "stats": { "customers": 1, "deposits": 2, "volume": "153.101981", "platform_revenue": "1.531019", "last_deposit_at": "2026-10-13T15:04:12.310Z" }, "chain_merchant_id": "0x6ae82d53a7735f63b3224bb969effb9ab3ddf057428b11ffda9c4a4afe4248fd" }, "api_keys": [ { "id": "ee9aa2e1-aadd-4af7-b4fd-bc965307bbe4", "prefix": "sk_test_4f9c", "label": "Servidor de producción", "created_at": "2026-10-13T14:52:28.159Z", "last_used_at": "2026-10-13T15:03:58.120Z", "revoked_at": null } ] } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Actualizar empresa `PATCH /admin/merchants/{id}` · cabecera x-admin-token Cambia las condiciones de una empresa: la comisión de la plataforma, quién paga el gas y todo lo que la empresa también puede cambiar por su cuenta. Envía solo los campos que quieras cambiar; cualquier campo desconocido responde `400`. Las comisiones y el paso de `gas_mode` entre `platform` y los otros dos modos se escriben primero en el contrato. Si esa transacción falla, la base de datos no cambia. Las billeteras (`treasury`, `fee_wallet`, `admin_address`) no se cambian por la API: solo las cambia el `admin` de la empresa en el contrato. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **Parámetros del cuerpo** - `platform_fee_bps` (integer): Comisión de la plataforma en puntos básicos (100 = 1 %). Entre 0 y 10000. - `merchant_fee_bps` (integer): Comisión que la empresa cobra a su usuario, en puntos básicos (200 = 2 %). Entre 0 y 10000. - `merchant_fee_base` (enum): Base de la comisión de la empresa: `gross` = sobre el depósito; `net` = sobre lo que queda tras la comisión de la plataforma. Valores: gross, net. - `gas_mode` (enum): Quién paga el gas del barrido. Ver [Gas](/docs/guias/gas). Valores: platform, merchant, customer. - `min_deposit` (number): Depósito mínimo en USD (1 token = 1 USD). Por debajo, el depósito queda como `below_minimum` y no se acredita. Mínimo 0. - `confirmations` (integer): Bloques de espera antes de confirmar un depósito. Entre 0 y 10000. - `webhook_url` (string): URL que recibe los webhooks, o `null` para dejar de enviarlos. En producción debe empezar por `https://`; en pruebas también se acepta `http://`. **200 — Actualizada** ```json { "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "chain_id": 421614, "network": "Arbitrum Sepolia", "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "admin_address": "0xF16217230D35154bf5D7691e544c1769B3F7e12d", "treasury": "0x284847Daa892f5A307Df2Fe0685F0B46480F51bB", "fee_wallet": "0x0A0418A471C516FC7ACF766c82b619c8cd5f6860", "platform_fee_bps": 100, "merchant_fee_bps": 200, "merchant_fee_base": "gross", "gas_mode": "merchant", "min_deposit": "0.500000", "confirmations": 4, "webhook_url": "https://tu-servidor.com/webhooks/pagos", "payouts": { "fee_bps": 100, "gas_paid_by": "merchant", "min_payout": "1" }, "created_at": "2026-10-13T14:52:28.158Z" } } ``` **400 — Comisiones mayores que el depósito** ```json { "error": "Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito" } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Crear clave para una empresa `POST /admin/merchants/{id}/api-keys` · cabecera x-admin-token Crea una clave de API adicional para una empresa, por ejemplo si perdió la suya. Las claves anteriores siguen activas. La clave completa solo se muestra en esta respuesta. **Parámetros de ruta** - `id` (uuid, obligatorio): Id de la empresa. **Parámetros del cuerpo** - `label` (string): Nombre para distinguir la clave. Máximo 80 caracteres. Acepta `null`. **201 — Creada** ```json { "key": { "id": "f80946e4-e26e-4ca8-802d-db3f52bb60b5", "prefix": "sk_test_a81d", "label": "Entregada por soporte", "created_at": "2026-10-13T16:10:02.771Z", "last_used_at": null, "revoked_at": null }, "api_key": "sk_test_a81d…" } ``` **400 — El id no es un UUID** ```json { "error": "Datos inválidos", "details": [ "id: Invalid UUID" ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` **404 — La empresa no existe** ```json { "error": "Empresa no encontrada" } ``` ## Resumen de la plataforma `GET /admin/stats` · cabecera x-admin-token El mismo resumen que [Ver resumen](/docs/referencia/ver-resumen), pero sumando todas las empresas. Añade el número de empresas y las cinco con más volumen en el período (`top_merchants`). **Parámetros de consulta** - `days` (integer, por defecto 30): Días hacia atrás que cubre el resumen. Entre 1 y 365. **200 — Correcto** ```json { "stats": { "range_days": 2, "totals": { "deposits": 14, "paying_customers": 12, "volume": "966.203997", "net": "941.795833", "platform_fee": "9.662039", "merchant_fee": "14.684079", "gas_fee": "0.062046" }, "by_status": { "detected": 0, "confirmed": 0, "settled": 16, "below_minimum": 0, "reverted": 0 }, "by_token": [ { "token": "USDT", "deposits": 12, "volume": "766.203997", "net": "747.773485" }, { "token": "USDC", "deposits": 2, "volume": "200", "net": "194.022348" } ], "daily": [ { "day": "2026-10-12", "deposits": 0, "volume": "0", "net": "0", "platform_fee": "0" }, { "day": "2026-10-13", "deposits": 14, "volume": "966.203997", "net": "941.795833", "platform_fee": "9.662039" } ], "merchants": 12, "customers": 12, "webhooks": { "pending": 0, "delivered": 48, "dead": 0 }, "top_merchants": [ { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina", "deposits": 2, "volume": "153.101981", "platform_fee": "1.531019" } ] } } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Depósitos de todas las empresas `GET /admin/deposits` · cabecera x-admin-token Depósitos de todas las empresas, del más reciente al más antiguo. Cada uno trae, además del objeto depósito, la empresa a la que pertenece (`merchant`). **Parámetros de consulta** - `limit` (integer, por defecto 50): Cuántos registros devolver. Entre 1 y 200. - `offset` (integer, por defecto 0): Cuántos registros saltar. Mínimo 0. - `merchant_id` (uuid): Solo depósitos de esa empresa. - `status` (enum): Solo depósitos en ese estado. Ver [Estados de un depósito](/docs/guias/estados-de-un-deposito). Valores: detected, confirmed, settled, below_minimum, reverted. - `token` (string): Símbolo del token (`USDT` o `USDC`). No distingue mayúsculas. - `search` (string): Texto a buscar dentro del hash, el `external_id`, la dirección de depósito o la dirección de origen. Máximo 200 caracteres. **200 — Correcto** ```json { "deposits": [ { "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626", "status": "settled", "customer": { "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086", "external_id": "TARJETA-000123" }, "address": "0xeb1322f1c80462602a728ca005f1026709a738ba", "chain_id": 421614, "token": "USDT", "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183", "decimals": 6, "amount": "100", "amount_units": "100000000", "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6", "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d", "log_index": 20, "block_number": 318874750, "breakdown": { "final": true, "gas_paid_by": "merchant", "platform_fee": "1", "merchant_fee": "2", "gas_fee": "0.00885", "net": "96.99115" }, "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb", "detected_at": "2026-10-13T15:04:12.310Z", "confirmed_at": "2026-10-13T15:04:13.287Z", "settled_at": "2026-10-13T15:04:23.402Z", "merchant": { "id": "01a01557-db25-4c0a-91da-7a46e3f6c163", "name": "Transportes Andina" } } ], "total": 16 } ``` **400 — Parámetros inválidos** ```json { "error": "Datos inválidos", "details": [ "merchant_id: Invalid UUID" ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Gas cobrado frente a gas real `GET /admin/gas` · cabecera x-admin-token Compara, por token, el gas que se descontó de los barridos con lo que costó de verdad, y cuánto gas absorbió la plataforma. También da la estimación típica de un primer depósito y de los siguientes con el precio actual. - `charged`: barridos donde el gas se descontó (lo pagó la empresa o el usuario). `gas_charged` es lo cobrado y `gas_real_cost` lo que costó. - `absorbed`: barridos donde el gas lo pagó la plataforma. - `typical_estimate` usa la mediana de los últimos 50 barridos reales de cada tipo. **200 — Correcto** ```json { "typical_estimate": [ { "token": "USDT", "first_deposit": "0.008654", "next_deposits": "0.0044", "eth_usd": 2506.55, "gas_price_wei": "20000000" }, { "token": "USDC", "first_deposit": "0.00865262", "next_deposits": "0.00439914", "eth_usd": 2506.55, "gas_price_wei": "20000000" } ], "totals": [ { "token": "USDC", "charged": { "sweeps": 2, "gas_charged": "0.01765175", "gas_real_cost": "0.01575383" }, "absorbed": { "sweeps": 0, "gas_real_cost": "0" } }, { "token": "USDT", "charged": { "sweeps": 6, "gas_charged": "0.044394", "gas_real_cost": "0.039702" }, "absorbed": { "sweeps": 4, "gas_real_cost": "0.028444" } } ] } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Estado del sistema `GET /admin/system` · cabecera x-admin-token Estado de las piezas del servidor: red y contrato, saldo de ETH de la cuenta dueña y del relayer (el que paga el gas de los barridos), atraso del lector de bloques, precio del ETH, topes de gas por token y colas pendientes. - `indexer.lag_blocks`: bloques que le faltan por leer al servidor. Lo normal es 0 o unos pocos. - `relayer.authorized`: si el contrato reconoce al relayer. En `false`, no se puede barrer. - `queues.deposits_to_sweep`: depósitos confirmados que aún no se han barrido. `queues.webhooks_dead`: envíos que agotaron los reintentos. - Si el nodo no responde, los campos que dependen de él vienen en `null` en vez de fallar la petición. **200 — Correcto** ```json { "system": { "chain_id": 421614, "network": "Arbitrum Sepolia", "testnet": true, "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "platform_wallet": "0x8C35dA812628A713041c7a496b95ac58503A716E", "owner": { "address": "0x78369Dbf247cA562331ac6DF7dfA736aC001129E", "eth": "0.009876642981452" }, "relayer": { "address": "0x8C35dA812628A713041c7a496b95ac58503A716E", "eth": "0.009949028111788", "authorized": true }, "indexer": { "latest_block": "318877372", "next_block": "318877373", "lag_blocks": 0 }, "eth_usd": 2506.55, "eth_usd_source": "chainlink", "gas_buffer_bps": 1000, "gas_fee_cap_usd": 0.5, "tokens": [ { "symbol": "USDT", "address": "0xdc190461C20DE36fFB305b0190985B4023D7D183", "decimals": 6, "gas_fee_cap": "0.5" }, { "symbol": "USDC", "address": "0x928C3C521F85790c4B9b8c37251f4e22186610aE", "decimals": 8, "gas_fee_cap": "0.5" } ], "queues": { "deposits_detected": 0, "deposits_to_sweep": 0, "sweeps_in_flight": 0, "sweeps_failed_24h": 0, "webhooks_pending": 0, "webhooks_dead": 0 } } } ``` **401 — Clave de administración inválida** ```json { "error": "No autorizado" } ``` ## Estado del servicio `GET /health` · pública Comprobación rápida de que el servidor responde. Devuelve la red, la dirección del contrato, el último bloque de la red (`latest_block`) y el siguiente que el servidor va a leer (`next_block`). Si los dos están cerca, los depósitos se están detectando al día. `latest_block` viene en `null` si el nodo de la red no responde en ese momento. **200 — Correcto** ```json { "ok": true, "chain_id": 421614, "factory": "0x4e6ccA28Aec69fc738Cfa770d0a5fF9eb80ddD6F", "latest_block": "318877350", "next_block": "318877329" } ``` --- # Cambios ## 2026-10-14 — Retiros: paga a tus usuarios en USDT y USDC Ahora tu empresa también puede pagar: un usuario cambia sus fichas (1 ficha = 1 USD) por USDT o USDC y los recibe en su billetera en unos segundos. El dinero sale de tu **caja de retiros**, que fondeas enviando USDT o USDC a tu dirección de fondeo. Ver [Retiros](/docs/guias/retiros). - **Rutas nuevas:** [Ver saldo de retiros](/docs/referencia/saldo-de-retiros), [Cotizar retiro](/docs/referencia/cotizar-retiro), [Crear retiro](/docs/referencia/crear-retiro), [Listar retiros](/docs/referencia/listar-retiros), [Obtener retiro](/docs/referencia/obtener-retiro), [Listar fondeos](/docs/referencia/listar-fondeos) y [Devolver saldo a la tesorería](/docs/referencia/devolver-saldo-de-retiros). - **Webhooks nuevos:** `payout.created`, `payout.completed`, `payout.failed` y `balance.funded`. - **Idempotencia obligatoria** en los retiros: `external_id` o la cabecera `Idempotency-Key`. Además, el contrato no acepta pagar dos veces el mismo retiro. - **Límites en el contrato:** máximo por retiro y límite diario por token. Si tu empresa tiene billetera administradora propia, solo tú puedes cambiarlos. - Los rechazos de retiros traen un campo `code` estable (`insufficient_balance`, `above_daily_limit`…) además de `error`. - [Ver cuenta](/docs/referencia/ver-cuenta) devuelve el campo nuevo `payouts` con la comisión, quién paga el gas y el mínimo por retiro. ## 2026-10-14 — Contratos nuevos: nada queda atrapado Se desplegó una versión nueva de la fábrica y el contrato de retiros, con salidas para el dinero enviado por error: - Tokens enviados directamente a la dirección de la fábrica: la plataforma los puede devolver (la fábrica nunca guarda fondos propios). - Tokens enviados directamente al contrato de retiros, sin pasar por una dirección de fondeo: la plataforma puede rescatar solo el sobrante; el saldo de las empresas no se toca. - ETH enviado por error a una dirección de fondeo: va entero a la tesorería de la empresa. - Probado con pruebas de invariantes (miles de operaciones al azar): la suma de las cajas siempre cuadra con lo que tiene el contrato y todo el dinero puede salir. Ninguna operación recorre listas que crezcan, así que el gas no aumenta con el número de usuarios o retiros. > **Cambian las direcciones en la red de pruebas.** Al ser una fábrica nueva, las direcciones de depósito de Arbitrum Sepolia son otras: vuelve a crear tus clientes de prueba. ## 2026-10-13 — El panel se abre con correo y contraseña Las personas entran al panel con su correo y su contraseña, tanto en las empresas como en la plataforma. La clave de API queda solo para que tu servidor llame a la API: ya no sirve para abrir el panel. - Cada empresa gestiona a su equipo desde **Equipo**: añadir personas, restablecer contraseñas y desactivar accesos. - Un usuario nuevo recibe una contraseña temporal y debe cambiarla al entrar. - Tras 5 intentos fallidos, el acceso de ese correo se bloquea 15 minutos. - La integración por API no cambia: sigue usando la cabecera `x-api-key`. Ver [Autenticación](/docs/guias/autenticacion). ## 2026-10-13 — Nuevas rutas: listados, resumen, varias claves de API y secreto de webhooks Todo lo que muestra el panel web ahora también está en la API de la empresa. Son rutas nuevas; las que ya existían siguen respondiendo igual, con campos añadidos. - **Listado de clientes.** [Listar clientes](/docs/referencia/listar-clientes) con `limit`, `offset`, `search` y un resumen de depósitos por cliente. - **Resumen.** [Ver resumen](/docs/referencia/ver-resumen): totales, depósitos por estado, por token y por día, y estado de los webhooks. - **Varias claves de API.** [Crear](/docs/referencia/crear-clave), [listar](/docs/referencia/listar-claves) y [revocar](/docs/referencia/revocar-clave) claves, cada una con nombre y fecha de último uso. Permite rotar sin cortar el servicio. Ver [Claves de API](/docs/guias/claves-de-api). - **Secreto de webhooks.** Se puede [consultar](/docs/referencia/ver-secreto-de-webhooks) y [rotar](/docs/referencia/rotar-secreto-de-webhooks). - **Detalle de un evento.** [Obtener evento](/docs/referencia/obtener-evento) devuelve el evento con todos sus envíos: intentos, último código de respuesta y último error. - **Filtros en depósitos.** [Listar depósitos](/docs/referencia/listar-depositos) acepta `status`, `token`, `customer_id`, `search` y `offset`, y devuelve `total`. - **Cabecera `x-request-id`.** Todas las respuestas la incluyen. Ver [Ids de petición](/docs/guias/ids-de-peticion). Para la plataforma: detalle de una empresa, clave adicional para una empresa, [resumen global](/docs/referencia/resumen-de-la-plataforma), [depósitos de todas las empresas](/docs/referencia/depositos-de-la-plataforma) y [estado del sistema](/docs/referencia/estado-del-sistema). La plataforma también puede consultar las rutas `/v1/...` en nombre de una empresa con `x-admin-token` y `x-merchant-id`. ## 2026-10-13 — Comisiones sin tope Antes la comisión de la plataforma no podía pasar del 5 % (`500` puntos básicos) ni la de la empresa del 20 % (`2000`). Esos máximos se eliminaron, tanto en la API como en el contrato: ahora cada una acepta de `0` a `10000` (0 a 100 %). - Con base `gross`, si las dos comisiones suman más de `10000`, la API responde `400` con `Las comisiones de la plataforma y de la empresa suman más del 100 % del depósito`. - Si las comisiones y el gas superan un depósito concreto, ese depósito queda como `below_minimum` y no se barre. - En las cotizaciones, cuando las comisiones se llevan todo, `to_cover_all` devuelve un `error` en lugar de un monto. - El tope de gas por barrido (0.50 USD por token) no cambia. > No hace falta cambiar nada en una integración existente. Más detalle en [Comisiones](/docs/guias/comisiones). ## 2026-10-13 — Gas garantizado en las cotizaciones Una cotización pedida con `customer_id` ahora fija el gas máximo de ese cliente durante 15 minutos. La respuesta de [Crear cotización](/docs/referencia/crear-cotizacion) incluye `gas_guaranteed_until`; si el depósito se detecta antes de esa hora, no se le descuenta más gas que el cotizado. Si el gas sube, la diferencia la asume la plataforma. El campo es `null` cuando no se envía `customer_id` o cuando el gas lo paga la plataforma. Ver [Gas](/docs/guias/gas) y [Cotizaciones](/docs/guias/cotizaciones). ## 2026-10-13 — Versión inicial de la API Primera versión de la pasarela, en Arbitrum Sepolia (chain id 421614) con USDT y USDC de prueba. - **Clientes** con dirección de depósito permanente, página de pago y QR: [Crear cliente](/docs/referencia/crear-cliente). - **Depósitos** con detección, confirmación y liquidación automáticas, y el reparto de cada uno: [Flujo de un depósito](/docs/guias/flujo-de-un-deposito). - **Reparto en una sola transacción**: comisión de la plataforma, comisión de la empresa sobre `gross` o `net`, gas según `gas_mode` y el resto a la tesorería. - **Cotizaciones** con `if_deposit_amount`, `to_cover_gas` y `to_cover_all`. - **Webhooks** firmados con HMAC-SHA256 y reintentos durante unos 3 días, con reenvío manual y aviso de prueba: [Webhooks](/docs/guias/webhooks). - **Eventos** en orden para conciliar: [Listar eventos](/docs/referencia/listar-eventos). - **Rutas de la plataforma** para dar de alta empresas, cambiar sus condiciones y comparar el gas cobrado con el real. > Los contratos están probados, pero aún no tienen auditoría externa. Ver [Sin custodia](/docs/guias/sin-custodia).