# Procesar emails entrantes con webhooks

> Suscribe un webhook a email.received, verifica cada petición y obtén con la API el cuerpo, las cabeceras y los adjuntos de cada mensaje recibido.

Esta guía explica cómo reaccionar en tu propio código a los emails recibidos. Emailit avisa a tu endpoint con un evento `email.received`, y tu código obtiene el mensaje completo con la API. Los ejemplos verifican la firma, gestionan lotes de eventos, descargan los adjuntos y omiten los duplicados.

## Antes de empezar

- [Los emails entrantes están configurados](/es/docs/inbound/set-up/) y un mensaje de prueba aparece en la pestaña **Incoming**.
- Una clave de API con **Full Access**. Las claves Sending Only no pueden leer el contenido de los emails. Consulta [Claves de API](/es/docs/developers/api-keys/).
- Un endpoint HTTPS público que acepte peticiones `POST`. Para el desarrollo local, usa un túnel como ngrok o Cloudflare Tunnel.

## Cómo funciona el flujo

El webhook te avisa de que ha llegado un mensaje. No contiene el cuerpo ni los adjuntos, lo que mantiene pequeñas las peticiones y te permite obtener el contenido solo cuando lo necesitas.

```json title="email.received (un evento del array de la petición)"
{
  "event_id": "evt_2xGk9Tb4QmF6wN2cJpR7uZsE1kD",
  "type": "email.received",
  "object": {
    "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
    "object": "email",
    "from": "ada@example.com",
    "to": "support@inbound.acme.com",
    "subject": "Question about order 1042",
    "created_at": "2026-10-01T09:14:05.317000+00:00"
  },
  "data": {
    "object": {
      "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
      "object": "email",
      "from": "ada@example.com",
      "to": "support@inbound.acme.com",
      "subject": "Question about order 1042",
      "created_at": "2026-10-01T09:14:05.317000+00:00"
    }
  }
}
```

Usa `data.object.id` con [Obtener un email](/es/docs/api-reference/emails/get/) para obtener el mensaje analizado:

```json title="GET /v2/emails/em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA (abreviado)"
{
  "object": "email",
  "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
  "type": "inbound",
  "status": "received",
  "from": "ada@example.com",
  "to": "support@inbound.acme.com",
  "subject": "Question about order 1042",
  "message_id": "<CAF3x1@mail.example.com>",
  "headers": {
    "From": "Ada Lovelace <ada@example.com>",
    "Reply-To": "ada@example.com",
    "Date": "Thu, 01 Oct 2026 09:14:02 +0000"
  },
  "body": {
    "text": "Hi, my order hasn't arrived yet...",
    "html": "<p>Hi, my order hasn't arrived yet...</p>"
  },
  "attachments": [
    {
      "filename": "receipt.pdf",
      "content_type": "application/pdf",
      "size": 48213,
      "content_id": null,
      "content_disposition": "attachment",
      "content": "JVBERi0xLjcKJcfsj6IK..."
    }
  ]
}
```

El `content` de los adjuntos está codificado en Base64. Los nombres de las cabeceras conservan sus mayúsculas y minúsculas originales y, cuando una cabecera aparece más de una vez (como `Received`), solo se conserva el último valor. Si solo necesitas una parte del mensaje, usa los endpoints más específicos: [Obtener el cuerpo](/es/docs/api-reference/emails/body/), [Listar adjuntos](/es/docs/api-reference/emails/attachments/) u [Obtener el MIME en bruto](/es/docs/api-reference/emails/raw/) para el código fuente original.

## Crear el webhook

**Panel**

  1. **Añade el webhook.** Ve a **Email API → Webhooks**, selecciona **Add webhook**, introduce un nombre y la URL de tu endpoint, y selecciona **Create**.

  2. **Copia el secreto.** El cuadro de diálogo muestra una sola vez el secreto del webhook (`whsec_…`). Guárdalo como `EMAILIT_WEBHOOK_SECRET` en el entorno de tu aplicación.

  3. **Suscríbete solo a email.received.** En la pestaña **Settings** del webhook, desactiva **All events**, selecciona `email.received` en **Emails** y selecciona **Save**.

