# Webhooks

From the Lero API docs: https://lero.studio/docs/en#webhooks. Everything in one file: https://lero.studio/docs/en/llms-full.txt.

Webhooks are Lero calling **your** server when something happens, instead of you polling. Example: a Conversational Agent call ended, and your system gets the transcript, the duration and the end reason right away, to save in the CRM, score an interview or open a ticket.

1. At [app.lero.studio/desenvolvedores](https://app.lero.studio/desenvolvedores?aba=webhooks), Webhooks tab, add your server's `https` URL and choose the events (up to 5 webhooks per account).
2. Copy the webhook secret (`whsec_...`) to your server.
3. Click **Enviar teste** (send test): the `webhook.teste` event arrives.
4. On your server, verify the signature and answer `2xx` within 10 s.

| event | when |
|---|---|
| `agente.conversa.encerrada` | A Conversational Agent call of your account ended. Carries version, LLM, voice, start, end, duration, end reason and the transcript. |
| `webhook.teste` | You clicked Enviar teste. |

Speech-to-Text answers in the same call, with no webhook. Voice Clone events arrive when that product launches.

Each delivery is a `POST` with `Content-Type: application/json` and the headers `Lero-Evento`, `Lero-Evento-Id` and `Lero-Assinatura: t=<unix>,v1=<hex>`. `v1` is the HMAC-SHA256 of `"<t>.<raw body>"` with the webhook secret. Reject when the signature does not match or when `t` is older than 5 minutes.

```json
{
  "id": "6f1c2a9e-3b7d-4e58-9a0c-2d4f6b8e1a37",
  "evento": "agente.conversa.encerrada",
  "criado": "2026-11-11T15:42:10.512Z",
  "dados": {
    "conversa_id": "2eecec700b63",
    "versao": "v0.4",
    "llm": "oss120b",
    "voz": "rio",
    "lingua": "pt",
    "inicio": "2026-11-11T15:31:07Z",
    "fim": "2026-11-11T15:42:09Z",
    "duracao_s": 662.4,
    "motivo": "a pessoa desligou",
    "transcript": [
      { "papel": "agente", "texto": "Oi! Aqui é a Bia, da Acme.", "t": 1.2 },
      { "papel": "pessoa", "texto": "Oi, tudo bem?", "t": 4.8 }
    ]
  }
}
```

Verify the signature (Node):

```javascript
import crypto from "node:crypto";

// Next.js route: POST /api/lero-webhook. LERO_WEBHOOK_SECRET = o segredo do webhook (whsec_...)
export async function POST(req) {
  const corpo = await req.text();                                  // o corpo cru, antes do JSON.parse
  const [t, v1] = (req.headers.get("lero-assinatura") || "").split(",").map((p) => p.split("=")[1]);
  const esperado = crypto.createHmac("sha256", process.env.LERO_WEBHOOK_SECRET).update(`${t}.${corpo}`).digest("hex");
  const ok = v1 && v1.length === esperado.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado));
  if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response("assinatura inválida", { status: 401 });

  const evento = JSON.parse(corpo);
  if (evento.evento === "agente.conversa.encerrada") {
    // salve evento.dados.transcript, avalie a entrevista, abra o ticket...
  }
  return new Response("ok");                                       // responda 2xx rápido; o trabalho pesado vai para uma fila
}
```

Verify the signature (Python):

```python
import hashlib, hmac, json, os, time

from flask import Flask, request

app = Flask(__name__)


@app.post("/lero-webhook")
def lero_webhook():
    corpo = request.get_data(as_text=True)                         # o corpo cru
    partes = dict(p.split("=", 1) for p in request.headers.get("Lero-Assinatura", "").split(",") if "=" in p)
    esperado = hmac.new(os.environ["LERO_WEBHOOK_SECRET"].encode(), f"{partes.get('t')}.{corpo}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(partes.get("v1", ""), esperado) or abs(time.time() - int(partes.get("t", 0))) > 300:
        return "assinatura inválida", 401
    evento = json.loads(corpo)
    if evento["evento"] == "agente.conversa.encerrada":
        pass  # salve evento["dados"]["transcript"]...
    return "ok"

```

- Without a `2xx`, Lero retries after 2 s and 8 s (3 attempts). The event `id` is the same across attempts: use it to avoid processing twice.
- Redirects are not followed: use the final URL.
- The latest deliveries, with status and time, show in the Webhooks tab.
