Developers

Webhooks

Mailmundo POSTs signed JSON to your endpoint whenever a subscribed event fires. Configure endpoints in /app/webhooks or via the Public API. Verify the signature on every incoming request.

Events

Each event type fires once per state change. Subscribe to any subset via enabled_events (empty = all).

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

Payload shape

Every delivery has the same envelope. data varies per event.

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

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

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 }

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.