Saltar al contenido principal
Los webhooks te permiten recibir notificaciones en tiempo real cuando un payout o un depósito crypto cambia de estado. Cuando ocurre un evento, enviamos una solicitud POST firmada al endpoint de webhook que configuraste, con el cuerpo del evento.

Configuración del endpoint

Tu endpoint debe ser accesible públicamente por HTTPS y responder con cualquier código de estado 2xx. Configura su URL con tu contacto de Meru o desde el dashboard. Al crearlo recibes un secreto de firma (con prefijo whsec_) — guárdalo de forma segura; lo necesitas para verificar las firmas.

Estructura del evento

Todos los cuerpos de entrega tienen la misma forma de nivel superior:
{
  "type": "payout.update",
  "timestamp": "2026-06-23T12:00:00.000Z",
  "data": { }
}
type
string
El tipo de evento. Uno de payout.update o balance.updated.
timestamp
string
Marca de tiempo ISO 8601 de cuándo se emitió el evento.
data
object
El cuerpo del evento. Su forma depende de type (ver más abajo).

Headers de entrega

Cada solicitud incluye estos headers:
HeaderDescripción
Content-Typeapplication/json
webhook-idID único del mensaje/entrega. Úsalo para idempotencia.
webhook-timestampMarca de tiempo Unix (segundos) de la entrega, usada para verificar la firma.
webhook-signatureLista separada por espacios de firmas v1,<base64> (más de una durante la rotación del secreto).

Verificación de firmas

Cada solicitud se firma con HMAC-SHA256 para que puedas confirmar que viene de nosotros. El contenido firmado es el string {webhook-id}.{webhook-timestamp}.{cuerpoCrudo}, con clave en tu secreto de firma (decodificado de base64 tras quitar el prefijo whsec_), y el resultado se codifica en base64 dentro del header webhook-signature. Para verificar: recalcula la firma sobre el cuerpo crudo de la solicitud (antes de cualquier parseo de JSON) y compárala contra el header en tiempo constante. Rechaza las entregas cuyo webhook-timestamp tenga más de 5 minutos.
import crypto from "crypto";

function verifySignature(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const header = headers["webhook-signature"]; // "v1,<base64> v1,<base64>"

  // Rechaza entregas con más de 5 minutos
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent)
    .digest("base64");

  // El header puede traer varias entradas "v1,<sig>" separadas por espacios
  return header.split(" ").some((part) => {
    const sig = part.split(",")[1];
    return (
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
  });
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  // req.body es el cuerpo CRUDO (Buffer/string), no JSON parseado
  if (!verifySignature(req.body.toString(), req.headers, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: "Invalid signature" });
  }
  const event = JSON.parse(req.body); // { type, timestamp, data }
  handleWebhookEvent(event);
  res.status(200).json({ received: true });
});

Evento: payout.update

Se envía cada vez que un payout cambia de estado. El type del evento es siempre payout.update, sin importar la transición — lee data.state (y data.previousState) para saber qué pasó.

Campos de data

payoutId
string
El identificador del payout.
companyId
string
El identificador de tu empresa.
externalId
string
Tu referencia externa, si se proporcionó.
onBehalfOf
string
El usuario/entidad para quien se hizo el payout.
previousState
string | null
El estado previo a esta transición. Es null en el primer evento (creación).
state
string
El estado actual del payout. Ver la tabla de abajo.
amount
number
El monto del payout, en unidades mínimas (por ejemplo, centavos).
currency
string
La moneda de destino.
destinationType
string
Uno de wallet, bank o qr_code.
errorReason
string
Presente solo cuando el payout falló.
txHash
string
Hash de la transacción on-chain. Presente para destinos wallet y bank.
extraData
object
Metadatos del riel bancario. Presente solo para destinationType = bank.
createdAt
string
Marca de tiempo de creación (ISO 8601).
updatedAt
string
Marca de tiempo de última actualización (ISO 8601).

Estados del payout (data.state)

