Events
Each event type fires once per state change. Subscribe to any subset via enabled_events (empty = all).
| Event | Description |
|---|---|
| contact.created | Contact inserted (manual, import, API, signup form, or list_subscribe). |
| contact.updated | Non-status fields changed (first/last name, locale, attributes, etc.). |
| contact.unsubscribed | Status transitioned to 'unsubscribed' (manual or List-Unsubscribe header). |
| contact.bounced | Hard bounce detected; contact will be suppressed from future sends. |
| contact.complained | Recipient marked the email as spam. |
| list.member_added | Contact added to a list (or re-subscribed after unsubscribe). |
| list.member_removed | Contact unsubscribed from a list (soft delete; row retained for audit). |
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
Payload shape
Every delivery has the same envelope. data varies per event.
{
"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"
}
}Verifying the signature
Recompute HMAC-SHA256 over `${timestamp}.${rawBody}` using your signing secret. Compare with `v1=<hex>` from mailmundo-signature header. Reject if the timestamp is older than 5 minutes (replay protection).
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 }Retry behavior
Mailmundo retries non-2xx responses with exponential backoff: 30s, 2m, 10m, 1h, 6h, 24h. After 6 failed attempts, delivery is moved to dead_letter (visible in /app/webhooks). 2xx response = success; idempotency is your responsibility — use the mailmundo-event-id header to dedupe.