# Webhooks

ReallyQuickEmails recibe notificaciones externas de AWS SES (entrega, rebotes, quejas, emails entrantes) y de Shopify (carritos, checkouts, ordenes).

**Base URL:** `https://api.reallyquickemails.com`

***

## POST /webhooks/ses

Recibe notificaciones de entrega, rebote y quejas desde AWS SES via SNS. Estos webhooks actualizan automaticamente el estado de cada email enviado.

### Autenticacion

Ninguna. La autenticidad se valida verificando la firma del mensaje SNS.

### Eventos procesados

| Evento SES      | Evento en DB     | Descripcion                                  | Efecto en activity                                     |
| --------------- | ---------------- | -------------------------------------------- | ------------------------------------------------------ |
| `Send`          | `sent`           | Email aceptado por SES                       | —                                                      |
| `Delivery`      | `delivered`      | Email entregado al servidor del destinatario | `current_status` → `delivered`, setea `delivered_at`   |
| `Bounce`        | `bounce`         | Email rebotado (hard o soft bounce)          | `current_status` → `bounced`, setea `bounced_at`       |
| `Complaint`     | `complaint`      | Destinatario marco como spam                 | `current_status` → `complained`, setea `complained_at` |
| `Reject`        | `reject`         | SES rechazo el envio                         | —                                                      |
| `DeliveryDelay` | `delivery_delay` | Retraso temporal en la entrega               | —                                                      |

### Flujo

```
AWS SES envia email (con ConfigurationSet: reallyquickemails-events)
  → Evento de entrega/rebote/queja
    → AWS SNS (topic: ses-email-events) notifica
      → POST /webhooks/ses
        → Parsea body text/plain como JSON
          → Verifica firma SNS
            → Encola en queue ses-webhook (20 concurrentes)
              → Inserta en email_events
              → Actualiza activity (status + timestamps)
              → Despacha webhook firmado al proyecto
```

> **Nota:** SNS envia el body como `Content-Type: text/plain`. El servidor parsea este formato automaticamente antes de procesarlo.

### Open y Click Tracking (Self-hosted)

El tracking de aperturas y clics se maneja mediante endpoints propios, sin costo adicional:

| Evento  | Mecanismo                          | Endpoint                       | Efecto en activity                             |
| ------- | ---------------------------------- | ------------------------------ | ---------------------------------------------- |
| `open`  | Pixel 1x1 GIF inyectado en el HTML | `GET /t/o/:activityId`         | Setea `opened_first_at` (solo la primera vez)  |
| `click` | Links reescritos con redirect      | `GET /t/c/:activityId?url=...` | Setea `clicked_first_at` (solo la primera vez) |

```
Email HTML contiene:
  → Pixel: <img src="https://api.reallyquickemails.com/t/o/{activityId}" />
  → Links: href="https://api.reallyquickemails.com/t/c/{activityId}?url={urlOriginal}"

Cuando el destinatario:
  → Abre el email → su cliente carga el pixel → registra open
  → Hace clic en un link → pasa por /t/c/ → redirect 302 al link original → registra click
```

> **Nota:** El tracking de aperturas depende de que el cliente de email cargue imagenes externas. Algunos clientes (como Outlook desktop) bloquean imagenes por defecto. El tracking de clics es confiable en \~99% de los casos.

### Correlacion con emails enviados

Cada email enviado tiene un `activity_id` (retornado en la respuesta de send-email y send-batch). Los webhooks de SES y los endpoints de tracking actualizan el registro de actividad correspondiente, permitiendo rastrear el estado de cada email:

* `queued` → `sent` → `delivered` → `opened` → `clicked` (entrega exitosa con engagement)
* `queued` → `sent` → `bounced` (rebote)
* `queued` → `sent` → `complained` (marcado como spam)

***

## POST /webhooks/shopify/:topic

Recibe webhooks de Shopify para atribucion de ingresos y automatizaciones de carritos abandonados.

### Autenticacion

Verificacion HMAC (si `SHOPIFY_API_SECRET` esta configurado). Deduplicacion automatica via `x-shopify-webhook-id` en Redis.

### Headers requeridos

| Header                  | Descripcion                               |
| ----------------------- | ----------------------------------------- |
| `x-project-id`          | ID del proyecto en ReallyQuickEmails      |
| `x-shopify-hmac-sha256` | Firma HMAC del webhook                    |
| `x-shopify-webhook-id`  | ID unico del webhook (para deduplicacion) |
| `x-shopify-shop-domain` | Dominio de la tienda Shopify              |

