# Webhooks (/es/guides/webhooks)

import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
import {TypeTable} from 'fumadocs-ui/components/type-table';

Cuando algo ocurre en Bitelio — un correo rebota, llega una queja de spam, se dispara un evento personalizado — puedes hacer que una solicitud HTTP llegue a tu aplicación en tiempo real. El mecanismo es un [flujo de trabajo](/es/concepts/workflows) que contiene un paso **Webhook** que envía los datos del evento a un endpoint que tú controlas.

<Callout title="¿Buscas una entrega firmada y con reintentos?" type="info">
  Esta página cubre el paso **Webhook** de flujo de trabajo: flexible, pero sin firma, sin historial de entregas, reenvío ni disyuntor de circuito, y sus reintentos solo cubren el paso en el que reentra un job reintentado (ver "Recibir webhooks de forma segura" más abajo). Para una alternativa gestionada centralmente — Bitelio firma cada petición, reintenta de extremo a extremo, y degrada un endpoint que sigue fallando — ver [Webhooks](/es/webhooks), que se registran en **Ajustes → Webhooks**.
</Callout>

## Cómo funciona

Los webhooks se apoyan sobre el sistema de flujos de trabajo. La cadena se ve así:

1. Ocurre algún evento dentro de Bitelio (un correo rebota, un contacto se suscribe, se rastrea un evento personalizado, ...)
2. Se dispara un flujo de trabajo que escucha ese evento
3. Su paso **Webhook** se dispara, entregando una solicitud HTTP con los datos relevantes a tu URL

Como cualquier evento rastreado puede disparar un flujo de trabajo, cualquiera de ellos — generado por el sistema o tus propios eventos personalizados — puede llegarte como un webhook.

## Eventos internos

Bitelio mismo rastrea un conjunto de eventos internos y los ofrece como disparadores de flujo de trabajo. No puedes registrar estos mediante la API; solo el sistema los emite.

### Eventos de correo

| Evento            | Descripción                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `email.sent`      | Un correo se envió con éxito                                                                                             |
| `email.delivery`  | Un correo se entregó al destinatario                                                                                     |
| `email.open`      | Un contacto abrió un correo por primera vez                                                                              |
| `email.click`     | Un contacto hizo clic en un enlace de un correo por primera vez                                                          |
| `email.bounce`    | Un correo rebotó (rebote duro o suave)                                                                                   |
| `email.complaint` | Un contacto marcó un correo como spam                                                                                    |
| `email.received`  | Se recibió un correo en tu dominio verificado (requiere [configuración de correo entrante](/es/guides/receiving-emails)) |

### Eventos de contacto

| Evento                 | Descripción                                                  |
| ---------------------- | ------------------------------------------------------------ |
| `contact.subscribed`   | El estado de suscripción de un contacto cambió a suscrito    |
| `contact.unsubscribed` | El estado de suscripción de un contacto cambió a no suscrito |

### Eventos de segmento

| Evento                 | Descripción                      |
| ---------------------- | -------------------------------- |
| `segment.<name>.entry` | Un contacto entró a un segmento  |
| `segment.<name>.exit`  | Un contacto salió de un segmento |

<Callout title="Nombres de eventos de segmento" type="info">
  La parte `<name>` es el nombre del segmento en forma de slug. Un segmento llamado "VIP Users" por lo tanto emite
  `segment.vip-users.entry` y `segment.vip-users.exit`.
</Callout>

## Configurar un webhook

import {Step, Steps} from 'fumadocs-ui/components/steps';

