Developers

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

EventDescription
contact.createdContact inserted (manual, import, API, or double opt-in confirmed).
contact.confirmedContact clicked the double-opt-in confirmation link. Status flipped from pending_double_opt_in to active. Includes list_id.
contact.unsubscribedContact clicked the one-click List-Unsubscribe (RFC 8058) link. Auto-added to suppressions for this org.
email.sentMessage handed to SES (MessageId assigned). First event for every send.
email.deliveredSES confirmed delivery at the recipient's MTA. Not an open.
email.openedRecipient loaded the tracking pixel. May fire multiple times per message — dedupe by message_id if you only want first-open.
email.clickedRecipient clicked a tracked link. Payload includes link_url.
email.bouncedHard or soft bounce reported by SES. Hard bounces auto-suppress the contact.
email.complainedRecipient marked the email as spam. Contact auto-unsubscribed.
email.failedTransactional send rejected by SES (validation, throttling, etc). Not retried on this path.
webhook.test_pingSynthetic 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.

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": "noreply@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.