Eventos
Cada tipo de evento se dispara una vez por cambio de estado. Suscríbete a cualquier subconjunto vía enabled_events (vacío = todos).
| Event | Description |
|---|---|
| contact.created | Contact inserted (manual, import, API, or double opt-in confirmed). |
| contact.confirmed | Contact clicked the double-opt-in confirmation link. Status flipped from pending_double_opt_in to active. Includes list_id. |
| contact.unsubscribed | Contact clicked the one-click List-Unsubscribe (RFC 8058) link. Auto-added to suppressions for this org. |
| email.sent | Message handed to SES (MessageId assigned). First event for every send. |
| email.delivered | SES confirmed delivery at the recipient's MTA. Not an open. |
| email.opened | Recipient loaded the tracking pixel. May fire multiple times per message — dedupe by message_id if you only want first-open. |
| email.clicked | Recipient clicked a tracked link. Payload includes link_url. |
| email.bounced | Hard or soft bounce reported by SES. Hard bounces auto-suppress the contact. |
| email.complained | Recipient marked the email as spam. Contact auto-unsubscribed. |
| email.failed | Transactional send rejected by SES (validation, throttling, etc). Not retried on this path. |
| webhook.test_ping | Synthetic event you fire from the dashboard ("Send test event"). Useful to verify your receiver before going live. |
Request headers
mailmundo-signature: t=<unix>,v1=<hex> mailmundo-event-id: <uuid> mailmundo-event-type: contact.created mailmundo-delivery-attempt: 1 user-agent: Mailmundo-Webhook/1.0 content-type: application/json
Formato del payload
Cada entrega tiene el mismo sobre. data varía según el evento.
{
"event_type": "contact.created",
"occurred_at": "2026-05-17T22:40:38.828591+00:00",
"data": {
"id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3",
"project_id": "00000000-0000-4000-8000-000000000102",
"email": "luciano@example.com",
"status": "active",
"locale": "pt-br",
"source": "api",
"first_name": "Luciano",
"last_name": null,
"attributes": { "tier": "premium" },
"external_id": "crm-1234"
}
}{
"event_type": "contact.confirmed",
"occurred_at": "2026-06-01T10:14:02.118Z",
"data": {
"contact_id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3",
"list_id": "a83d8e8e-66cd-4a4a-9d76-d10d5e5cd2a1",
"email": "luciano@example.com"
}
}{
"event_type": "contact.unsubscribed",
"occurred_at": "2026-06-02T18:22:51.044Z",
"data": {
"contact_id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3",
"email": "luciano@example.com",
"source": "user_link"
}
}{
"event_type": "email.sent",
"occurred_at": "2026-05-28T14:02:17.412Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"campaign_id": "9b1d4d63-2ad5-49ad-8c25-7b1d4f24e7d2",
"contact_id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3",
"to_email": "luciano@example.com",
"from_email": "noreply@yourbrand.mailmundo.email",
"subject": "Spring deep clean — 15% off this week",
"channel": "email"
}
}{
"event_type": "email.delivered",
"occurred_at": "2026-05-28T14:02:23.118Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"to_email": "luciano@example.com",
"smtp_response": "250 2.0.0 OK"
}
}{
"event_type": "email.opened",
"occurred_at": "2026-05-28T14:08:42.118Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"to_email": "luciano@example.com",
"contact_id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3"
}
}{
"event_type": "email.clicked",
"occurred_at": "2026-05-28T14:09:11.882Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"to_email": "luciano@example.com",
"contact_id": "10136199-8cc4-4e7d-9bac-ddd52a97bbd3",
"link_url": "https://yourbrand.com/spring-promo"
}
}{
"event_type": "email.bounced",
"occurred_at": "2026-05-28T14:03:01.880Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"to_email": "bouncy@example.com",
"bounce_type": "Permanent",
"bounce_sub_type": "General",
"diagnostic": "smtp; 550 5.1.1 The email account that you tried to reach does not exist."
}
}{
"event_type": "email.complained",
"occurred_at": "2026-05-28T14:05:44.211Z",
"data": {
"message_id": "ses-010101861a3d0cef-3b06b3a8-cafe",
"to_email": "annoyed@example.com",
"complaint_feedback_type": "abuse"
}
}{
"event_type": "email.failed",
"occurred_at": "2026-05-28T14:02:18.711Z",
"data": {
"message_id": "internal-msg-abc",
"to_email": "bad@example.com",
"error": "SES MessageRejected: Email address is not verified.",
"source": "transactional"
}
}{
"event_type": "webhook.test_ping",
"occurred_at": "2026-06-03T19:42:18.114Z",
"data": {
"triggered_by": "operator@yourbrand.com",
"note": "Synthetic test event from the Mailmundo dashboard."
}
}Verificando la signature
Recalcula HMAC-SHA256 sobre `${timestamp}.${rawBody}` usando tu secreto de firma. Compáralo con `v1=<hex>` del encabezado mailmundo-signature. Recházalo si el timestamp es más viejo que 5 minutos (protección contra reenvío).
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.MAILMUNDO_WEBHOOK_SECRET!;
// IMPORTANT: receive the raw body — not parsed JSON.
// Express needs the bodyParser.raw middleware for this route.
app.post("/webhooks/mailmundo",
express.raw({ type: "application/json" }),
(req, res) => {
const sigHeader = req.header("mailmundo-signature");
if (!sigHeader) return res.status(400).send("missing signature");
const match = sigHeader.match(/^t=(\d+),v1=([a-f0-9]+)$/);
if (!match) return res.status(400).send("malformed signature");
const [, timestamp, hexSig] = match;
// Replay protection: reject events older than 5 minutes.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return res.status(400).send("timestamp too old");
}
const rawBody = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Timing-safe comparison.
if (
hexSig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(hexSig), Buffer.from(expected))
) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody);
// Idempotency: use mailmundo-event-id to dedupe.
const eventId = req.header("mailmundo-event-id");
// ... handle the event ...
res.status(200).send("ok");
},
);import hmac, hashlib, time, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["MAILMUNDO_WEBHOOK_SECRET"]
@app.post("/webhooks/mailmundo")
def mailmundo_webhook():
sig_header = request.headers.get("mailmundo-signature", "")
parts = dict(x.split("=", 1) for x in sig_header.split(",") if "=" in x)
timestamp = parts.get("t")
hex_sig = parts.get("v1")
if not timestamp or not hex_sig:
return "missing signature", 400
if abs(time.time() - int(timestamp)) > 300:
return "timestamp too old", 400
raw = request.get_data(as_text=True)
expected = hmac.new(
SECRET.encode("utf-8"),
f"{timestamp}.{raw}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(hex_sig, expected):
return "invalid signature", 401
event = request.get_json()
# Idempotency: dedupe via mailmundo-event-id
return "ok", 200package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = os.Getenv("MAILMUNDO_WEBHOOK_SECRET")
func handleWebhook(w http.ResponseWriter, r *http.Request) {
sigHeader := r.Header.Get("mailmundo-signature")
var ts, hexSig string
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 { continue }
if kv[0] == "t" { ts = kv[1] }
if kv[0] == "v1" { hexSig = kv[1] }
}
if ts == "" || hexSig == "" {
http.Error(w, "missing signature", 400); return
}
tsInt, _ := strconv.ParseInt(ts, 10, 64)
if abs(time.Now().Unix() - tsInt) > 300 {
http.Error(w, "timestamp too old", 400); return
}
raw, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(raw)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(hexSig), []byte(expected)) {
http.Error(w, "invalid signature", 401); return
}
// Idempotency: r.Header.Get("mailmundo-event-id")
w.WriteHeader(200)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }Comportamiento de retry
Mailmundo reintenta las respuestas distintas de 2xx con retroceso exponencial: 30s, 2m, 10m, 1h, 6h, 24h. Después de 6 intentos fallidos, la entrega va a dead_letter (visible en /app/webhooks). Una respuesta 2xx = éxito; la idempotencia es tu responsabilidad — usa el encabezado mailmundo-event-id para deduplicar.