<Steps>
  <Step>
    ### Crea el flujo de trabajo

    Ve a **Flujos de trabajo** en el panel e inicia uno nuevo, eligiendo el evento que te interesa como su disparador. Para recibir notificaciones sobre rebotes, por ejemplo, establece el disparador en `email.bounce`.
  </Step>

  <Step>
    ### Añade un paso Webhook

    Adjunta un paso **Webhook** después del disparador y completa sus ajustes:

    * **URL**: Dónde en tu servidor debe aterrizar el webhook (p. ej. `https://api.example.com/webhooks/bitelio`)
    * **Método**: El verbo HTTP a usar. `POST` es el predeterminado y la elección correcta para la mayoría de situaciones.
    * **Encabezados** (opcional): Encabezados adicionales para la solicitud, dados como JSON — útil para autenticación.

    ```json
    {
      "Authorization": "Bearer your-secret-token"
    }
    ```

    * **Cuerpo** (opcional): Un cuerpo de solicitud personalizado. Déjalo vacío y Bitelio envía la [carga útil por defecto](#webhook-payload) documentada abajo; suministra uno y reemplaza por completo esa carga por defecto, codificada en JSON antes de enviarse.
  </Step>

  <Step>
    ### Usa variables en la solicitud (opcional)

    La interpolación `{{variable}}` funciona en la `url`, en los valores de encabezado, y en el `body`. El alcance de variables coincide con el que obtienen las plantillas `SEND_EMAIL`, con una adición exclusiva de los webhooks: un espacio de nombres `event` que lleva el payload del evento disparador:

    | Variable                                                  | Valor                                                                                         |
    | --------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
    | `{{id}}`, `{{email}}`                                     | El ID y el correo del contacto.                                                               |
    | `{{<key>}}` (nivel superior)                              | Cualquier clave del JSON `data` del contacto (p. ej. `{{firstName}}`, `{{plan}}`).            |
    | `{{data.<key>}}`                                          | Los mismos datos de contacto, dirigidos vía el espacio de nombres `data`.                     |
    | `{{event.<key>}}`                                         | Exclusivo de webhooks. Campos del payload del evento disparador (p. ej. `{{event.subject}}`). |
    | `{{<key>}}` (del contexto de ejecución)                   | Claves pasadas como `context` al iniciar una ejecución `MANUAL`.                              |
    | `{{unsubscribeUrl}}`, `{{subscribeUrl}}`, `{{manageUrl}}` | URL de gestión de suscripción por contacto.                                                   |

    Dos cosas **no** son parametrizables: el `method` HTTP, que tiene que ser un verbo literal (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), y el esquema de la URL, que debe escribirse como `http://` o `https://` — los marcadores pueden aparecer en cualquier otra parte de la URL, solo no en lugar del esquema.

    Ejemplo — reenviar un evento de contacto a tu propia API, parametrizado con datos del contacto:

    **URL**

    ```text
    https://api.example.com/users/{{id}}/events
    ```

    **Encabezados**

    ```json
    {
      "Authorization": "Bearer your-secret-token"
    }
    ```

    **Cuerpo**

    ```json
    {
      "email": "{{email}}",
      "plan": "{{plan}}",
      "referrer": "{{event.referrer}}"
    }
    ```
  </Step>

  <Step>
    ### Habilita el flujo de trabajo

    Activa el flujo de trabajo terminado. A partir de ahí, cada ocurrencia del evento disparador produce una solicitud de webhook.
  </Step>
</Steps>

## Carga útil del webhook

Sin ningún cuerpo personalizado configurado, la carga útil por defecto que envía Bitelio es una solicitud JSON con esta forma:

```json
{
  "contact": {
    "email": "user@example.com",
    "subscribed": true,
    "data": {
      "name": "John",
      "plan": "pro"
    }
  },
  "workflow": {
    "id": "wf_abc123",
    "name": "Bounce Notifications"
  },
  "execution": {
    "id": "exec_xyz789",
    "startedAt": "2025-01-15T10:30:00.000Z"
  },
  "event": {
    "subject": "Welcome to Bitelio",
    "from": "hello@example.com",
    "fromName": "Bitelio Team",
    "messageId": "ses-message-id",
    "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
    "templateId": null,
    "campaignId": "camp_abc123",
    "sourceType": "CAMPAIGN",
    "bounceType": "Permanent",
    "bouncedAt": "2025-01-15T10:30:00.000Z"
  }
}
```

Cualquier dato que haya acompañado al evento disparador termina en el campo `event` — así que su forma exacta varía según el tipo de evento.

### Datos de evento por tipo

#### Eventos de correo

Un conjunto común de campos base aparece en la mayoría de los eventos de correo:

<TypeTable
  type={{
  subject: { type: 'string', description: 'La línea de asunto del correo.' },
  from: { type: 'string', description: 'La dirección de correo del remitente.' },
  fromName: { type: 'string', description: 'El nombre para mostrar del remitente.' },
  messageId: { type: 'string', description: 'Identificador de mensaje del lado del proveedor (útil para correlacionar con registros de entrega).' },
  emailId: { type: 'string', description: 'El ID del registro de correo de Bitelio — devuelto por `POST /v1/send`, usado para correlacionar webhooks con respuestas de API.' },
  templateId: { type: 'string', description: 'El ID de la plantilla, si el correo se envió usando una plantilla. De lo contrario `null`.' },
  campaignId: { type: 'string', description: 'El ID de la campaña, si el correo formó parte de una campaña. De lo contrario `null`.' },
  sourceType: { type: 'string', description: 'Cómo se disparó el correo. Uno de `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, o `INBOUND`.' },
}}
/>

Además de esta base, cada tipo de evento añade sus propios campos, mostrados por pestaña abajo. Recuerda que los campos base (`subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId`, `sourceType`) acompañan a todo evento de correo junto a los específicos del evento.

<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
  <Tab value="email.sent">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "sentAt": "2025-01-15T10:30:00.000Z"
    }
    ```

    <TypeTable
      type={{
  sentAt: { type: 'string', description: 'Marca de tiempo ISO 8601 de cuándo se aceptó el correo para su entrega.' },
}}
    />
  </Tab>

  <Tab value="email.delivery">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": "camp_abc123",
      "sourceType": "CAMPAIGN",
      "deliveredAt": "2025-01-15T10:30:05.000Z"
    }
    ```

    <TypeTable
      type={{
  deliveredAt: { type: 'string', description: 'Marca de tiempo ISO 8601 de cuándo se entregó el correo al destinatario.' },
}}
    />
  </Tab>

  <Tab value="email.open">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "openedAt": "2025-01-15T11:00:00.000Z",
      "opens": 1,
      "isFirstOpen": true
    }
    ```

    <TypeTable
      type={{
  openedAt: { type: 'string', description: 'Marca de tiempo ISO 8601 de la primera apertura.' },
  opens: { type: 'number', description: 'Número total de veces que este correo se ha abierto.' },
  isFirstOpen: { type: 'boolean', description: '`true` si esta es la primera vez que el contacto abre este correo.' },
}}
    />
  </Tab>

  <Tab value="email.click">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "link": "https://example.com/pricing",
      "clickedAt": "2025-01-15T11:05:00.000Z",
      "clicks": 1,
      "isFirstClick": true
    }
    ```

    <TypeTable
      type={{
  link: { type: 'string', description: 'La URL en la que se hizo clic.' },
  clickedAt: { type: 'string', description: 'Marca de tiempo ISO 8601 del primer clic.' },
  clicks: { type: 'number', description: 'Número total de clics en enlaces de este correo.' },
  isFirstClick: { type: 'boolean', description: '`true` si este es el primer clic de este contacto en este correo.' },
}}
    />
  </Tab>

  <Tab value="email.bounce">
    Rebote permanente:

    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "bounceType": "Permanent",
      "bouncedAt": "2025-01-15T10:31:00.000Z"
    }
    ```

    Rebote transitorio (suave):

    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "bounceType": "Transient",
      "transientBounce": true
    }
    ```

    <TypeTable
      type={{
  bounceType: { type: 'string', description: '`Permanent` para rebotes duros, `Transient` para rebotes suaves (buzón lleno, fuera de la oficina, greylist).' },
  bouncedAt: { type: 'string', description: 'Marca de tiempo ISO 8601 de cuándo ocurrió el rebote. Solo presente en rebotes permanentes.' },
  transientBounce: { type: 'boolean', description: '`true` para rebotes suaves — estos no cuentan hacia tu tasa de rebote y el contacto permanece suscrito.' },
}}
    />

    <Callout title="Impacto en la tasa de rebote" type="warn">
      Solo los rebotes `Permanent` cuentan hacia la tasa de rebote de tu proyecto y disparan la baja automática del contacto.
      Los rebotes `Transient` se rastrean solo para visibilidad.
    </Callout>
  </Tab>

  <Tab value="email.complaint">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "complainedAt": "2025-01-15T10:35:00.000Z"
    }
    ```

    <TypeTable
      type={{
  complainedAt: { type: 'string', description: 'Marca de tiempo ISO 8601 de cuándo se recibió la queja.' },
}}
    />
  </Tab>

  <Tab value="email.received">
    Se dispara cuando llega correo a tu dominio verificado — la configuración se cubre en [Recepción de correos](/es/guides/receiving-emails).

    ```json
    {
      "messageId": "ses-message-id",
      "from": "sender@example.com",
      "fromHeader": "Jane Smith <sender@example.com>",
      "to": "support@yourdomain.com",
      "subject": "Re: Your question",
      "timestamp": "2025-01-15T10:30:00.000Z",
      "recipients": ["support@yourdomain.com"],
      "hasContent": true,
      "body": "<html><body>This is the email body content...</body></html>",
      "spamVerdict": "PASS",
      "virusVerdict": "PASS",
      "spfVerdict": "PASS",
      "dkimVerdict": "PASS",
      "dmarcVerdict": "PASS",
      "processingTimeMillis": 142
    }
    ```

    <TypeTable
      type={{
  messageId: { type: 'string', description: 'Identificador único para el mensaje recibido.' },
  from: { type: 'string', description: 'La dirección de correo del remitente.' },
  fromHeader: { type: 'string', description: 'El encabezado `From` completo, incluyendo el nombre para mostrar si está presente.' },
  to: { type: 'string', description: 'La dirección destinataria en tu dominio verificado.' },
  subject: { type: 'string', description: 'La línea de asunto del correo.' },
  timestamp: { type: 'string', description: 'Marca de tiempo ISO 8601 de cuándo se recibió el correo.' },
  recipients: { type: 'array of strings', description: 'Todas las direcciones destinatarias en el sobre.' },
  hasContent: { type: 'boolean', description: 'Si el contenido del cuerpo del correo está disponible.' },
  body: { type: 'string', description: 'Cuerpo HTML sanitizado del correo (o texto plano si no hay HTML disponible).' },
  spamVerdict: { type: 'string', description: 'Resultado de la comprobación de spam. Uno de `PASS`, `FAIL`, `GRAY`, o `PROCESSING_FAILED`.' },
  virusVerdict: { type: 'string', description: 'Resultado de la comprobación de virus. Uno de `PASS`, `FAIL`, `GRAY`, o `PROCESSING_FAILED`.' },
  spfVerdict: { type: 'string', description: 'Resultado de autenticación SPF.' },
  dkimVerdict: { type: 'string', description: 'Resultado de autenticación DKIM.' },
  dmarcVerdict: { type: 'string', description: 'Resultado de autenticación DMARC.' },
  processingTimeMillis: { type: 'number', description: 'Tiempo que tomó procesar el correo entrante.' },
}}
    />
  </Tab>
</Tabs>

#### Eventos de contacto

Por defecto, `contact.subscribed` y `contact.unsubscribed` no vienen con ningún dato de evento — `event` es simplemente `{}`.

Hay una excepción: cuando un rebote o una queja causó la baja automáticamente, `event` lleva un `reason`:

```json
{
  "reason": "bounce"
}
```

<TypeTable
  type={{
  reason: { type: 'string', description: 'Por qué se dio de baja al contacto. Uno de `bounce` o `complaint`. Solo presente cuando se dispara automáticamente por un rebote o una queja.' },
}}
/>

#### Eventos de segmento

`segment.<name>.entry` y `segment.<name>.exit` llevan ambos:

```json
{
  "segmentId": "seg_abc123",
  "segmentName": "VIP Users"
}
```

<TypeTable
  type={{
  segmentId: { type: 'string', description: 'El ID del segmento.' },
  segmentName: { type: 'string', description: 'El nombre para mostrar del segmento.' },
}}
/>

#### Eventos personalizados

Para eventos que rastreas tú mismo a través de la API, la carga útil es el objeto `data` que hayas suministrado en la llamada `track`.

#### Sin datos de evento

Los eventos sin ningún dato asociado producen un objeto vacío `{}` en el campo `event`.

## Correlacionar webhooks con solicitudes de envío

Todo evento de correo lleva un `emailId` — el mismo ID de registro de correo de Bitelio que devuelve `POST /v1/send`. Eso te da una unión directa entre eventos de webhook y las llamadas a la API que los causaron.

**Ejemplo de flujo de trabajo:**

1. Enviar correo vía API:

```json
POST /v1/send
{
  "to": "user@example.com",
  "subject": "Welcome",
  "body": "Hello!"
}

