Developers

Webhooks

Mailmundo hace POST de JSON firmado a tu endpoint cuando un evento subscrito dispara. Configura endpoints en /app/webhooks o via Public API. Verifica la signature en cada request recibido.

Eventos

Cada event type dispara una vez por cambio de estado. Subscríbete a cualquier subset via enabled_events (vacío = todos).

EventDescription
contact.createdContact inserted (manual, import, API, signup form, or list_subscribe).
contact.updatedNon-status fields changed (first/last name, locale, attributes, etc.).
contact.unsubscribedStatus transitioned to 'unsubscribed' (manual or List-Unsubscribe header).
contact.bouncedHard bounce detected; contact will be suppressed from future sends.
contact.complainedRecipient marked the email as spam.
list.member_addedContact added to a list (or re-subscribed after unsubscribe).
list.member_removedContact 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

Formato del payload

Cada delivery tiene el mismo envelope. data varía por evento.

Sample payloadjson
{
  "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"
  }
}

Verificando la signature

Recompute HMAC-SHA256 sobre `${timestamp}.${rawBody}` usando tu signing secret. Compara con `v1=<hex>` del header mailmundo-signature. Rechaza si el timestamp es más viejo que 5 minutos (protección 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 }

Comportamiento de retry

Mailmundo reintenta responses no-2xx con backoff exponencial: 30s, 2m, 10m, 1h, 6h, 24h. Después de 6 intentos fallidos, el delivery va a dead_letter (visible en /app/webhooks). Response 2xx = éxito; la idempotencia es tu responsabilidad — usa el header mailmundo-event-id para deduplicar.