EstadoSignificado
createdPayout creado
in_reviewEn revisión (pre-partner)
awaiting_fundsEsperando fondos
funds_receivedFondos recibidos
payment_submittedEnviado / en proceso
payment_processedCompletado exitosamente
undeliverableNo entregable
returnedDevuelto
refundedReembolsado
canceledCancelado
errorError / fallido
{
  "type": "payout.update",
  "timestamp": "2026-06-23T12:00:00.000Z",
  "data": {
    "payoutId": "3ddd0e5b-6276-4b48-b756-c1fcc9a2efd1",
    "companyId": "cmp_123",
    "externalId": "ext-001",
    "onBehalfOf": "user-123",
    "previousState": "payment_submitted",
    "state": "payment_processed",
    "amount": 100000,
    "currency": "cop",
    "destinationType": "bank",
    "txHash": "0xabc123",
    "extraData": {
      "finalAmount": "99950",
      "finalAmountCurrency": "cop",
      "uetr": "97ed4827-7b6f-4b1a-9b0e-2a1c3d4e5f60",
      "trackingNumber": "TRK-0001"
    },
    "createdAt": "2026-06-23T11:00:00.000Z",
    "updatedAt": "2026-06-23T12:00:00.000Z"
  }
}

Evento: balance.updated

Se envía cuando un depósito crypto cambia de estado. Su data difiere del de payout.update: lleva detalles del depósito on-chain y no incluye onBehalfOf, destinationType ni extraData.

Campos de data

payoutId
string
El identificador del depósito (se llama payoutId por consistencia de transporte).
companyId
string
El identificador de tu empresa.
externalId
string
Tu referencia externa, si se proporcionó.
previousState
null
Siempre null para eventos de depósito.
state
string
El estado actual del depósito.
amount
number
El monto del depósito, en unidades mínimas.
currency
string
La moneda del depósito (por ejemplo, usdc).
blockchain
string
La blockchain donde se liquidó el depósito.
txHash
string | null
Hash de la transacción on-chain, cuando está disponible.
errorReason
string | null
Motivo de la falla, cuando aplica.
updatedAt
string
Marca de tiempo de última actualización (ISO 8601).
{
  "type": "balance.updated",
  "timestamp": "2026-06-23T12:00:00.000Z",
  "data": {
    "payoutId": "dep_9f86d081884c7d65",
    "companyId": "cmp_123",
    "externalId": "ext-009",
    "previousState": null,
    "state": "funds_received",
    "amount": 100000,
    "currency": "usdc",
    "blockchain": "ethereum",
    "txHash": "0xdef456",
    "errorReason": null,
    "updatedAt": "2026-06-23T12:00:00.000Z"
  }
}

Idempotencia

El mismo evento puede entregarse más de una vez. Usa el header webhook-id como clave de idempotencia: guarda los IDs procesados y descarta los duplicados.

Reintentos

Si tu endpoint no devuelve un estado 2xx (o agota el tiempo de espera), reintentamos la entrega automáticamente con backoff exponencial a lo largo de varias horas, respetando Retry-After en respuestas de error. Confirma rápido (en pocos segundos) y procesa el trabajo pesado de forma asíncrona. Los endpoints que fallan de forma persistente pueden quedar deshabilitados.

Buenas prácticas

  • Verifica siempre la firma contra el cuerpo crudo antes de procesar.
  • Responde rápido con un 2xx y procesa de forma asíncrona.
  • Sé idempotente usando webhook-id.
  • No asumas orden — apóyate en state/previousState y updatedAt, no en el orden de entrega.
  • Registra el webhook-id, el type y el data.state de cada entrega.

Ejemplo de handler

import express from "express";
import crypto from "crypto";

const app = express();

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString();
  if (!verifySignature(raw, req.headers, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(raw);
  switch (event.type) {
    case "payout.update":
      console.log(
        `Payout ${event.data.payoutId}: ${event.data.previousState}${event.data.state}`,
      );
      break;
    case "balance.updated":
      console.log(`Depósito ${event.data.payoutId}: ${event.data.state}`);
      break;
    default:
      console.warn(`Tipo de evento no manejado: ${event.type}`);
  }

  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log("Servidor de webhooks escuchando en el puerto 3000"));

Pruebas

Expón tu servidor local con un túnel (por ejemplo, ngrok) y registra la URL pública como tu endpoint de webhook:
ngrok http 3000

Solución de problemas

  • Firma inválida: verifica contra el cuerpo crudo (no un JSON re-serializado), usa el secreto whsec_ correcto y lee los headers webhook-id/webhook-timestamp/webhook-signature tal cual.
  • Eventos duplicados: deduplica usando webhook-id.
  • Eventos perdidos: asegúrate de que tu endpoint devuelva 2xx rápido; las respuestas non-2xx y los timeouts se reintentan, pero las fallas persistentes pueden deshabilitar la entrega.
  • Errores de SSL: tu endpoint necesita un certificado TLS válido.
Para ayuda con una entrega específica, contacta a soporte con el webhook-id.