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

# Webhooks (Salientes)

> Recibe eventos del SalesOS en tiempo real en tu endpoint HTTPS — envelope, verificación de firma, catálogo de eventos, reintentos e idempotencia.

# Webhooks (Salientes)

Los webhooks permiten que SalesOS **llame a tu sistema** cuando algo ocurre (un lead es ofertado, un
ranking se publica, un mensaje se despacha). Nos das una URL HTTPS y un secreto; hacemos `POST` de un
payload JSON firmado cada vez que dispara un evento suscrito.

<Note>
  Esta página cubre webhooks **salientes** (SalesOS → tu endpoint). Para enviar datos **hacia** SalesOS,
  consulta [Actividades](/es/api/integrations/activities) y [API Keys](/es/api/integrations/api-keys).
</Note>

## Configurar un webhook en el Dashboard

Ve a **Integraciones → Webhooks → Nuevo Webhook** y completa el formulario.

<Steps>
  <Step title="Básico">
    * **Nombre** (obligatorio) — ej.: `Notificar CRM`.
    * **Clave** — un identificador estable (ej.: `notify_crm`).
    * **Descripción** — opcional.
  </Step>

  <Step title="Eventos (disparadores)">
    Elige una **Categoría** y selecciona los **eventos** que disparan este webhook — esta es tu
    suscripción (ver el [catálogo](#catálogo-de-eventos)). Déjalo vacío para un webhook que solo
    disparas manualmente (botón *Probar*) o desde un workflow.
  </Step>

  <Step title="Destino">
    * **Método** — `POST` (por defecto), `PUT` o `PATCH`.
    * **Timeout (segundos)** — cuánto esperamos tu `2xx` (por defecto 30).
    * **URL** (obligatorio) — tu endpoint. **Debe ser `https://`** y público (hosts internos/loopback están bloqueados).
    * **Autenticación** — elige **HMAC** para firmar cada entrega y, en **Configuración de Autenticación (JSON)**, indica tu secreto:
      ```json theme={null}
      { "secret": "whsec_tu_secreto" }
      ```
      (Otros esquemas disponibles: Bearer, API Key, Basic, OAuth2.)
  </Step>

  <Step title="Avanzado">
    * **Headers Personalizados (JSON)** — headers extra enviados en cada entrega.
    * **Plantilla del Payload (JSON con variables `{{ }}`)** — modela el `data` del envelope a partir
      del contexto del evento, ej.: `{ "id": "{{event.id}}", "type": "{{event.type}}", "to": "{{event.to}}" }`.

    <Warning>
      Los valores de la plantilla se interpolan en JSON. Usa campos **planos y escalares**. Inyectar un objeto
      anidado vía `"{{event.data}}"` puede no renderizar limpio hoy — revisa siempre el **Preview** / envía una **Prueba** antes.
    </Warning>
  </Step>

  <Step title="Preview, probar y guardar">
    El formulario muestra un **Preview** en vivo (headers + cuerpo). Haz clic en **Guardar webhook** y usa
    **Probar Webhook** para enviar una entrega de ejemplo y confirmar que tu endpoint recibe — y verifica — el payload.
  </Step>
</Steps>

## El envelope

En la ruta de evento, cada entrega es un único objeto JSON:

```json theme={null}
{
  "id": "evt_2KWPBgLlAfxdpx2AI54pPJ85f4W",
  "type": "ranking.weekly.published",
  "version": "1",
  "ts": "2026-06-03T12:00:00.000Z",
  "tenant": "9b1f0c2e-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
  "data": { /* específico del evento — ver catálogo */ }
}
```

| Campo     | Descripción                                                                   |
| --------- | ----------------------------------------------------------------------------- |
| `id`      | Id único del evento, **estable entre reintentos** → tu clave de idempotencia. |
| `type`    | Tipo de evento (ver catálogo).                                                |
| `version` | Versión del schema de `data`. Evoluciona de forma aditiva.                    |
| `ts`      | Cuándo ocurrió el evento (ISO 8601, UTC).                                     |
| `tenant`  | Tenant de origen (uuid).                                                      |
| `data`    | Payload específico del evento.                                                |

## Headers

| Header                  | Descripción                                              |
| ----------------------- | -------------------------------------------------------- |
| `X-SalesOS-Event`       | Tipo de evento — enruta sin leer el cuerpo.              |
| `X-SalesOS-Event-Id`    | Igual a `id`; estable entre reintentos (idempotencia).   |
| `X-SalesOS-Delivery-Id` | Id del intento (cambia en cada reintento; para depurar). |
| `X-SalesOS-Timestamp`   | Unix en segundos — parte de la firma (anti-replay).      |
| `X-SalesOS-Tenant`      | Id del tenant de origen.                                 |
| `X-SalesOS-Signature`   | `sha256=<hmac>` — ver [Verificar](#verificar-la-firma).  |

## Verificar la firma

<Warning>
  Verifica siempre la firma antes de confiar en una entrega. Sin ella, cualquiera que descubra tu URL
  podría falsificar eventos.
</Warning>

En la ruta de evento, la firma cubre **`{id}.{timestamp}.{rawBody}`** (estilo Standard Webhooks), por lo
que autentica el payload **y** el timestamp (anti-replay). Recalcula el HMAC con tu secreto, compara en
tiempo constante y rechaza si el timestamp está a más de **300s**.

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from "node:crypto";

  // raw = el cuerpo crudo exacto de la petición (NO hagas JSON.parse antes)
  export function verify(req, secret) {
    const id  = req.headers["x-salesos-event-id"];
    const ts  = req.headers["x-salesos-timestamp"];
    const sig = req.headers["x-salesos-signature"]; // "sha256=<hex>"
    const raw = req.rawBody;

    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // ventana de replay

    const expected =
      "sha256=" + crypto.createHmac("sha256", secret).update(`${id}.${ts}.${raw}`).digest("hex");

    const a = Buffer.from(sig), b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

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

  def verify(headers, raw_body: bytes, secret: str) -> bool:
      event_id = headers["X-SalesOS-Event-Id"]
      ts       = headers["X-SalesOS-Timestamp"]
      sig      = headers["X-SalesOS-Signature"]  # "sha256=<hex>"

      if abs(time.time() - int(ts)) > 300:        # ventana de replay
          return False

      msg = f"{event_id}.{ts}.{raw_body.decode()}".encode()
      expected = "sha256=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
      return hmac.compare_digest(sig, expected)
  ```
</CodeGroup>

<Note>
  **Entregas de prueba** (el botón "Probar Webhook") y los disparadores legados de workflow usan hoy un
  esquema más simple: el cuerpo es la plantilla renderizada cruda (no el envelope) y el header de firma es
  `X-Signature: <prefijo><hmac(cuerpo)>` — **sin** el prefijo `id.timestamp.`, por lo que no tiene
  anti-replay. Prefiere la ruta de evento de arriba en producción.
</Note>

## Catálogo de eventos

| `type`                           | Cuándo dispara                         | `data`                        |
| -------------------------------- | -------------------------------------- | ----------------------------- |
| `lead.offer_created`             | Un lead es ofertado a un corredor      | payload del lead/oportunidad  |
| `lead.accepted`                  | Un corredor acepta un lead             | payload del lead/oportunidad  |
| `lead.offer_expired`             | Una oferta expira sin aceptación       | payload del lead/oportunidad  |
| `mission.completed`              | Una misión se completa                 | payload de la misión          |
| `ranking.weekly.published`       | Ranking semanal publicado (programado) | leaderboard (filas por email) |
| `ranking.consolidated.published` | Ranking consolidado publicado          | leaderboard (filas por email) |
| `message.dispatch`               | Mensaje despachado a un usuario/grupo  | `{ to, m }`                   |
| `notification.push.dispatch`     | Push despachado a un usuario/grupo     | `{ to, n }`                   |

Ejemplo de `data` para `ranking.weekly.published`:

```json theme={null}
{
  "p": { "type": "weekly", "label": "Semana 23/2026", "start": "2026-05-25", "end": "2026-05-31" },
  "org_unit": null,
  "totals": { "users": 843, "points": 1820400, "missions": 5120 },
  "rows": [
    { "rank": 1, "delta": 2, "email": "maria@loja.com", "points": 4820, "missions": 37, "org_unit": "ou_12" }
  ]
}
```

Los destinatarios se identifican por **email** — nombres e ids no se envían; enriquece de tu lado si lo necesitas.

## Confiabilidad

**Responde rápido.** Devuelve un estado `2xx` rápidamente (antes de procesamiento pesado) para confirmar la recepción.

| Tu respuesta                        | Qué hace SalesOS                                                                                |
| ----------------------------------- | ----------------------------------------------------------------------------------------------- |
| `2xx`                               | Marca la entrega como completada. Sin reintento.                                                |
| `4xx` (400, 401, 422…)              | Tratado como rechazo permanente. **Sin reintento** (excepto `429`, que respeta el backoff).     |
| `5xx` / timeout / error de conexión | Reentrega con **backoff exponencial** (`2^n` minutos, hasta 5 intentos), luego **dead-letter**. |

**Idempotencia.** El mismo evento puede entregarse más de una vez (reintentos). Deduplica por
`X-SalesOS-Event-Id` — se mantiene igual entre reintentos.

<Tip>
  Las entregas fallidas aparecen en el panel **Entregas** (Dashboard → Integraciones → Webhooks), donde
  puedes inspeccionar estado, intentos y el último error, y reenviar desde la cola de dead-letter.
</Tip>
