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.

Lo que recibe tu servidor
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 cuerpoQué es
idId del evento. Es estable: el mismo en reintentos y reenvíos. Úsalo para no procesar dos veces.
typeTipo de evento, por ejemplo deposit.confirmed.
created_atCuándo ocurrió el evento (no cuándo se envió).
data.depositEn eventos deposit.*: el depósito tal como estaba al ocurrir el evento. Ver Objeto depósito.
data.payoutEn eventos payout.*: el retiro. Ver Objeto retiro.
data.fundingEn balance.funded: token, monto acreditado, saldo de la caja y transacción.

Configurar#

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

    Registra la URL

    Envía webhook_url con Actualizar cuenta. En producción debe ser https://. Con null se dejan de enviar avisos.

  3. 3

    Guarda el secreto

    Se entrega al crear la empresa y se puede consultar con Ver secreto de webhooks. Empieza por whsec_.

  4. 4

    Prueba

    Con Enviar webhook de prueba llega un evento webhook.test firmado 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.

IntentoEspera desde el fallo anteriorTiempo acumulado
1Inmediato0
210 s10 s
330 s40 s
42 min2 min 40 s
510 min12 min 40 s
630 min42 min 40 s
71 h1 h 43 min
82 h3 h 43 min
94 h7 h 43 min
108 h15 h 43 min
1112 h27 h 43 min
1224 h51 h 43 min
1324 h75 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.
  • 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, 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#

EventoCuándo ocurreQué hacer
deposit.detectedLa transferencia se vio en la red, aún sin los bloques de espera.Mostrar "pago recibido, confirmando". No acreditar.
deposit.confirmedPasaron los bloques de espera y el monto llega al mínimo.Acreditar aquí.
deposit.below_minimumEl depósito se confirmó, pero es menor que el mínimo o no alcanza para comisiones y gas.No acreditar. Avisar al usuario.
deposit.settledEl dinero ya está en tu tesorería. El reparto es definitivo.Conciliar con los montos finales.
deposit.revertedLa transferencia desapareció de la red antes de confirmarse.Quitar el aviso de "pago recibido".
payout.createdSe aceptó un retiro y su saldo quedó reservado.Mostrar "procesando".
payout.completedEl usuario ya recibió el retiro. Es definitivo.Marcar pagado y guardar tx_hash.
payout.failedEl retiro no se pagó; no se movió dinero.Devolver las fichas al usuario.
balance.fundedLlegó un fondeo a tu caja de retiros.Nada obligatorio: actualizar el saldo que muestres.
webhook.testLo 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.

deposit.detected
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.

deposit.confirmed
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.

deposit.settled
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.

deposit.below_minimum
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.

deposit.reverted
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.

balance.funded
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#

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

    Separa t y v1

    Parte la cabecera por la coma y cada trozo por el signo igual.

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

    Responde

    2xx si lo aceptaste (aunque sea un repetido). Si la firma no coincide, responde 400 y 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.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.
  • 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 desde tu último seq para recuperar lo que se haya perdido.
  • Vigila los envíos muertos. webhooks.dead en Ver resumen debería ser 0.