**API**

  Llama a [Crear un webhook](/es/docs/api-reference/webhooks/create/) con la lista de eventos. La respuesta incluye el `secret`.

```bash
curl https://api.emailit.com/v2/webhooks \
  -H "Authorization: Bearer $EMAILIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Inbound mail",
    "url": "https://acme.com/webhooks/emailit",
    "events": ["email.received"]
  }'
```

En los planes Pro, Business y Custom puedes añadir un [filtro de payload](/es/docs/webhooks/set-up/#filter-events-by-payload) para que el webhook solo reciba el correo de algunas direcciones, por ejemplo `to` termina en `@inbound.acme.com`.

## Escribir el gestor

El cuerpo de cada petición es un array JSON de hasta 100 eventos. El gestor siguiente verifica la [firma](/es/docs/webhooks/request-signature/), recorre el array, omite todo lo que no sea `email.received` o que ya se haya procesado, y obtiene cada mensaje.

**Node.js**

```javascript title="server.js"
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // whsec_...
const API_KEY = process.env.EMAILIT_API_KEY; // secret_..., Full Access
const processed = new Set(); // use a database table with a unique key in production

function verify(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= 300)) return false; // 5-minute tolerance
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function fetchEmail(id) {
  const res = await fetch(`https://api.emailit.com/v2/emails/${id}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`Emailit API returned ${res.status}`);
  return res.json();
}

async function handleReceived(event) {
  const email = await fetchEmail(event.data.object.id);
  console.log(`From ${email.from} to ${email.to}: ${email.subject}`);
  console.log(email.body.text ?? email.body.html);

  for (const file of email.attachments ?? []) {
    const bytes = Buffer.from(file.content, 'base64');
    console.log(`Attachment ${file.filename} (${file.content_type}, ${bytes.length} bytes)`);
  }
}

// Keep the raw body: the signature is computed over the exact bytes.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verify(req.body, req.get('X-Emailit-Signature'), req.get('X-Emailit-Timestamp'))) {
    return res.status(401).send('Invalid signature');
  }

  const events = JSON.parse(req.body.toString('utf8'));
  try {
    for (const event of events) {
      if (event.type !== 'email.received' || processed.has(event.event_id)) continue;
      await handleReceived(event);
      processed.add(event.event_id);
    }
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // Emailit retries the whole batch later
  }
});

app.listen(3000);
```

**Python**

```python title="app.py"
import base64
import hashlib
import hmac
import json
import os
import time

import requests
from flask import Flask, abort, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()  # whsec_...
API_KEY = os.environ["EMAILIT_API_KEY"]  # secret_..., Full Access
processed = set()  # use a database table with a unique key in production