### Topics soportados

| Topic             | Queue de procesamiento     | Descripcion                                                       |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| `cart/update`     | `process-shopify-cart`     | Carrito actualizado — para automatizaciones de carrito abandonado |
| `checkout/create` | `process-shopify-checkout` | Checkout iniciado — captura datos de pago                         |
| `order/create`    | `process-shopify-order`    | Orden completada — atribucion de ingresos a campanas              |

### Respuesta exitosa (200)

```json
{
  "success": true,
  "message": "Webhook processed"
}
```

### Codigos de Error

| Codigo | Descripcion                           |
| ------ | ------------------------------------- |
| `400`  | Payload invalido o topic no soportado |
| `401`  | Firma HMAC invalida                   |
| `409`  | Webhook duplicado (ya procesado)      |
| `500`  | Error interno                         |

***

## POST /api/inbound/ses

Recibe notificaciones de correos electronicos entrantes a traves de AWS SNS. Permite procesar respuestas a tus correos y mantener hilos de conversacion.

### Autenticacion

Ninguna. La autenticidad se valida verificando la firma del mensaje SNS.

### Flujo de Correo Entrante

```
Remitente externo
  → AWS SES (recepcion)
    → Regla de recepcion SES (almacena en S3)
      → AWS SNS (notificacion)
        → POST /api/inbound/ses (este endpoint)
          → Procesa, almacena y asocia al hilo correspondiente
```

### Tipos de Mensaje SNS

| Tipo de Mensaje            | Descripcion                                                                                             |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `SubscriptionConfirmation` | Solicitud de confirmacion de suscripcion al topico SNS. Se confirma automaticamente.                    |
| `Notification`             | Notificacion de un correo entrante. Contiene la referencia al objeto en S3 con el correo MIME completo. |
| `UnsubscribeConfirmation`  | Confirmacion de cancelacion de suscripcion al topico SNS.                                               |

### Procesamiento de Notificaciones

Cuando se recibe una notificacion de tipo `Notification`:

1. **Obtener correo desde S3** — Descarga el archivo MIME completo del bucket S3.
2. **Parsear correo** — Extrae: remitente, destinatario, asunto, cuerpo (texto plano y HTML), adjuntos y headers.
3. **Resolver hilo** — Identifica el proyecto y la actividad asociada. La resolucion de hilos sigue una jerarquia de metodos, en orden de prioridad:
   1. **Reply token** (formato actual): `"Nombre Sender" <r-{token}@rqe.inbound.reallyquickemails.com>` — Token corto de 8 caracteres mapeado en Redis al proyecto y actividad.
   2. **Return-Path legacy**: `reply+{projectId}-{activityId}@rqe.inbound.reallyquickemails.com` — Formato anterior, soportado para compatibilidad.
   3. **Header In-Reply-To** — Coincide el Message-ID del correo original.
   4. **Header References** — Busca en la cadena de Message-IDs referenciados.
   5. **Asunto normalizado** — Ultimo recurso, coincide por asunto (sin prefijos Re:/Fwd:).
4. **Subir adjuntos** — Si el correo contiene adjuntos, se suben a Supabase Storage.
5. **Almacenar mensaje** — Guarda el mensaje en la base de datos, asociado al proyecto y hilo.

### Headers Relevantes

| Header                   | Descripcion                                      |
| ------------------------ | ------------------------------------------------ |
| `x-amz-sns-message-type` | Tipo de mensaje SNS (ver tabla de tipos arriba). |
| `x-amz-sns-message-id`   | ID unico del mensaje SNS.                        |
| `x-amz-sns-topic-arn`    | ARN del topico SNS de origen.                    |

### Ejemplo de Payload (Notification)

```json
{
  "Type": "Notification",
  "MessageId": "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324",
  "TopicArn": "arn:aws:sns:us-east-1:123456789012:ses-inbound-emails",
  "Subject": "Amazon SES Email Receipt Notification",
  "Message": "{\"notificationType\":\"Received\",\"mail\":{\"source\":\"cliente@ejemplo.com\",\"destination\":[\"r-x7K9mP2q@rqe.inbound.reallyquickemails.com\"],\"messageId\":\"abc123def456\"},\"receipt\":{\"action\":{\"type\":\"S3\",\"bucketName\":\"rqe-inbound-emails\",\"objectKey\":\"emails/abc123def456\"}}}"
}
```

### Respuesta exitosa (200)

```json
{
  "success": true,
  "messageId": "abc123def456",
  "threadId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "projectId": "d4e5f6a7-b8c9-0123-def4-567890abcdef"
}
```

