Guía práctica
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 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.
- 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.
{
"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 para obtener el mensaje analizado:
{
"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, Listar adjuntos u Obtener el MIME en bruto para el código fuente original.
Crear el webhook
-
Añade el webhook. Ve a Email APIWebhooks, selecciona Add webhook, introduce un nombre y la URL de tu endpoint, y selecciona Create.
-
Copia el secreto. El cuadro de diálogo muestra una sola vez el secreto del webhook (
whsec_…). Guárdalo comoEMAILIT_WEBHOOK_SECRETen el entorno de tu aplicación. -
Suscríbete solo a email.received. En la pestaña Settings del webhook, desactiva All events, selecciona
email.receiveden Emails y selecciona Save.
Llama a Crear un webhook con la lista de eventos. La respuesta incluye el secret.
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 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, recorre el array, omite todo lo que no sea email.received o que ya se haya procesado, y obtiene cada mensaje.
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);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
$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 de2xxo una redirección cuentan como fallo, y todo el lote se reintenta según el calendario de reintentos. Si obtener y procesar los mensajes puede tardar más, guarda los eventos en una cola, devuelve200y 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 cadaevent_iddespué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
2xxaunque 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.
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:
const match = email.to.match(/^reply\+(\d+)@inbound\.acme\.com$/i);
if (match) {
await addReplyToTicket(match[1], email.body.text);
}Comprobar que funciona
- Envía un mensaje a una dirección de tu subdominio de entrada.
- En la pestaña Requests del webhook, la petición
email.receivedmuestra Delivered. - 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.