Desarrolladores

Webhooks

Mailmundo hace POST de JSON firmado a tu endpoint cuando se dispara un evento suscrito. Configura endpoints en /app/webhooks o vía la API pública. Verifica la firma en cada solicitud recibida.

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).

EventoDescripción
contact.createdContacto creado (manual, importación, API o double opt-in confirmado).
contact.confirmedEl contacto hizo clic en el enlace de confirmación del double opt-in. El status pasó de pending_double_opt_in a active. Incluye list_id.
contact.unsubscribedEl contacto hizo clic en el List-Unsubscribe de un clic (RFC 8058). Se agrega automáticamente a las supresiones de la organización.
email.sentMensaje entregado a SES (con MessageId asignado). Es el primer evento de cada envío.
email.deliveredSES confirmó la entrega en el MTA del destinatario. No es una apertura.
email.openedEl destinatario cargó el píxel de seguimiento. Puede dispararse varias veces por mensaje — deduplica por message_id si solo quieres la primera apertura.
email.clickedEl destinatario hizo clic en un enlace rastreado. El payload incluye link_url.
email.bouncedBounce hard o soft reportado por SES. Un bounce hard suprime al contacto automáticamente.
email.complainedEl destinatario marcó el correo como spam. El contacto se da de baja automáticamente.
email.failedEnvío transaccional rechazado por SES (validación, throttling, etc.). En esta ruta no se reintenta.
webhook.test_pingEvento sintético que disparas desde el panel ("Enviar evento de prueba"). Sirve para validar tu receptor antes de salir a producción.

Cabeceras de la solicitud

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.

contact.created
json
{
  "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"
  }
}
contact.confirmed
json
{
  "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"
  }
}
contact.unsubscribed
json
{
  "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"
  }
}
email.sent
json
{
  "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": "hello@yourbrand.mailmundo.email",
    "subject": "Spring deep clean — 15% off this week",
    "channel": "email"
  }
}
email.delivered
json
{
  "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"
  }
}
email.opened
json
{
  "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"
  }
}
email.clicked
json
{
  "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"
  }
}
email.bounced
json
{
  "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."
  }
}
email.complained
json
{
  "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"
  }
}
email.failed
json
{
  "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"
  }
}
webhook.test_ping
json
{
  "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).

Node.js (Express)
typescript
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");
  },
);
Python (Flask)
python
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", 200
Go (net/http)
go
package 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.