### Codigos de Error

| Codigo | Descripcion                                                     |
| ------ | --------------------------------------------------------------- |
| `200`  | Procesamiento exitoso o confirmacion de suscripcion completada. |
| `400`  | Payload invalido o no se pudo parsear el mensaje SNS.           |
| `500`  | Error interno al procesar el correo entrante.                   |

***

## Live vs Test routing

Cada proyecto tiene **cuatro URLs de webhook configurables**, agrupadas en dos pares (outbound y inbound), y RQE elige a cuál disparar segun el prefijo de la API Key con la que se envió el email.

| Tipo de webhook                                | URL para Live         | URL para Test             | Disparado por                        |
| ---------------------------------------------- | --------------------- | ------------------------- | ------------------------------------ |
| Outbound (delivered, bounced, opened, clicked) | `webhook_url`         | `webhook_url_dev`         | Eventos SES + tracking propio        |
| Inbound (replies con adjuntos)                 | `inbound_webhook_url` | `inbound_webhook_url_dev` | Respuestas a emails enviados por API |

### Cómo se decide el modo

El modo se determina **únicamente** por el prefijo de la API Key con la que se hizo el `POST /v1/send-email` (o `send-batch` / `send-template-email`):

* **Live** (`sk_proj_*` o `sk_live_*`) → eventos van a las URLs sin sufijo (`webhook_url`, `inbound_webhook_url`).
* **Test** (`sk_test_*`) → eventos van a las URLs con sufijo `_dev`.

No existe un parámetro `mode` en el body ni un header especial — el modo viaja en la key.

### Comportamiento si la URL `_dev` está vacía

Si tu proyecto **no tiene** `webhook_url_dev` configurada y enviás con `sk_test_*`:

* Los eventos outbound de ese envío **no se entregan a ningún webhook**. Quedan registrados en la base de datos (tabla `email_events`, `activity` con `is_test=true`) para inspección manual, pero RQE **no** hace fallback al `webhook_url` de live para evitar contaminar tu sistema productivo con tráfico de pruebas.
* Mismo principio para inbound: si `inbound_webhook_url_dev` está vacío y el email original fue enviado con `sk_test_*`, la respuesta del cliente queda almacenada pero no se reenvía.

### Configuración

En la app: **Project Settings → Integraciones → Webhooks**. Verás cuatro inputs separados (`Producción`, `Producción - inbound`, `Test`, `Test - inbound`), cada uno con su propio botón de Guardar.

### Ejemplo end-to-end

```
1. Tu app envía email con sk_test_xyz
   POST /v1/send-email con Authorization: Bearer sk_test_xyz
   →  apiKeyAuth setea req.isTestMode = true
   →  emailSend.processor marca activity.is_test = true, skip counters
   →  SES envía el email real

2. Destinatario abre el email
   →  pixel hit → tracking.processor → email_events INSERT
   →  webhook dispatcher: lee project.webhook_url_dev → POST a esa URL
   →  payload incluye is_test: true para que tu sistema lo identifique

3. Destinatario responde con un adjunto
   →  SES inbound captura el reply
   →  RQE resuelve el reply token al activity_id original
   →  Como el original fue is_test=true: lee project.inbound_webhook_url_dev → POST
```

### Test mode en el payload del webhook

Todos los webhooks (outbound e inbound) incluyen `is_test: boolean` en el body para que tu sistema pueda identificar el origen sin tener que mantener URLs separadas si no quieres:

```json
{
  "event": "email.delivered",
  "is_test": true,
  "data": { "...": "..." }
}
```

Útil si preferís recibir todo en una sola URL (`webhook_url`) y filtrar en tu lado, dejando `webhook_url_dev` vacío. Pero **mantenerlas separadas es lo recomendado** para evitar accidentes.

***

## Inbound Webhook (Forward de respuestas)

Cuando un cliente externo (sistema integrador, ej. Nexor) configura una `inbound_webhook_url` en su proyecto, RQE hace POST automatico a esa URL cada vez que un destinatario responde a un email enviado via API. Permite continuar conversaciones de forma programatica desde el sistema externo.

### Configuracion

