Developers

Webhooks

O Mailmundo faz POST de JSON assinado para o seu endpoint sempre que um evento assinado dispara. Configure endpoints em /app/webhooks ou pela API pública. Verifique a assinatura em cada requisição recebida.

Eventos

Cada tipo de evento dispara uma vez por mudança de estado. Assine qualquer subconjunto via enabled_events (vazio = 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.

Cabeçalhos da requisição

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 do payload

Toda entrega tem o mesmo envelope. O campo data varia por 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 a assinatura

Recalcule o HMAC-SHA256 sobre `${timestamp}.${rawBody}` usando o seu signing secret. Compare com `v1=<hex>` do cabeçalho mailmundo-signature. Rejeite se o timestamp for mais antigo que 5 minutos (proteção contra replay).

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 }

Comportamento de novas tentativas

O Mailmundo refaz as tentativas para respostas não-2xx com recuo exponencial: 30s, 2m, 10m, 1h, 6h, 24h. Após 6 tentativas falhas, a entrega vai para dead_letter (visível em /app/webhooks). Resposta 2xx = sucesso; a idempotência é responsabilidade sua — use o cabeçalho mailmundo-event-id para deduplicar.