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

# Webhooks

> Recibe eventos de woku en tu sistema en tiempo real, con firma HMAC para verificar la autenticidad y reintentos automáticos

Los **webhooks** te permiten recibir notificaciones HTTP en tu propio
servidor cada vez que ocurre un evento relevante en woku: una nueva
reseña **woku**, un **NPS**, un **CSAT**, un **CES**, la respuesta de un
**formulario** o un **ticket de soporte generado por IA**. woku hace un
`POST` con un cuerpo JSON a la URL que configures.

## Configuración

Los webhooks se administran desde la aplicación administrativa, no por
API pública.

<Steps>
  <Step title="Abre la configuración de integraciones">
    En el panel, entra a **Empresa → Integraciones → Webhooks**.
  </Step>

  <Step title="Crea un webhook">
    Pulsa **Nuevo webhook** e ingresa la **URL** de tu endpoint, los
    **eventos** a los que te suscribes y, opcionalmente, headers
    personalizados y la política de reintentos.
  </Step>

  <Step title="Guarda el secret">
    Al crear el webhook, woku muestra el **secret** una sola vez. Cópialo y
    guárdalo de forma segura: lo necesitarás para verificar la firma de
    cada evento.
  </Step>
</Steps>

<Warning>
  El secret se muestra **una única vez** al crear o rotar el webhook. woku
  lo guarda cifrado y no puede volver a mostrártelo. Si lo pierdes, rótalo
  desde el panel para generar uno nuevo.
</Warning>

## Eventos disponibles

woku emite estos eventos:

| Evento                    | Cuándo se emite                                                                                                                                |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `qualification.created`   | Se registró una nueva reseña woku (calificación 1-5).                                                                                          |
| `nps_submission.created`  | Se registró una nueva respuesta NPS (puntaje 0-10).                                                                                            |
| `csat_submission.created` | Se registró una nueva respuesta CSAT (1-5).                                                                                                    |
| `ces_submission.created`  | Se registró una nueva respuesta CES (1-5).                                                                                                     |
| `form_submission.created` | Se completó una respuesta de formulario.                                                                                                       |
| `ticket.created`          | La IA de woku creó un ticket de soporte desde una opinión negativa (ver [Tickets generados por IA](#tickets-generados-por-ia-ticket-created)). |

### Estructura común

Todos los payloads comparten esta envoltura:

| Campo       | Descripción                              |
| ----------- | ---------------------------------------- |
| `event`     | Nombre del evento (ver tabla anterior).  |
| `timestamp` | Fecha y hora de emisión (ISO 8601, UTC). |
| `companyId` | Empresa a la que pertenece el evento.    |
| `data`      | Datos específicos del evento.            |

### Ejemplos de payload

<Note>
  Los payloads no incluyen datos de contacto del cliente, con una
  excepción: `ticket.created`, cuyo caso incluye la identidad del cliente
  para que el equipo de soporte pueda contactarlo.
</Note>

<CodeGroup>
  ```json qualification.created theme={null}
  {
    "event": "qualification.created",
    "timestamp": "2026-05-26T15:30:00Z",
    "companyId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "data": {
      "clientId": "64f1a2b3c4d5e6f7a8b9c0d1",
      "wokuId": "64d4e5f6a7b8c9d0e1f2a3b4",
      "qualification": 5,
      "responseChannel": "review-app"
    }
  }
  ```

  ```json nps_submission.created theme={null}
  {
    "event": "nps_submission.created",
    "timestamp": "2026-05-26T15:30:00Z",
    "companyId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "data": {
      "npsId": "64b2c3d4e5f6a7b8c9d0e1f2",
      "npsToolId": "64c3d4e5f6a7b8c9d0e1f2a3",
      "responseChannel": "whatsapp"
    }
  }
  ```

  ```json csat_submission.created theme={null}
  {
    "event": "csat_submission.created",
    "timestamp": "2026-05-26T15:30:00Z",
    "companyId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "data": {
      "csatId": "64b2c3d4e5f6a7b8c9d0e1f2",
      "csatToolId": "64c3d4e5f6a7b8c9d0e1f2a3",
      "responseChannel": "review-app"
    }
  }
  ```

  ```json form_submission.created theme={null}
  {
    "event": "form_submission.created",
    "timestamp": "2026-05-26T15:30:00Z",
    "companyId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "data": {
      "formResponseId": "64b2c3d4e5f6a7b8c9d0e1f2",
      "formId": "64c3d4e5f6a7b8c9d0e1f2a3",
      "responseChannel": "api"
    }
  }
  ```
</CodeGroup>

`ces_submission.created` es el espejo de `csat_submission.created` con
`cesId` y `cesToolId`.

## Tickets generados por IA (ticket.created)

Cuando la IA de woku detecta riesgo de perder a un cliente por una mala
experiencia, crea un **ticket de soporte**. Puedes recibirlo de dos
formas, y usar ambas a la vez:

* **Conectores directos** (recomendado, sin código): en **Soporte →
  Configurar** conectas woku a tu plataforma de soporte (Zendesk,
  Salesforce, Slack o un endpoint personalizado). woku crea el ticket
  directamente en la plataforma; el ruteo por trackers y el formato de
  cada plataforma se resuelven en el servidor de woku, sin que expongas
  ningún servicio propio.
* **Webhook** (para integraciones a medida): suscribe un webhook al
  evento `ticket.created` y recibe el **caso completo** en formato
  canónico, listo para crearlo en tu plataforma con tu propio código.

A diferencia del resto de los eventos, el payload de `ticket.created`
lleva la identidad del cliente para que soporte pueda contactarlo.

### Conectores directos (sin código)

En **Soporte → Configurar** agregas uno o más **destinos**. Cada destino
representa una **unidad** de tu SAC (por ejemplo "SAC Chile" y "SAC
México"): apunta a una plataforma (`zendesk`, `salesforce`, `slack` o
`personalizado`), guarda sus credenciales de forma cifrada e identifica el
equipo que recibe el ticket dentro de la plataforma, nunca un agente
suelto:

* **Zendesk**: el **grupo** de agentes (`group_id`).
* **Salesforce**: la **cola** de casos (`OwnerId`, un id `00G...`).
* **Slack**: el **canal** (queda definido por la URL del webhook entrante).

Cada destino define además sus **condiciones de ruteo** sobre los valores
de los trackers: el ticket llega al destino cuando se cumplen **todas** sus
condiciones (y cada condición se cumple con **cualquiera** de sus valores).
Por ejemplo, "SAC Chile" recibe los tickets con `País = Chile`. Si un
ticket no cumple las condiciones de ningún destino, va al **destino por
defecto**. El formato del cuerpo lo resuelve woku según la plataforma; para
el destino `personalizado` defines tú el template con placeholders sobre el
payload canónico (por ejemplo `{{case.subject}}`).

### Webhook con payload canónico

Un webhook suscrito a `ticket.created` recibe siempre el **payload
canónico completo** (los webhooks no transforman el cuerpo ni participan
del ruteo: todo webhook suscrito recibe todos los tickets). El campo
`routing.matched` indica si el ticket se ruteó a un destino directo
(`route`), al destino por defecto (`default`) o a ninguno (`null`):

```json ticket.created (canonical) theme={null}
{
  "event": "ticket.created",
  "timestamp": "2026-07-01T14:32:11Z",
  "companyId": "64a1b2c3d4e5f6a7b8c9d0e1",
  "schemaVersion": "2026-07-01",
  "data": {
    "case": {
      "externalId": "woku_ticket_665f0a...",
      "subject": "Atraso reiterado en entregas",
      "description": { "text": "…", "html": "<p>…</p>" },
      "priority": "urgent",
      "type": "problem",
      "tags": ["woku", "voc", "csat", "churn-high"],
      "requester": { "name": "María Gómez", "email": "maria@cliente.com", "phone": "+56 9 ..." },
      "source": {
        "tool": "csat",
        "instrumentName": "CSAT post entrega",
        "trackers": [{ "name": "Locación", "value": "Sucursal Centro" }],
        "responseId": "64b2c3d4...",
        "score": 1,
        "scaleMax": 5,
        "comment": "Tercera vez que el pedido llega tarde…",
        "language": "es",
        "respondedAt": "2026-07-01T14:20:03Z"
      },
      "routing": { "matched": "route" },
      "assessment": {
        "generatedByAI": true,
        "churnRisk": "high",
        "criteria": ["B1_churn_intent", "B3_repeat_issue"],
        "rationale": "Cliente con entregas tardías repetidas amenaza con irse",
        "whatCustomerWants": "Que el pedido llegue a tiempo",
        "suggestedResolution": "Llamar hoy y compensar el envío",
        "historySummary": "8 respuestas del cliente (csat: 5, nps: 3) 3 negativas desde 2026-01-15",
        "model": "gpt-5.5"
      },
      "links": { "ticketUrl": "https://admin.woku.app/companies/.../tickets?ticketId=..." }
    }
  }
}
```

Campos clave del caso:

| Campo             | Descripción                                                                                                                        |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `externalId`      | Identificador estable del ticket en woku; úsalo para deduplicar reintentos.                                                        |
| `subject`         | Título del problema (máximo 255 caracteres).                                                                                       |
| `description`     | Narrativa completa del caso en `text` (plano) y `html`.                                                                            |
| `priority`        | `urgent`, `high`, `normal` o `low`.                                                                                                |
| `requester`       | Identidad del cliente afectado (nombre, email, teléfono).                                                                          |
| `source`          | Instrumento de origen, trackers, score, comentario textual y fecha de la respuesta.                                                |
| `routing`         | Resultado del ruteo a un destino directo (`matched`: `route`, `default` o `null`). Los trackers del caso van en `source.trackers`. |
| `assessment`      | Por qué la IA escaló: riesgo de fuga, criterios de la rúbrica, qué pide el cliente y la acción sugerida.                           |
| `links.ticketUrl` | Enlace al detalle del ticket en woku.                                                                                              |

## Verificación de firma (HMAC)

Cada entrega incluye el header `X-Woku-Signature` con una firma
**HMAC-SHA256** del cuerpo JSON crudo, usando tu secret como llave:

```
X-Woku-Signature: sha256=<hex>
```

<Warning>
  Calcula la firma sobre el **cuerpo crudo** de la solicitud (los bytes
  exactos recibidos), **antes** de parsearlo como JSON. Re-serializar el
  objeto puede cambiar el orden de las llaves o el espaciado y producir una
  firma distinta.
</Warning>

Compara siempre con una función de **tiempo constante** para evitar
ataques de temporización. Si la firma no coincide, descarta la solicitud.

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

  function verifyWokuSignature(rawBody, signatureHeader, secret) {
    // signatureHeader: "sha256=<hex>"
    const expected =
      'sha256=' +
      crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

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

  // Ejemplo con Express. Usa el cuerpo crudo, no el JSON parseado.
  import express from 'express';
  const app = express();

  app.post(
    '/webhooks/woku',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const signature = req.header('X-Woku-Signature') ?? '';
      if (!verifyWokuSignature(req.body, signature, process.env.WOKU_WEBHOOK_SECRET)) {
        return res.status(401).send('Invalid signature');
      }
      const event = JSON.parse(req.body.toString('utf8'));
      // ... procesa el evento
      res.status(200).json({ received: true });
    },
  );
  ```

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

  def verify_woku_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
      # signature_header: "sha256=<hex>"
      expected = "sha256=" + hmac.new(
          secret.encode("utf-8"), raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature_header)


  # Ejemplo con Flask. request.data es el cuerpo crudo.
  from flask import Flask, request, abort, jsonify
  import os

  app = Flask(__name__)

  @app.post("/webhooks/woku")
  def woku_webhook():
      signature = request.headers.get("X-Woku-Signature", "")
      if not verify_woku_signature(request.data, signature, os.environ["WOKU_WEBHOOK_SECRET"]):
          abort(401)
      event = request.get_json()
      # ... procesa el evento
      return jsonify(received=True), 200
  ```

  ```php PHP theme={null}
  <?php
  function verify_woku_signature(string $rawBody, string $signatureHeader, string $secret): bool
  {
      // $signatureHeader: "sha256=<hex>"
      $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
      return hash_equals($expected, $signatureHeader);
  }

  $rawBody = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_WOKU_SIGNATURE'] ?? '';
  $secret = getenv('WOKU_WEBHOOK_SECRET');

  if (!verify_woku_signature($rawBody, $signature, $secret)) {
      http_response_code(401);
      echo 'Invalid signature';
      exit;
  }

  $event = json_decode($rawBody, true);
  // ... procesa el evento

  http_response_code(200);
  header('Content-Type: application/json');
  echo json_encode(['received' => true]);
  ```
</CodeGroup>

## Respuesta esperada de tu endpoint

Tu endpoint debe responder con un código **HTTP 2xx** (idealmente `200`)
lo antes posible. woku considera exitosa la entrega ante cualquier 2xx.

<Tip>
  Procesa el evento de forma **asíncrona**: responde `200` de inmediato y
  encola el trabajo pesado. woku usa un timeout de **30 segundos** por
  intento; si tu endpoint tarda más, la entrega se cuenta como fallida.
</Tip>

## Reintentos y dead-letter

Si tu endpoint no responde con 2xx (o no responde dentro del timeout),
woku **reintenta con backoff exponencial**:

| Intento | Espera antes del intento |
| ------- | ------------------------ |
| 1       | inmediato                |
| 2       | \~1 s                    |
| 3       | \~2 s                    |

Tras agotar los **3 intentos**, la entrega pasa a estado **dead-letter**:
no se vuelve a intentar automáticamente. Puedes revisar el historial de
entregas y **reintentar manualmente** las que quedaron en dead-letter
desde **Empresa → Integraciones → Webhooks**, en el detalle del webhook.

<Note>
  Como un evento puede entregarse más de una vez (por un reintento sobre una
  entrega que sí llegó pero respondió tarde), diseña tu endpoint para que
  sea **idempotente**: usa el identificador del recurso en `data` para
  descartar duplicados.
</Note>
