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

# Webhooks

> Recibe eventos de entrega e inbound en tu servidor, verificados con HMAC.

RQE dispara un webhook por cada evento de un email: aceptación, entrega, rebote, queja, apertura, click, supresión y respuesta del destinatario.

## Cómo recibir webhooks

<Steps>
  <Step title="Configura la URL en el dashboard">
    Abre tu proyecto y ve a **Configuración → Integraciones → Webhooks**. Pega la URL pública de tu endpoint. Las URLs por proyecto y qué recibe cada una están en [Routing live / test / environment](#routing-live--test--environment).
  </Step>

  <Step title="Expón tu endpoint local">
    En desarrollo, crea un túnel a tu servidor local con [ngrok](https://ngrok.com) (`ngrok http 3000`) y usa la URL pública generada como URL del webhook.
  </Step>

  <Step title="Valida la firma">
    Cada POST incluye el header `X-RQE-Signature`. Verifica el HMAC sobre el body raw antes de procesar el evento. Ver más en [Verificación HMAC](#verificación-hmac).
  </Step>

  <Step title="Responde 200">
    Devuelve un status `2xx` cuanto antes. Cualquier otro status, timeout o error de red activa reintentos. Ver [Retry y respuesta esperada](#retry-y-respuesta-esperada).
  </Step>

  <Step title="Pasa a producción">
    Reemplaza la URL del túnel por la de tu servidor. El flag `is_test` del payload distingue el tráfico de envíos con `sk_test_*`.
  </Step>
</Steps>

## Eventos

| `event`               | Cuándo dispara                                                                     | Datos extra en `data`                                                                          |
| --------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `email.send`          | RQE aceptó y despachó el envío                                                     | —                                                                                              |
| `email.delivery`      | El servidor del destinatario aceptó el email                                       | —                                                                                              |
| `email.bounce`        | Email rebotado                                                                     | `bounce_type`, `bounce_subtype`                                                                |
| `email.complaint`     | Destinatario marcó como spam                                                       | `complaint_feedback_type`                                                                      |
| `email.reject`        | Envío rechazado antes de salir                                                     | —                                                                                              |
| `email.deliverydelay` | Entrega temporalmente demorada                                                     | —                                                                                              |
| `email.open`          | Destinatario abrió el email (pixel cargado)                                        | —                                                                                              |
| `email.click`         | Click en un link                                                                   | `url_clicked`                                                                                  |
| `email.suppressed`    | El destinatario está en la lista de supresión — el envío se bloqueó antes de salir | `email`, `suppressed_at` (ver [nota](#emailsuppressed))                                        |
| `email.inbound`       | Destinatario respondió tu email                                                    | (ver sección Replies)                                                                          |
| `sender.verified`     | Un remitente verificado por email individual (magic link) quedó listo              | `identity_id`, `sender_email`, `can_send`                                                      |
| `sender.failed`       | La verificación del remitente individual falló                                     | `identity_id`, `sender_email`                                                                  |
| `domain.verified`     | Un dominio de envío quedó verificado (dominio + DKIM)                              | ver [abajo](#domainverified-domainfailed)                                                      |
| `domain.failed`       | La verificación del dominio falló en SES                                           | ver [abajo](#domainverified-domainfailed)                                                      |
| `domain.send_ready`   | El dominio ya puede enviar, aunque DKIM todavía no termine de propagar             | ver [abajo](#domainsendready)                                                                  |
| `suppression.added`   | Una dirección entró a tu lista de supresión (rebote duro o queja)                  | `email`, `reason`, `source`, `description` (ver [abajo](#suppressionadded-suppressionremoved)) |
| `suppression.removed` | Una dirección salió de tu lista de supresión                                       | `email`, `removed_at` (ver [abajo](#suppressionadded-suppressionremoved))                      |

## Payload outbound

Todos los eventos outbound (`email.send`, `email.delivery`, `email.bounce`, `email.complaint`, `email.reject`, `email.deliverydelay`, `email.open`, `email.click`) comparten la misma estructura top-level. Solo cambia `data`.

```json theme={null}
{
  "event": "email.delivery",
  "timestamp": "2026-04-29T15:30:42.123Z",
  "project_id": "uuid",
  "is_test": false,
  "environment": "staging",
  "data": { "...": "..." }
}
```

| Campo         | Tipo       | Descripción                                                                                                             |
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| `event`       | string     | Tipo del evento (ver tabla arriba).                                                                                     |
| `timestamp`   | string ISO | Momento en que RQE despachó el webhook.                                                                                 |
| `project_id`  | uuid       | Tu proyecto.                                                                                                            |
| `is_test`     | boolean    | `true` si el email se envió con `sk_test_*`.                                                                            |
| `environment` | string     | (Opcional) presente solo si el envío incluyó `environment`. Ver [Webhook environments](/concepts/webhook-environments). |
| `data`        | object     | Detalle del evento (ver siguiente tabla).                                                                               |

### Campos de `data`

| Campo                     | Presente en                         | Descripción                                                                                                                                                                |
| ------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activity_id`             | todos                               | UUID del email original (lo recibes en la respuesta de `/v1/send-email`).                                                                                                  |
| `message_id`              | todos, salvo algunos `open`/`click` | Message-ID asignado al email. Los `open`/`click` registrados por el tracking propio de RQE (pixel/redirect) no lo incluyen — usa `activity_id` como identificador estable. |
| `recipient`               | todos                               | Email del destinatario.                                                                                                                                                    |
| `event_type`              | todos                               | Tipo lowercase (`delivery`, `bounce`, `open`, `click`, ...). Equivale a `event` sin `email.`.                                                                              |
| `event_timestamp`         | todos                               | ISO timestamp del evento real (distinto del top-level `timestamp` que es cuando RQE lo despachó).                                                                          |
| `bounce_type`             | bounce                              | `Permanent` o `Transient`.                                                                                                                                                 |
| `bounce_subtype`          | bounce                              | `General`, `NoEmail`, `Suppressed`, etc.                                                                                                                                   |
| `complaint_feedback_type` | complaint                           | `abuse`, `auth-failure`, `fraud`, `not-spam`, `other`, `virus`.                                                                                                            |
| `url_clicked`             | click                               | URL original a la que el destinatario hizo click.                                                                                                                          |

<Note>
  Los eventos `email.send` y `email.delivery` no incluyen un campo `delivered_at` separado — usa `event_timestamp`.
</Note>

### `email.suppressed`

Se emite cuando envías vía API a una dirección que está en la lista de supresión del proyecto (rebote duro o queja previos): el correo **se bloquea antes de salir** y la respuesta de `/v1/send-email` ya lo indica con `suppressed: true` (ver [Destinatario suprimido](/api-reference/send-email#destinatario-suprimido)). Úsalo para marcar la dirección en tu sistema y dejar de intentar.

Particularidades vs los demás eventos outbound:

* Solo se emite para envíos vía **API** — campañas y automatizaciones no lo disparan.
* Su payload es reducido: el top-level no incluye `is_test` ni `environment`, y no hay `message_id` — el correo nunca salió. `data` trae `email`, `activity_id`, `email_type`, `suppressed_at` y el motivo (`suppression_reason`, `suppression_source`, `suppression_description`).

```json theme={null}
{
  "event": "email.suppressed",
  "timestamp": "2026-07-05T22:30:00.000Z",
  "project_id": "uuid",
  "data": {
    "email": "destinatario@ejemplo.com",
    "activity_id": "uuid",
    "email_type": "individual",
    "suppressed_at": "2026-07-05T22:30:00.000Z",
    "suppression_reason": "bounce",
    "suppression_source": "ses_event",
    "suppression_description": "The address does not exist"
  }
}
```

### `suppression.added` / `suppression.removed`

`email.suppressed` avisa cuando **intentas enviar** a una dirección ya suprimida. Estos dos avisan **en el momento en que la dirección entra o sale** de la lista, sin esperar a un envío.

Es la diferencia que importa si mantienes tu propio CRM: sin ellos, un contacto se te muere en silencio y solo te enteras en el siguiente intento, que puede ser semanas después.

`suppression.added` se emite cuando RQE suprime automáticamente una dirección tras un **rebote duro** o una **queja de spam**.

```json theme={null}
{
  "event": "suppression.added",
  "timestamp": "2026-08-19T00:30:00.000Z",
  "project_id": "uuid",
  "data": {
    "email": "destinatario@ejemplo.com",
    "reason": "bounce",
    "source": "ses_event",
    "description": "The address does not exist",
    "bounce_type": "Permanent",
    "bounce_subtype": "NoEmail",
    "suppressed_at": "2026-08-19T00:30:00.000Z"
  }
}
```

En una queja, `reason` es `complaint` y en lugar de los campos de rebote llega `complaint_feedback_type`.

`suppression.removed` se emite cuando una dirección se quita de la lista desde el dashboard o la API — útil porque puede haberla reactivado otra persona de tu equipo, no tu sistema.

```json theme={null}
{
  "event": "suppression.removed",
  "timestamp": "2026-08-19T00:45:00.000Z",
  "project_id": "uuid",
  "data": {
    "email": "destinatario@ejemplo.com",
    "removed_at": "2026-08-19T00:45:00.000Z",
    "removed_by": "uuid"
  }
}
```

<Note>
  `suppression.added` **no** se emite para los rebotes de subtipo `OnAccountSuppressionList`: ese es el eco de la lista del proveedor, no un rebote nuevo, y no genera una supresión en tu proyecto.

  En una remoción masiva se emite un evento por dirección hasta un tope de 200; si lo superas queda registrado en los logs.
</Note>

### `domain.send_ready`

Se emite **una sola vez**, cuando el dominio pasa a poder enviar. Llega **antes** que `domain.verified`: SES habilita el envío en cuanto reconoce el dominio, mientras DKIM puede seguir propagando un rato más.

Sirve para desbloquear al remitente sin tener que fingir que la autenticación ya terminó. Si automatizas altas de dominio, este es el evento que te deja habilitar el envío en tu propia UI; `domain.verified` llega después y confirma que además hay firma DKIM alineada.

```json theme={null}
{
  "event": "domain.send_ready",
  "timestamp": "2026-07-21T14:03:00.000Z",
  "project_id": "uuid",
  "data": {
    "identity_id": "uuid",
    "domain": "mitienda.com",
    "can_send": true
  }
}
```

<Note>
  Un dominio que puede enviar pero todavía no tiene DKIM verificado entrega peor: sin firma alineada, DMARC no pasa por DKIM. Habilita el envío si lo necesitas, pero espera a `domain.verified` antes de mandar volumen.
</Note>

### `domain.verified` / `domain.failed`

Se emiten cuando un dominio de envío **cambia** de estado de verificación. Son la alternativa a consultar `GET /domains/{domain}/status` en bucle: si automatizas el alta de dominios, escucha estos eventos en lugar de pollear.

```json theme={null}
{
  "event": "domain.verified",
  "timestamp": "2026-07-21T14:05:00.000Z",
  "project_id": "uuid",
  "data": {
    "identity_id": "uuid",
    "identity_type": "domain",
    "domain": "mitienda.com",
    "verification_status": "verified",
    "previous_verification_status": "pending",
    "sender_profile_id": "uuid",
    "sender_email": "ventas@mitienda.com",
    "sender_name": "Mi Tienda",
    "domain_authenticated": true,
    "can_send": true,
    "details": {
      "domain_verification": "Success",
      "dkim_verification": "Success",
      "mail_from_status": "Success"
    },
    "verified_at": "2026-07-21T14:05:00.000Z"
  }
}
```

<Info>
  **Solo en la transición.** El estado de los dominios `pending` se re-consulta periódicamente, pero el webhook se emite **una única vez**, cuando el estado realmente cambia. Un dominio que sigue `verified` no vuelve a emitir. `previous_verification_status` te dice desde qué estado venía.
</Info>

<Note>
  `domain.failed` llega con la misma forma, con `verification_status: "failed"` y `can_send: false`. Revisa `details` para saber qué parte falló (`domain_verification` o `dkim_verification`) y llama a `POST /domains/{domain}/recreate` si DKIM quedó atascado.
</Note>

## Headers de la request

RQE hace POST a tu URL con:

| Header              | Valor                                                                                                                                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                                                                                                          |
| `User-Agent`        | `ReallyQuickEmails-Webhook/1.0`                                                                                                                                             |
| `X-RQE-Signature`   | `sha256=<hmac_hex>` — HMAC-SHA256 del body raw, secret = tu API key de producción (`sk_proj_*`), incluso para eventos de envíos test                                        |
| `X-RQE-Environment` | `live`, `dev`, o el nombre del [environment custom](/concepts/webhook-environments) si aplica. Solo en eventos outbound — el POST de `email.inbound` no incluye este header |

### Verificación HMAC

```javascript theme={null}
const crypto = require('crypto');
const expected = 'sha256=' + crypto
  .createHmac('sha256', apiKey)
  .update(rawBody)
  .digest('hex');
if (req.headers['x-rqe-signature'] !== expected) {
  return res.status(401).send('Invalid signature');
}
```

<Warning>
  Verifica la firma sobre el body **raw**, antes de cualquier parser JSON. Si el body cambia (espacios, orden de keys), el HMAC no va a coincidir.
</Warning>

## Routing live / test / environment

Cada proyecto tiene URLs configurables:

| URL                            | Recibe                                                                                                                  |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `webhook_url` (Producción)     | Todos los eventos outbound — live **y** test                                                                            |
| `webhook_url_dev` (Desarrollo) | Todos los eventos outbound — live **y** test                                                                            |
| `webhook_environments[<key>]`  | Solo eventos de envíos que incluyeron `environment: "<key>"` en el body (override: las demás URLs no reciben ese envío) |

Sin `environment` custom, cada evento outbound se entrega a **todas las URLs configuradas** (Producción y Desarrollo a la vez): el routing no filtra por modo del envío. El header `X-RQE-Environment` indica el slot de cada POST (`live` / `dev`); el flag `is_test` del payload dice si el envío usó `sk_test_*`.

Si configuras `inbound_webhook_url` / `inbound_webhook_url_dev`, esas URLs **reemplazan** a `webhook_url` / `webhook_url_dev` y reciben todos los eventos (outbound + inbound) — modelo "una URL recibe todo".

Los webhooks **inbound** (replies) van a **una sola URL** según el modo del envío original (ver fallback abajo). El override por environment usa `inbound_webhook_environments[<key>]`.

### Fallback automático

Aplica solo a `email.inbound`; los eventos outbound no tienen fallback porque van a todas las URLs configuradas. RQE usa la primera URL no vacía de esta cadena:

* Envío original con `sk_test_*`: `inbound_webhook_url_dev` → `webhook_url_dev` → `inbound_webhook_url` → `webhook_url`
* Envío original live: `inbound_webhook_url` → `webhook_url` → `inbound_webhook_url_dev` → `webhook_url_dev`

Si ninguna URL está configurada (outbound o inbound), el evento no se entrega.

Para environments custom, **no hay fallback** — si el `environment` declarado en el envío no está configurado, el envío entero falla con `400 ENVIRONMENT_NOT_CONFIGURED`. Ver [Webhook environments](/concepts/webhook-environments).

### `is_test` en el payload

```json theme={null}
{
  "event": "email.delivery",
  "is_test": true,
  "data": { "...": "..." }
}
```

Como los eventos outbound llegan a todas las URLs configuradas, basta configurar `webhook_url` para recibir todo en una sola URL. Distingue el tráfico test con el flag `is_test`.

## Inbound (replies con adjuntos)

Cuando un destinatario responde tu email, RQE captura la respuesta y la dispara como `email.inbound`.

```mermaid theme={null}
flowchart LR
  A[Destinatario responde] --> B[SES recibe en el inbound de RQE]
  B --> C[RQE resuelve el hilo por Message-ID]
  C --> D[POST webhook email.inbound a tu endpoint]
  D --> E[Incluye activity_id y thread_id resueltos]
```

### Cuándo dispara

Solo cuando el email outbound original se envió **vía API** (`POST /v1/send-email`, `POST /send-email` o `POST /v1/send-batch`). En ese caso el Reply-To lleva un token único:

```
Reply-To: "Tu Empresa" <r-{token}@rqe.inbound.reallyquickemails.com>
```

Los clientes (Gmail, Outlook, Apple Mail) muestran el nombre del remitente, no la dirección técnica. Al responder, la respuesta llega a RQE y dispara el webhook.

**No dispara** para envíos desde la UI de RQE (campañas, automatizaciones, "enviar prueba"): esos usan el Reply-To del sender humano para que reciba en su inbox.

### Reply domain branded (opcional, sin token)

Un proyecto puede configurar un **dominio de reply propio** (`reply.tudominio.com`) para que el Reply-To salga limpio y branded, sin el token codificado:

```
Reply-To: "Tu Empresa" <soporte@reply.tudominio.com>
```

En vez del token, **el dominio identifica tu proyecto** y el **`Message-ID` identifica el hilo** (headers `In-Reply-To` / `References`). El evento `email.inbound` que recibes es **idéntico**: sigues leyendo `activity_id` y `thread_id` del payload, no la dirección. El token siempre fue plomería interna de RQE, no algo que tu integración parsee — así que activar un reply domain **no cambia tu código**.

<Info>
  Se habilita por proyecto publicando 2 registros DNS (un TXT de verificación SES en `_amazonses.reply.tudominio.com` + un MX `reply.tudominio.com` → `inbound-smtp.us-east-1.amazonaws.com`) y coordinando con RQE. La configuración self-service está en camino. Sin reply domain configurado, se usa el token de siempre.
</Info>

<Warning>
  Con reply domain el hilo se resuelve por los headers de Message-ID. En casos raros —un cliente de correo que borra esos headers, o un correo **nuevo** enviado directo a la dirección que no es una respuesta— el evento llega con `thread_id: null`. Trátalo como un inbound nuevo.
</Warning>

### Body del payload inbound

```json theme={null}
{
  "event": "email.inbound",
  "timestamp": "2026-04-28T22:45:32.299Z",
  "project_id": "uuid",
  "is_test": false,
  "data": {
    "message_id": "<gmail-message-id@mail.gmail.com>",
    "thread_id": "uuid",
    "in_reply_to": "<original-message-id@us-east-1.amazonses.com>",
    "references": ["<...>"],
    "from": { "email": "cliente@empresa.com", "name": "Cliente Externo" },
    "to": [
      { "email": "r-Rbxiu6RC@rqe.inbound.reallyquickemails.com", "name": null }
    ],
    "cc": [],
    "subject": "Re: Asunto original",
    "text_body": "respuesta plana del cliente",
    "html_body": "<div>respuesta html</div>",
    "date": "2026-04-28T23:30:49.000Z",
    "attachments": [
      {
        "filename": "factura.pdf",
        "content_type": "application/pdf",
        "size": 50826,
        "storage_key": "...",
        "download_url": "https://...?token=..."
      }
    ],
    "return_path_parsed": null,
    "original_outbound": {
      "message_id": "<original-message-id@us-east-1.amazonses.com>",
      "activity_id": "uuid",
      "campaign_id": null,
      "email_type": null,
      "thread_id": "uuid"
    }
  }
}
```

Igual que en outbound, el payload incluye `is_test` y, si el envío original usó un environment custom, también `environment`.

### Campos clave

| Campo                               | Descripción                                                                                                                                                                           |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.from`                         | Quien envió la respuesta (cliente externo).                                                                                                                                           |
| `data.to[]`                         | Dirección token a la que respondió.                                                                                                                                                   |
| `data.text_body` / `data.html_body` | Cuerpo de la respuesta.                                                                                                                                                               |
| `data.attachments[]`                | Adjuntos del reply. Cada uno incluye `filename`, `content_type`, `size`, `storage_key` y `download_url` firmada con **TTL 7 días**. Descarga y persiste si necesitas retención mayor. |
| `data.return_path_parsed`           | `{ project_id, activity_id }` si la dirección respondida referencia directamente el envío original; `null` en caso contrario.                                                         |
| `data.original_outbound`            | Referencia al email original que disparó la conversación: `message_id`, `activity_id`, `campaign_id`, `email_type`, `thread_id` (`null` si no se pudo resolver).                      |

### Límite de tamaño

**150 KB total por reply** (incluyendo adjuntos en MIME base64). Replies que lo excedan rebotan con `"Message length exceeds limit set by recipient"`. Workaround: pide al contacto que comparta archivos grandes vía link Drive/WeTransfer.

## Retry y respuesta esperada

| Status                        | Comportamiento                                                                                                       |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `200`–`299`                   | Éxito. RQE marca el dispatch como exitoso.                                                                           |
| Otro / timeout / error de red | RQE reintenta con backoff exponencial (30s → 1m → 2m → 4m). Después de **5 intentos** fallidos, no se reintenta más. |

* **Timeout:** 10 segundos por intento.
* **Intentos (eventos outbound):** 5 en total — 1 inicial + 4 reintentos con backoff exponencial.
* **`email.inbound`:** un solo intento, **sin reintentos**.
* **Idempotencia:** los retries pueden re-entregar el mismo evento; si entregas a ambas URLs y solo una falla, el reintento re-entrega a **las dos**. Usa el par `(event, data.activity_id, data.event_timestamp)` para deduplicar lado-cliente.

## Próximos pasos

* [Tracking](/concepts/tracking) — qué eventos disparan webhook y cómo se generan.
* [Test mode](/concepts/test-mode) — separar tráfico dev/prod vía `sk_test_*`.
* [Webhook environments](/concepts/webhook-environments) — N URLs por proyecto vía campo `environment`.
* [Send email](/api-reference/send-email) — cómo disparar emails que generen estos eventos.