En la app: **Project Settings → Integraciones → Webhook de respuestas** → pegar URL → Guardar. Para tráfico generado con `sk_test_*` configurá el campo de **Webhook de respuestas (test)** — ver [Live vs Test routing](#live-vs-test-routing) arriba.

Internamente se persiste en `projects.inbound_webhook_url` y `projects.inbound_webhook_url_dev`. Si el campo correspondiente está vacío, el reply queda almacenado en DB pero no se reenvía (no hay fallback cross-mode para evitar contaminar live con tráfico de test).

### Cuando dispara

Solo cuando el email outbound fue enviado **via API** (`POST /send-email` con `Authorization: Bearer sk_proj_...`, sin header `x-source`). En ese caso el Reply-To del email lleva un token unico:

```
Reply-To: r-{token}@rqe.inbound.reallyquickemails.com
```

Cuando el destinatario responde, SES inbound captura el reply, RQE resuelve el token al proyecto + actividad original, y dispara el webhook.

**No dispara** para envios desde la UI (campanas, automatizaciones, "enviar prueba") — esos usan `source=platform` y el Reply-To apunta al sender humano para que reciba en su inbox.

### Endpoint que recibe el cliente

El cliente expone un endpoint HTTP POST (cualquier URL publica) y lo configura en RQE. RQE hace POST con:

#### Headers

```
Content-Type: application/json
User-Agent: ReallyQuickEmails-Webhook/1.0
X-RQE-Signature: sha256={hmac_hex}
```

`X-RQE-Signature` es HMAC-SHA256 del body raw, usando la `api_key` del proyecto como secreto. El cliente debe verificar la firma antes de procesar.

Ejemplo verificacion en Node:

```js
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');
}
```

#### Body

```json
{
  "event": "email.inbound",
  "timestamp": "2026-04-28T22:45:32.299Z",
  "project_id": "uuid",
  "data": {
    "message_id": "<gmail-message-id@mail.gmail.com>",
    "thread_id": "uuid",
    "in_reply_to": "<original-ses-message-id@email.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": "{message_id_sanitized}/factura.pdf",
        "download_url": "https://bjzcfsazxnommbiiqoux.supabase.co/storage/v1/object/sign/email-attachments/...?token=..."
      }
    ],
    "return_path_parsed": null,
    "original_outbound": {
      "message_id": "<original-ses-message-id@us-east-1.amazonses.com>",
      "activity_id": "uuid",
      "campaign_id": null,
      "email_type": "individual",
      "thread_id": "uuid"
    }
  }
}
```

#### Campos clave

| Campo                               | Descripcion                                                                                                                                                    |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                             | Discriminador. Siempre `"email.inbound"` para este evento. Diseno extensible a otros eventos (`email.delivered`, `email.bounced`, etc).                        |
| `data.from`                         | Quien envio la respuesta (cliente externo).                                                                                                                    |
| `data.to[]`                         | Direccion token a la que respondio (`r-{token}@rqe.inbound...`).                                                                                               |
| `data.text_body` / `data.html_body` | Cuerpo de la respuesta.                                                                                                                                        |
| `data.attachments[]`                | Adjuntos del reply. Cada uno incluye `download_url` firmada (Supabase Storage, **TTL 7 dias**). Descargar y persistir si se necesita retencion mayor.          |
| `data.original_outbound`            | Referencia al email original que disparo la conversacion (`activity_id`, `thread_id`, `campaign_id` si aplica). Permite enlazar el reply al contexto original. |

### Retry y logs

* Timeout: **10 segundos** por intento.
* Reintentos: **5 intentos** con backoff exponencial (30s → 1m → 2m → 4m → 8m).
* Todos los intentos quedan en la tabla `webhook_logs` con: status HTTP, response body (truncado a 5000 chars), duracion, error message si aplica. Visible solo via DB (no UI dashboard aun).

### Limites conocidos

* **150 KB total por reply** (incluyendo adjuntos en MIME base64). SES inbound publica el correo via SNS topic, que tiene limit hard de 150 KB. Replies que excedan rebotan con `"Message length exceeds limit set by recipient"`. Workaround: el cliente externo le pide a sus contactos que compartan archivos grandes via link Drive/WeTransfer en vez de adjuntar.
* **Solo API sends**: como se menciona arriba, sends desde UI no disparan el webhook (el Reply-To va al sender humano).

### Respuesta esperada del cliente

| Status                        | Comportamiento                                                                                                               |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `200`-`299`                   | Exito. RQE marca el dispatch como exitoso en `webhook_logs`.                                                                 |
| Otro / timeout / error de red | RQE reintenta con backoff exponencial hasta 5 veces. Despues de 5 fallos, queda registrado en logs pero no se reintenta mas. |


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reallyquickemails.gitbook.io/reallyquickemails-docs/referencia-api/webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