Response:
{
  "success": true,
  "data": {
    "emails": [
      {
        "contact": {"id": "cnt_abc", "email": "user@example.com"},
        "email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
      }
    ]
  }
}
```

2. Guarda el ID `email` (`ac32f08e-c6b9-45d3-9824-a73dff1e3bbf`) en tu base de datos

3. Cuando se disparen eventos de webhook (p. ej., `email.open`, `email.bounce`), empárejalos usando `event.emailId`:

```json
{
  "event": {
    "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
    "messageId": "ses-message-id",
    "openedAt": "2025-01-15T11:00:00.000Z"
  }
}
```

Con esta unión en su lugar, no hay razón para emparejar por correo del contacto más marca de tiempo, ni para suscribirte a webhooks de `email.sent` solo para capturar el `messageId` del proveedor.

## Casos de uso comunes

### Monitorización de rebotes y quejas

Reenvía rebotes y quejas a tu aplicación con un flujo de trabajo disparado en `email.bounce` o `email.complaint` — una forma directa de mantener tu propia base de datos alineada con los estados de contacto de Bitelio.

Los pasos colocados antes del webhook te permiten afinar el comportamiento:

* **Condición**: Restringe el webhook a rebotes duros inspeccionando `bounceType`
* **Retraso**: Inserta una pausa corta para que los eventos relacionados puedan procesarse juntos
* **Actualizar contacto**: Adjunta metadatos al contacto antes de que salga el webhook

### Sincronizar bajas

Un flujo de trabajo disparado en `contact.unsubscribed` puede alertar a tu aplicación cada vez que alguien se da de baja — el patrón estándar para mantener el estado de suscripción consistente entre sistemas.

### Reenvío de eventos personalizados

Los eventos que rastreas tú mismo (digamos `user.signup` u `order.completed`) pueden retransmitirse a otros servicios mediante webhooks. En efecto, Bitelio se convierte en un enrutador de eventos: una llamada a `track`, entrega a tantos endpoints como quieras.

## Recibir webhooks de forma segura

Algunas propiedades del paso Webhook de Bitelio importan al diseñar el endpoint que lo recibe:

* **Método**: `POST` con `Content-Type: application/json` por defecto; cada paso puede sobrescribir el método.
* **Tiempo de espera**: las solicitudes se cortan después de **10 segundos**. Si tu procesamiento tarda más, acepta la solicitud, encola el trabajo, y responde `2xx` de inmediato.
* **Redirecciones**: se siguen como máximo 5 redirecciones, y cada salto se vuelve a comprobar contra las reglas SSRF de abajo.
* **Se requiere URL pública**: el endpoint tiene que ser alcanzable desde internet público — los destinos privados e internos (loopback, rangos RFC 1918, etc.) nunca reciben entregas.
* **Esquemas**: solo `http://` y `https://`. Prefiere HTTPS.
* **Reintentos, pero limitados a un paso**: una respuesta que no sea 2xx o un tiempo de espera lanza una excepción, y la cola reintenta el job (3 intentos en total, con espera exponencial). Ese reintento vuelve a ejecutar el mismo paso para el que se encoló el job — un paso Webhook al que se llega más adelante en la misma ejecución, sin un `DELAY` o `WAIT_FOR_EVENT` de por medio, no tiene reintento propio; un fallo ahí termina el intento de inmediato. En cualquier caso, haz que tu manejador sea idempotente, y configura el `onError` del paso a `continue` (por defecto es `stop`) si un fallo aquí no debería detener el recorrido del contacto.
* **Verifica la autenticidad con un secreto compartido**: pon un encabezado secreto en el paso webhook y valídalo del lado del servidor:

  ```json
  // Paso Webhook → Encabezados
  {
    "Authorization": "Bearer your-shared-secret"
  }
  ```

  Cada solicitud de ese paso lleva el secreto; rótalo como lo harías con cualquier credencial compartida. Esto es mejor que la lista blanca de IP, ya que las IP de salida están sujetas a cambios.

## Añadir condiciones y retrasos

Como los webhooks son pasos de flujo de trabajo ordinarios, se combinan con todo lo demás que ofrece el sistema de flujos de trabajo:

* Un paso **Condición** condiciona el webhook a cualquier criterio que elijas (p. ej. solo contactos en un plan particular)
* Un paso **Esperar evento** retiene el webhook hasta que llegue un evento de seguimiento (p. ej. ver si un contacto que rebotó se resuscribe primero)
* Un paso **Retraso** inserta un margen de tiempo antes del webhook
