> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meru.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Resumen

> Recibe eventos de payouts, depósitos, customers y tarjetas en tiempo real mediante webhooks

Los webhooks te permiten recibir notificaciones en tiempo real sobre payouts, depósitos, onboarding/verificación de customers y transacciones de tarjeta. 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:

```json theme={null}
{
  "type": "payout.updated",
  "timestamp": "2026-06-23T12:00:00.000Z",
  "data": { }
}
```

<ResponseField name="type" type="string">El tipo de evento (ver los eventos más abajo).</ResponseField>
<ResponseField name="timestamp" type="string">Marca de tiempo ISO 8601 de cuándo se emitió el evento.</ResponseField>
<ResponseField name="data" type="object">El cuerpo del evento. Su forma depende de `type`.</ResponseField>

## Eventos

| Evento                                                                                            | Descripción                                            |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [`payout.updated`](/es/api-reference/webhooks/payout-updated)                                     | Un payout cambió de estado                             |
| [`payin.updated`](/es/api-reference/webhooks/payin-updated)                                       | Un depósito fiat (riel bancario) cambió de estado      |
| [`crypto_deposit.updated`](/es/api-reference/webhooks/crypto-deposit-updated)                     | Un depósito cripto on-chain cambió de estado           |
| [`customer.status.updated`](/es/api-reference/webhooks/customer-status-updated)                   | Cambió el KYC/KYB/estado del customer                  |
| [`customer.product.request.updated`](/es/api-reference/webhooks/customer-product-request-updated) | Avanzó el onboarding/aprovisionamiento de un producto  |
| [`card.transaction.*`](/es/api-reference/webhooks/card-transaction)                               | Se creó/actualizó/reembolsó una transacción de tarjeta |

### Las cuentas virtuales no tienen eventos de ciclo de vida

**No** existe un evento `virtual_account.created`, `virtual_account.activated` ni `virtual_account.deactivated`. La creación de una cuenta virtual, y su activación o desactivación, no generan un webhook.

La única señal asociada a una cuenta virtual es el depósito en sí: cuando llegan los fondos recibes un [`payin.updated`](/es/api-reference/webhooks/payin-updated). Si necesitas su estado actual, consulta los endpoints de cuentas en la API.

## Migración de nombres de eventos

Estamos estandarizando algunos nombres de eventos. Durante la transición, **se entregan tanto el nombre nuevo como el heredado** para el mismo evento, así no se pierde nada mientras migras. Cada evento incluye un `data.eventId` estable —idéntico entre el nombre nuevo y el heredado—, con el que puedes deduplicar el par.

| Heredado (deprecado)         | Nuevo                       |
| ---------------------------- | --------------------------- |
| `payout.update`              | `payout.updated`            |
| `balance.updated` (fiat)     | `payin.updated`             |
| `balance.updated` (on-chain) | `crypto_deposit.updated`    |
| `card.transaction.refund`    | `card.transaction.refunded` |

Los nombres heredados se eliminarán el **15 de noviembre de 2026**. Apunta tus handlers a los nombres nuevos antes de esa fecha.

## Versionado y deprecación

Los eventos de webhook se versionan **por tipo de evento**, no por un header ni por un campo del payload. Tu endpoint se suscribe a tipos de evento específicos, así que mantener la versión en el nombre es lo que te permite elegir qué versión recibes.

### Qué se considera un cambio incompatible

Agregar un campo opcional nuevo a `data` **no** es incompatible, y lo publicamos sin una versión nueva: se anuncia en el changelog. **Tu handler debe ignorar los campos que no reconozca.**

Los cambios incompatibles son eliminar o renombrar un campo, cambiar su tipo o cambiar el significado de un valor. Nunca los aplicamos sobre un tipo de evento existente: en su lugar publicamos uno nuevo con un sufijo de versión —por ejemplo `payin.updated.v2`— y entregamos ambos en paralelo durante la ventana de deprecación.

### Política de deprecación

Cuando deprecamos un tipo de evento o un campo:

* Lo anunciamos **con al menos 90 días** de anticipación, a través del changelog, un aviso en la página de referencia del evento con la fecha de eliminación, y un correo a los suscriptores del evento afectado.
* El evento deprecado y su reemplazo se entregan **en paralelo durante toda la ventana**, ambos con el mismo `data.eventId` para que puedas deduplicar el par.
* No eliminamos un evento deprecado hasta que sus suscriptores hayan migrado.

## Headers de entrega

Cada solicitud incluye estos headers:

| Header              | Descripción                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                                |
| `webhook-id`        | ID único del mensaje/entrega. Úsalo para idempotencia.                                            |
| `webhook-timestamp` | Marca de tiempo Unix (segundos) de la entrega, usada para verificar la firma.                     |
| `webhook-signature` | Lista 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.

<CodeGroup>
  ```js Node.js theme={null}
  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 });
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib, base64, time

  def verify_signature(raw_body: bytes, headers, secret: str) -> bool:
      msg_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      sig_header = headers["webhook-signature"]  # "v1,<base64> v1,<base64>"

      # Rechaza entregas con más de 5 minutos
      if abs(time.time() - int(timestamp)) > 300:
          return False

      key = base64.b64decode(secret.replace("whsec_", "", 1))
      signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
      expected = base64.b64encode(
          hmac.new(key, signed_content, hashlib.sha256).digest()
      ).decode()

      for part in sig_header.split(" "):
          _, _, sig = part.partition(",")
          if hmac.compare_digest(sig, expected):
              return True
      return False
  ```
</CodeGroup>

## 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` de cada entrega.

## Ejemplo de handler

```js theme={null}
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.updated":
    case "payout.update": // alias heredado, deprecado
      console.log(`Payout ${event.data.payoutId}: ${event.data.previousState} → ${event.data.state}`);
      break;
    case "payin.updated":
    case "crypto_deposit.updated":
    case "balance.updated": // alias heredado, deprecado
      console.log(`Depósito ${event.data.payoutId}: ${event.data.state}`);
      break;
    case "customer.status.updated":
    case "customer.product.request.updated":
      console.log(`Customer ${event.data.customerId}: ${event.type}`);
      break;
    case "card.transaction.created":
    case "card.transaction.completed":
    case "card.transaction.updated":
    case "card.transaction.refunded":
    case "card.transaction.refund": // alias heredado, deprecado
      console.log(`Transacción de tarjeta ${event.data.cardId}: ${event.data.status}`);
      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:

```bash theme={null}
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`.
