# Webhooks

> Recibe un aviso en tu sistema cada vez que un cliente escribe, un mensaje se lee o termina una campaña.

Un webhook es una URL tuya a la que MINDO le manda un `POST` cada vez que pasa algo. Así tu sistema se entera al instante, sin tener que preguntar cada tanto.

```mermaid
sequenceDiagram
    participant C as Cliente
    participant W as WhatsApp
    participant E as MINDO
    participant S as Tu sistema
    C->>W: "Hola, ¿tienen envío a Rosario?"
    W->>E: Mensaje entrante
    E->>S: POST /webhooks/mindo (message.received, firmado)
    S-->>E: 200 OK
```

## Eventos disponibles

| Evento | Cuándo llega |
| --- | --- |
| [`message.received`](/api/webhooks/eventos#message-received) | Un cliente te escribió por WhatsApp o Instagram. |
| [`message.status`](/api/webhooks/eventos#message-status) | Un mensaje tuyo se envió, se entregó, se leyó o falló. |
| [`contact.created`](/api/webhooks/eventos#contact-created) | Se sumó un contacto nuevo. |
| [`contact.opted_out`](/api/webhooks/eventos#contact-opted-out) | Un contacto pidió no recibir más mensajes. |
| [`campaign.completed`](/api/webhooks/eventos#campaign-completed) | Terminó de enviarse una campaña. |
| [`automation.completed`](/api/webhooks/eventos#automation-completed) | Terminó una ejecución de una automatización. |
| [`automation.failed`](/api/webhooks/eventos#automation-failed) | Una ejecución de una automatización falló. |
| [`agent.goal_reached`](/api/webhooks/eventos#agent-goal_reached) | Un agente IA cumplió su objetivo con un cliente. |
| [`agent.handed_off`](/api/webhooks/eventos#agent-handed_off) | Un agente IA pasó una conversación a tu equipo. |
| [`agent.conversation_reviewed`](/api/webhooks/eventos#agent-conversation_reviewed) | Se analizó una conversación de un agente avanzado. |

## Crear un webhook

<Note>
  Igual que las claves, los webhooks están en los planes **Escala** y **Enterprise**, y los configuran el Dueño o los Administradores. Puedes tener hasta 5 URLs en Escala y 25 en Enterprise, y las entregas tienen un cupo mensual. Mira [Límites](/api/limites).
</Note>

<Steps>
  <Step title="Prepara tu URL">
    Tiene que ser pública y empezar con `https://`. Debe responder rápido (en menos de 8 segundos) con un código `2xx`. Más abajo tienes un [ejemplo de servidor](#un-receptor-minimo).
  </Step>
  <Step title="Abre Ajustes › API y webhooks y toca Nuevo webhook">
    Pega tu URL y marca los eventos que quieres recibir. Puedes crear varios webhooks, cada uno con sus eventos (por ejemplo, uno para tu CRM y otro para tu sistema de pedidos).

    <Frame>
      <img src="/images/api/nuevo-webhook.png" alt="Diálogo Nuevo webhook con la URL y los cinco eventos marcados" />
    </Frame>
  </Step>
  <Step title="Guarda el secreto de firma">
    Al crearlo te mostramos el **secreto de firma** (`whsec_…`), **una sola vez**. Lo vas a usar para [verificar](/api/webhooks/firma) que cada aviso viene de MINDO.

    <Frame>
      <img src="/images/api/secreto-webhook.png" alt="Diálogo Secreto de firma mostrado una sola vez" />
    </Frame>
  </Step>
  <Step title="Pruébalo">
    Toca **Probar**. Mandamos un evento `message.received` de prueba (con `"test": true`) y te decimos si tu servidor respondió bien.

    <Frame>
      <img src="/images/api/probar-webhook.png" alt="Aviso Llegó bien (HTTP 200) después de probar el webhook" />
    </Frame>
  </Step>
  <Step title="Revisa las entregas">
    **Entregas** muestra los últimos 50 envíos a ese webhook, con el evento, los intentos y la respuesta de tu servidor. Es lo primero que conviene mirar si algo no llega.

    <Frame>
      <img src="/images/api/entregas.png" alt="Diálogo Últimas entregas con un message.received entregado con 200" />
    </Frame>
  </Step>
</Steps>

Desde la misma lista puedes **Pausar** un webhook (deja de recibir eventos sin perder la configuración), volver a **Activar**lo o eliminarlo.

## Qué te mandamos

Cada aviso es un `POST` con un cuerpo JSON y estos encabezados:

| Encabezado | Ejemplo | Para qué |
| --- | --- | --- |
| `Content-Type` | `application/json` | |
| `User-Agent` | `mindo-webhooks/1` | |
| `X-Mindo-Event` | `message.received` | El tipo de evento. |
| `X-Mindo-Delivery` | `01a0d4a1-3b4c-…` | Id de la entrega. Se repite en los reintentos. |
| `X-Mindo-Signature` | `t=1790271068,v1=5f2b…` | La [firma](/api/webhooks/firma). |

```json Cuerpo
{
  "id": "01a0d4a1-3b4c-7d5e-8f60-7a8b9c0d1e2f",
  "event": "message.received",
  "createdAt": "2026-09-24T17:31:08.412Z",
  "data": {
    "id": "01a0d4a1-3a10-7b21-9c32-4d5e6f7a8b9c",
    "wamid": "wamid.HBgNNTQ5MTE1NTU1MDEwMRUCABIYFjNFQjBDMEY2RTc1QjQ4QjM1QTI0AA==",
    "conversationId": "01a0d456-e5a1-7b2c-8d3e-4f5a6b7c8d9e",
    "phoneNumberId": "109876543210987",
    "contact": { "id": "01a0d456-e419-7c69-a35f-38a00ae65b81", "phone": "5491155550101", "name": "Marta Gómez" },
    "type": "text",
    "text": "Hola, ¿tienen envío a Rosario?",
    "receivedAt": "2026-09-24T17:31:07.000Z"
  }
}
```

- `id`: id de la **entrega** (igual a `X-Mindo-Delivery`). Guárdalo para no procesar dos veces el mismo aviso.
- `event`: el tipo de evento.
- `createdAt`: cuándo pasó.
- `data`: los datos del evento. Mira cada uno en [Eventos](/api/webhooks/eventos).

## Un receptor mínimo

Este servidor verifica la firma, responde `200` enseguida y después procesa el evento.

<CodeGroup>

```javascript Node (Express)
import express from "express"
import crypto from "node:crypto"

const app = express()
const SECRET = process.env.MINDO_WEBHOOK_SECRET

// Importante: el cuerpo crudo, sin parsear, para verificar la firma
app.post("/webhooks/mindo", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8")
  if (!verify(raw, req.get("x-mindo-signature") ?? "", SECRET)) {
    return res.status(401).send("firma inválida")
  }
  res.sendStatus(200) // responde rápido…

  const event = JSON.parse(raw)
  queueMicrotask(() => handle(event)) // …y procesa después
})

function verify(raw, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  if (!parts.t || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex")
  const a = Buffer.from(parts.v1, "hex")
  const b = Buffer.from(expected, "hex")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

function handle(event) {
  if (event.test) return console.log("Evento de prueba recibido 👌")
  console.log(event.event, event.data)
}

app.listen(3000)
```

```python Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["MINDO_WEBHOOK_SECRET"]

def verify(raw: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

@app.post("/webhooks/mindo")
def mindo_webhook():
    raw = request.get_data()  # cuerpo crudo
    if not verify(raw, request.headers.get("X-Mindo-Signature", ""), SECRET):
        abort(401)
    event = json.loads(raw)
    if not event.get("test"):
        print(event["event"], event["data"])
    return "", 200
```

```php PHP
<?php
$secret = getenv("MINDO_WEBHOOK_SECRET");
$raw = file_get_contents("php://input"); // cuerpo crudo
$header = $_SERVER["HTTP_X_MINDO_SIGNATURE"] ?? "";

parse_str(str_replace(",", "&", $header), $parts);
$t = $parts["t"] ?? "";
$v1 = $parts["v1"] ?? "";
$expected = hash_hmac("sha256", "$t.$raw", $secret);

if (!$t || abs(time() - (int) $t) > 300 || !hash_equals($expected, $v1)) {
  http_response_code(401);
  exit("firma inválida");
}

http_response_code(200);
$event = json_decode($raw, true);
if (empty($event["test"])) {
  error_log($event["event"] . " " . json_encode($event["data"]));
}
```

</CodeGroup>

## Buenas prácticas

<CardGroup cols={2}>
  <Card title="Responde en menos de 8 segundos" icon="timer">
    Si tardas más, lo contamos como fallido y reintentamos. Responde `200` primero y procesa en segundo plano.
  </Card>
  <Card title="Verifica siempre la firma" icon="shield-check">
    Tu URL es pública: cualquiera podría mandarle un POST. La [firma](/api/webhooks/firma) prueba que viene de MINDO.
  </Card>
  <Card title="Ignora los duplicados" icon="copy-x">
    Un mismo aviso puede llegar más de una vez. Usa `id` (o `X-Mindo-Delivery`) para detectarlo.
  </Card>
  <Card title="No dependas del orden" icon="shuffle">
    Los eventos pueden llegar desordenados, sobre todo después de un reintento. Usa `createdAt` y los datos del evento.
  </Card>
</CardGroup>

¿Qué pasa si tu servidor está caído? Lo explicamos en [Reintentos](/api/webhooks/reintentos).
