Webhooks
Recepción de eventos SES, Shopify e inbound replies.
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 proyectoNota: 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 clickNota: 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)
{
"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 correspondienteTipos 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:
-
Obtener correo desde S3 — Descarga el archivo MIME completo del bucket S3.
-
Parsear correo — Extrae: remitente, destinatario, asunto, cuerpo (texto plano y HTML), adjuntos y headers.
-
Resolver hilo — Identifica el proyecto y la actividad asociada. La resolucion de hilos sigue una jerarquia de metodos, en orden de prioridad:
- Reply token (formato actual):
"Nombre Sender" <r-{token}@rqe.inbound.reallyquickemails.com>— Token corto de 8 caracteres mapeado en Redis al proyecto y actividad. - Return-Path legacy:
reply+{projectId}-{activityId}@rqe.inbound.reallyquickemails.com— Formato anterior, soportado para compatibilidad. - Header In-Reply-To — Coincide el Message-ID del correo original.
- Header References — Busca en la cadena de Message-IDs referenciados.
- Asunto normalizado — Ultimo recurso, coincide por asunto (sin prefijos Re:/Fwd:).
- Reply token (formato actual):
-
Subir adjuntos — Si el correo contiene adjuntos, se suben a Supabase Storage.
-
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)
{
"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)
{
"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_*osk_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,activityconis_test=true) para inspección manual, pero RQE no hace fallback alwebhook_urlde live para evitar contaminar tu sistema productivo con tráfico de pruebas. - Mismo principio para inbound: si
inbound_webhook_url_devestá vacío y el email original fue enviado consk_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 → POSTTest 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:
{
"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 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.comCuando 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:
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
{
"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_logscon: 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. |