def verify(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
    if not signature or not timestamp:
        return False
    try:
        if abs(time.time() - int(timestamp)) > 300:  # 5-minute tolerance
            return False
    except ValueError:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

def fetch_email(email_id: str) -> dict:
    response = requests.get(
        f"https://api.emailit.com/v2/emails/{email_id}",
        headers={"Authorization": f"Bearer {API_KEY}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

def handle_received(event: dict) -> None:
    email = fetch_email(event["data"]["object"]["id"])
    print(f"From {email['from']} to {email['to']}: {email['subject']}")
    print(email["body"]["text"] or email["body"]["html"])

    for attachment in email.get("attachments") or []:
        data = base64.b64decode(attachment["content"])
        print(f"Attachment {attachment['filename']} ({len(data)} bytes)")

@app.post("/webhooks/emailit")
def emailit_webhook():
    raw_body = request.get_data()  # raw bytes, before any JSON parsing
    if not verify(
        raw_body,
        request.headers.get("X-Emailit-Signature"),
        request.headers.get("X-Emailit-Timestamp"),
    ):
        abort(401)

    for event in json.loads(raw_body):
        if event["type"] != "email.received" or event["event_id"] in processed:
            continue
        handle_received(event)  # an exception returns 500 and Emailit retries
        processed.add(event["event_id"])

    return "", 200
```

**PHP**

```php title="webhook.php"
<?php
$webhookSecret = getenv('EMAILIT_WEBHOOK_SECRET'); // whsec_...
$apiKey = getenv('EMAILIT_API_KEY'); // secret_..., Full Access

// Dedupe table: CREATE TABLE processed_events (event_id TEXT PRIMARY KEY)
$db = new PDO('sqlite:' . __DIR__ . '/events.sqlite');

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300;
if (!$fresh || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

function fetchEmail(string $id, string $apiKey): array
{
    $ch = curl_init('https://api.emailit.com/v2/emails/' . rawurlencode($id));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
        CURLOPT_TIMEOUT => 10,
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status !== 200) {
        throw new RuntimeException("Emailit API returned {$status}");
    }
    return json_decode($body, true);
}

try {
    foreach (json_decode($rawBody, true) as $event) {
        if ($event['type'] !== 'email.received') {
            continue;
        }
        $seen = $db->prepare('SELECT 1 FROM processed_events WHERE event_id = ?');
        $seen->execute([$event['event_id']]);
        if ($seen->fetchColumn()) {
            continue;
        }

        $email = fetchEmail($event['data']['object']['id'], $apiKey);
        error_log("From {$email['from']} to {$email['to']}: {$email['subject']}");
        foreach ($email['attachments'] ?? [] as $attachment) {
            $bytes = base64_decode($attachment['content']);
            file_put_contents(sys_get_temp_dir() . '/' . basename($attachment['filename']), $bytes);
        }

        $db->prepare('INSERT INTO processed_events (event_id) VALUES (?)')
           ->execute([$event['event_id']]);
    }
    http_response_code(200);
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500); // Emailit retries the whole batch later
}
```

## Gestionar los reintentos y los duplicados

- **Responde en menos de 30 segundos.** Emailit espera hasta 30 segundos una respuesta `2xx`. Un tiempo de espera agotado, un estado distinto de `2xx` o una redirección cuentan como fallo, y todo el lote se reintenta según el [calendario de reintentos](/es/docs/webhooks/retries-and-failures/). Si obtener y procesar los mensajes puede tardar más, guarda los eventos en una cola, devuelve `200` y procésalos en una tarea en segundo plano.
- **Elimina los duplicados por `event_id`.** Un lote reintentado vuelve a contener todos sus eventos, incluidos los que ya gestionaste antes del fallo. Registra cada `event_id` después de procesarlo y omite los ID que ya hayas visto. Cada mensaje recibido tiene además su propio ID de email, que puedes usar como segunda clave.
- **Confirma los eventos que no gestionas.** Devuelve `2xx` aunque un lote solo contenga tipos de eventos que ignoras. Si no, Emailit los sigue reintentando.
- **Obtén el contenido sin demora.** El contenido de los mensajes se conserva durante un tiempo limitado que depende de tu plan (7 días en Pay as you go). Después, la API devuelve el email sin su cuerpo ni sus adjuntos. Consulta [Retención de datos](/es/docs/data-retention/).

## Enrutar los mensajes por dirección

Como se acepta cualquier parte local, puedes codificar información en la dirección y leerla después en `to`. Por ejemplo, envía las notificaciones con `reply_to` igual a `reply+4821@inbound.acme.com` y después dirige las respuestas a la incidencia 4821:

```javascript
const match = email.to.match(/^reply\+(\d+)@inbound\.acme\.com$/i);
if (match) {
  await addReplyToTicket(match[1], email.body.text);
}
```

## Comprobar que funciona

1. Envía un mensaje a una dirección de tu subdominio de entrada.
2. En la pestaña **Requests** del webhook, la petición `email.received` muestra **Delivered**.
3. Tu aplicación registra el remitente, el asunto y los adjuntos que haya.

Si la petición muestra **Attempting** o **Failed**, selecciona **View** en una fila fallida para ver el código de estado y el cuerpo de la respuesta que ha devuelto tu endpoint. Consulta [Reintentos y fallos](/es/docs/webhooks/retries-and-failures/).

## Ver también

  - [Verificar las firmas de los webhooks](/es/docs/webhooks/request-signature/)
  - [Formato de las peticiones de webhook](/es/docs/webhooks/webhook-requests/)
  - [Reenviar con automatizaciones](/es/docs/inbound/forward-with-automations/)
  - [Obtener un email](/es/docs/api-reference/emails/get/)

---
Fuente: https://emailit.com/es/docs/inbound/process-with-webhooks/
