Desenvolvedores

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

EventoDescrição
contact.createdContato criado (manual, importação, API ou double opt-in confirmado).
contact.confirmedO contato clicou no link de confirmação do double opt-in. O status passou de pending_double_opt_in para active. Inclui list_id.
contact.unsubscribedO contato clicou no List-Unsubscribe de um clique (RFC 8058). Entra automaticamente nas supressões da organização.
email.sentMensagem entregue ao SES (MessageId atribuído). É o primeiro evento de todo envio.
email.deliveredO SES confirmou a entrega no MTA do destinatário. Não significa abertura.
email.openedO destinatário carregou o pixel de rastreamento. Pode disparar várias vezes por mensagem — deduplique por message_id se quiser só a primeira abertura.
email.clickedO destinatário clicou em um link rastreado. O payload inclui link_url.
email.bouncedBounce hard ou soft reportado pelo SES. Bounce hard suprime o contato automaticamente.
email.complainedO destinatário marcou o email como spam. O contato é descadastrado automaticamente.
email.failedEnvio transacional recusado pelo SES (validação, throttling etc.). Nesse caminho não há nova tentativa.
webhook.test_pingEvento sintético que você dispara pelo painel ("Enviar evento de teste"). Serve para validar seu receptor antes de ir para produção.

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