Primeros pasos
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.
1POST /webhooks/pagos HTTP/1.12content-type: application/json3user-agent: Pasarela-Webhooks/1.04x-pasarela-signature: t=1791903853,v1=5f2b9c…e41a5x-pasarela-event-id: 87f9bb94-70af-4879-a8e0-a2c03e974a8e6x-pasarela-event-type: deposit.confirmed7 8{"id":"87f9bb94-70af-4879-a8e0-a2c03e974a8e","type":"deposit.confirmed","created_at":"2026-10-13T15:04:13.289Z","data":{"deposit":{…}}}| Campo del cuerpo | Qué es |
|---|---|
Campo del cuerpoid | Qué esId del evento. Es estable: el mismo en reintentos y reenvíos. Úsalo para no procesar dos veces. |
Campo del cuerpotype | Qué esTipo de evento, por ejemplo deposit.confirmed. |
Campo del cuerpocreated_at | Qué esCuándo ocurrió el evento (no cuándo se envió). |
Campo del cuerpodata.deposit | Qué esEn eventos deposit.*: el depósito tal como estaba al ocurrir el evento. Ver Objeto depósito. |
Campo del cuerpodata.payout | Qué esEn eventos payout.*: el retiro. Ver Objeto retiro. |
Campo del cuerpodata.funding | Qué esEn 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
2xxen menos de 10 segundos. - 2
Registra la URL
Envía
webhook_urlcon Actualizar cuenta. En producción debe serhttps://. Connullse dejan de enviar avisos. - 3
Guarda el secreto
Se entrega al crear la empresa y se puede consultar con Ver secreto de webhooks. Empieza por
whsec_. - 4
Prueba
Con Enviar webhook de prueba llega un evento
webhook.testfirmado igual que los reales. Revisa el resultado con 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.
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
deady no se reintenta más. Se puede reenviar a mano con Reenviar evento. - Cada reintento lleva una firma nueva (con la hora del intento) y el mismo
idde evento. - El orden de llegada no está garantizado: un
deposit.settledpuede llegar antes que undeposit.confirmedque se está reintentando. Guíate pordata.deposit.statusy por elseqde 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.
Eventos#
| Evento | Cuándo ocurre | Qué hacer |
|---|---|---|
Eventodeposit.detected | Cuándo ocurreLa transferencia se vio en la red, aún sin los bloques de espera. | Qué hacerMostrar "pago recibido, confirmando". No acreditar. |
Eventodeposit.confirmed | Cuándo ocurrePasaron los bloques de espera y el monto llega al mínimo. | Qué hacerAcreditar aquí. |
Eventodeposit.below_minimum | Cuándo ocurreEl depósito se confirmó, pero es menor que el mínimo o no alcanza para comisiones y gas. | Qué hacerNo acreditar. Avisar al usuario. |
Eventodeposit.settled | Cuándo ocurreEl dinero ya está en tu tesorería. El reparto es definitivo. | Qué hacerConciliar con los montos finales. |
Eventodeposit.reverted | Cuándo ocurreLa transferencia desapareció de la red antes de confirmarse. | Qué hacerQuitar el aviso de "pago recibido". |
Eventopayout.created | Cuándo ocurreSe aceptó un retiro y su saldo quedó reservado. | Qué hacerMostrar "procesando". |
Eventopayout.completed | Cuándo ocurreEl usuario ya recibió el retiro. Es definitivo. | Qué hacerMarcar pagado y guardar tx_hash. |
Eventopayout.failed | Cuándo ocurreEl retiro no se pagó; no se movió dinero. | Qué hacerDevolver las fichas al usuario. |
Eventobalance.funded | Cuándo ocurreLlegó un fondeo a tu caja de retiros. | Qué hacerNada obligatorio: actualizar el saldo que muestres. |
Eventowebhook.test | Cuándo ocurreLo pediste con la ruta de prueba. | Qué hacerNada: 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.
1{2 "id": "021f882c-8cbe-4d2f-b847-0cbd4f3238e9",3 "type": "deposit.detected",4 "created_at": "2026-10-13T15:04:12.312Z",5 "data": {6 "deposit": {7 "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626",8 "status": "detected",9 "customer": {10 "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086",11 "external_id": "TARJETA-000123"12 },13 "address": "0xeb1322f1c80462602a728ca005f1026709a738ba",14 "chain_id": 421614,15 "token": "USDT",16 "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183",17 "decimals": 6,18 "amount": "100",19 "amount_units": "100000000",20 "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6",21 "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d",22 "log_index": 20,23 "block_number": 318874750,24 "breakdown": null,25 "sweep_tx_hash": null,26 "detected_at": "2026-10-13T15:04:12.310Z",27 "confirmed_at": null,28 "settled_at": null29 }30 }31}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.
1{2 "id": "87f9bb94-70af-4879-a8e0-a2c03e974a8e",3 "type": "deposit.confirmed",4 "created_at": "2026-10-13T15:04:13.289Z",5 "data": {6 "deposit": {7 "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626",8 "status": "confirmed",9 "customer": {10 "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086",11 "external_id": "TARJETA-000123"12 },13 "address": "0xeb1322f1c80462602a728ca005f1026709a738ba",14 "chain_id": 421614,15 "token": "USDT",16 "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183",17 "decimals": 6,18 "amount": "100",19 "amount_units": "100000000",20 "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6",21 "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d",22 "log_index": 20,23 "block_number": 318874750,24 "breakdown": {25 "final": false,26 "gas_paid_by": "merchant",27 "platform_fee": "1",28 "merchant_fee": "2",29 "gas_fee": "0.008923",30 "net": "96.991077"31 },32 "sweep_tx_hash": null,33 "detected_at": "2026-10-13T15:04:12.310Z",34 "confirmed_at": "2026-10-13T15:04:13.287Z",35 "settled_at": null36 }37 }38}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.
1{2 "id": "efc2eed0-3d0b-4c82-b37a-61a4f0500dcc",3 "type": "deposit.settled",4 "created_at": "2026-10-13T15:04:23.407Z",5 "data": {6 "deposit": {7 "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626",8 "status": "settled",9 "customer": {10 "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086",11 "external_id": "TARJETA-000123"12 },13 "address": "0xeb1322f1c80462602a728ca005f1026709a738ba",14 "chain_id": 421614,15 "token": "USDT",16 "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183",17 "decimals": 6,18 "amount": "100",19 "amount_units": "100000000",20 "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6",21 "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d",22 "log_index": 20,23 "block_number": 318874750,24 "breakdown": {25 "final": true,26 "gas_paid_by": "merchant",27 "platform_fee": "1",28 "merchant_fee": "2",29 "gas_fee": "0.00885",30 "net": "96.99115"31 },32 "sweep_tx_hash": "0x31f0595da9da4a477935ef7e7f1a62598f62bb5f7fd858f9e031fe88f65b7bfb",33 "detected_at": "2026-10-13T15:04:12.310Z",34 "confirmed_at": "2026-10-13T15:04:13.287Z",35 "settled_at": "2026-10-13T15:04:23.402Z"36 }37 }38}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.
1{2 "id": "eb66c429-16e5-4fa1-831c-cb2943ac756c",3 "type": "deposit.below_minimum",4 "created_at": "2026-10-13T15:04:13.289Z",5 "data": {6 "deposit": {7 "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626",8 "status": "below_minimum",9 "customer": {10 "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086",11 "external_id": "TARJETA-000123"12 },13 "address": "0xeb1322f1c80462602a728ca005f1026709a738ba",14 "chain_id": 421614,15 "token": "USDT",16 "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183",17 "decimals": 6,18 "amount": "0.1",19 "amount_units": "100000",20 "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6",21 "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d",22 "log_index": 20,23 "block_number": 318874750,24 "breakdown": {25 "final": false,26 "gas_paid_by": "merchant",27 "platform_fee": "0.001",28 "merchant_fee": "0.002",29 "gas_fee": "0.008923",30 "net": "0.088077"31 },32 "sweep_tx_hash": null,33 "detected_at": "2026-10-13T15:04:12.310Z",34 "confirmed_at": "2026-10-13T15:04:13.287Z",35 "settled_at": null36 }37 }38}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.
1{2 "id": "06998d7a-342c-4743-bfe7-c559b64cf0c2",3 "type": "deposit.reverted",4 "created_at": "2026-10-13T15:04:13.289Z",5 "data": {6 "deposit": {7 "id": "ba7524c6-79b7-438a-8ecd-76ce3093a626",8 "status": "reverted",9 "customer": {10 "id": "3d8ed2f2-72d4-4c27-b606-a53b5a4db086",11 "external_id": "TARJETA-000123"12 },13 "address": "0xeb1322f1c80462602a728ca005f1026709a738ba",14 "chain_id": 421614,15 "token": "USDT",16 "token_address": "0xdc190461c20de36ffb305b0190985b4023d7d183",17 "decimals": 6,18 "amount": "100",19 "amount_units": "100000000",20 "from": "0xd116a420bb016e712c0d979718821c5fa16c13f6",21 "tx_hash": "0xece621149543e194b19897b0d8947023a7c925e12db2b9ba33c59b940c58c14d",22 "log_index": 20,23 "block_number": 318874750,24 "breakdown": null,25 "sweep_tx_hash": null,26 "detected_at": "2026-10-13T15:04:12.310Z",27 "confirmed_at": null,28 "settled_at": null29 }30 }31}payout.created, payout.completed y payout.failed#
Avisos de Retiros. data.payout es el retiro tal como estaba en ese momento (ver Objeto retiro). En payout.failed, data.payout.error trae code y message.
1{2 "id": "c4a1e7d2-5b3f-4e8a-9c0d-1f2e3a4b5c6d",3 "type": "payout.created",4 "created_at": "2026-10-14T13:20:05.120Z",5 "data": {6 "payout": {7 "id": "6b1f0c3e-1d7a-4a39-9a7e-2f5c8d0b4e11",8 "external_id": "retiro-000981",9 "customer_ref": "jugador-77",10 "status": "pending",11 "chain_id": 421614,12 "token": "USDT",13 "token_address": "0xdc190461C20DE36fFB305b0190985B4023D7D183",14 "decimals": 6,15 "to": "0xF389A660Fa404c39E8f346C46E569fF41b8d3d68",16 "requested": "500",17 "amount": "500",18 "platform_fee": "5",19 "gas_fee": "0.008419",20 "debit": "505.008419",21 "gas_paid_by": "merchant",22 "tx_hash": null,23 "error": null,24 "metadata": {25 "fichas": 50026 },27 "created_at": "2026-10-14T13:20:05.114Z",28 "sent_at": null,29 "completed_at": null,30 "failed_at": null31 }32 }33}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.
1{2 "id": "f7d4b0a5-8e6c-4b1d-8f3a-4c5d6e7f8091",3 "type": "balance.funded",4 "created_at": "2026-10-14T13:15:42.880Z",5 "data": {6 "funding": {7 "token": "USDT",8 "amount": "1000",9 "balance": "1000",10 "tx_hash": "0x3e8b1d6f0a2c4e7b9d1f3a5c7e9b0d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b"11 }12 }13}webhook.test#
1{2 "id": "a136ae86-5793-4df5-bebe-d8a8e91043f1",3 "type": "webhook.test",4 "created_at": "2026-10-13T15:10:02.118Z",5 "data": {6 "message": "Aviso de prueba"7 }8}Verificar la firma#
Cada aviso lleva la cabecera X-Pasarela-Signature con la forma t=<unix>,v1=<hex>. t es la hora del envío en segundos y v1 es el HMAC-SHA256, en hexadecimal, del texto <t>.<cuerpo> 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
tdifiere 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 conv1usando una comparación de tiempo constante. - 5
Responde
2xxsi lo aceptaste (aunque sea un repetido). Si la firma no coincide, responde400y no proceses nada.
1import express from 'express';2import { createHmac, timingSafeEqual } from 'node:crypto';3 4const SECRETO = process.env.WEBHOOK_SECRET; // whsec_…5const app = express();6 7function firmaValida(cabecera, cuerpoCrudo) {8 const partes = Object.fromEntries(String(cabecera).split(',').map((p) => p.split('=')));9 const t = Number(partes.t);10 if (!t || !partes.v1) return false;11 if (Math.abs(Date.now() / 1000 - t) > 300) return false; // más de 5 minutos: se rechaza12 const esperado = createHmac('sha256', SECRETO).update(t + '.' + cuerpoCrudo).digest();13 const recibido = Buffer.from(partes.v1, 'hex');14 return esperado.length === recibido.length && timingSafeEqual(esperado, recibido);15}16 17// express.raw: el cuerpo llega sin tocar, que es sobre lo que se calcula la firma18app.post('/webhooks/pagos', express.raw({ type: 'application/json' }), async (req, res) => {19 const crudo = req.body.toString('utf8');20 if (!firmaValida(req.get('x-pasarela-signature'), crudo)) return res.status(400).send('firma inválida');21 22 const evento = JSON.parse(crudo);23 if (await yaProcesado(evento.id)) return res.sendStatus(200); // repetido: no acreditar otra vez24 25 if (evento.type === 'deposit.confirmed') {26 const d = evento.data.deposit;27 await acreditar(d.customer.external_id, d.amount, d.token, evento.id);28 }29 res.sendStatus(200);30});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.detectedes informativo ydeposit.settledllega después, cuando el dinero ya se movió. - No acredites dos veces. Guarda el
iddel evento y eliddel depósito con restricción de unicidad. Ver 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.
amountes un decimal en texto; no lo conviertas a coma flotante. - Concilia. Una vez al día, o tras una caída, recorre Listar eventos desde tu último
seqpara recuperar lo que se haya perdido. - Vigila los envíos muertos.
webhooks.deaden Ver resumen debería ser 0.