• Aug 27, 2026
  • 5 min read

A Mailjet Event Webhook That Only Suppresses Permanent Failures

My do-not-send list was only written by people clicking “unsubscribe”. That covers people who say no. It doesn’t cover addresses that don’t exist, inboxes that mark me as spam, or recipients my email provider refuses to deliver to. Keep mailing those and the sending domain’s reputation pays for it.

Mailjet reports all of that through its Event API: you give it a URL, it POSTs JSON every time something happens to a message. So I added one more endpoint to the forms Worker that turns the right events into suppressions — and deliberately ignores the rest.

The suppression list itself is described in a do-not-send list that stores no email addresses. This post is only about feeding it from provider events.

Suppress only what is permanent

The core of the endpoint is one small function. Every event either means “never email this address again” or it doesn’t:

export function decide(event: MailjetEvent): Decision {
  switch (event.event) {
    case "bounce":
      return event.hard_bounce === true
        ? { suppress: true, reason: "hard-bounce" }
        : { suppress: false, reason: "soft-bounce" };
    case "spam":
      return { suppress: true, reason: "spam-complaint" };
    case "blocked":
      return event.error_related_to === "recipient"
        ? { suppress: true, reason: "blocked-recipient" }
        : { suppress: false, reason: `blocked-${event.error_related_to ?? "other"}` };
    case "unsub":
      return { suppress: true, reason: "provider-unsubscribe" };
    default:
      return { suppress: false, reason: `ignored-${event.event ?? "unknown"}` };
  }
}

As a table:

Mailjet eventActionWhy
bounce with hard_bounce: truesuppressThe address doesn’t exist
bounce, soft (mailbox full, temporary error)ignoreTrying again later is fine
spamsuppress + alertThe strongest “stop” a person can send
blocked, error_related_to: "recipient"suppressMailjet itself refuses that recipient
blocked, other reasons (content, duplicate…)ignoreNot the person’s choice
unsubsuppressMailjet’s own unsubscribe, if it’s ever used
sent, open, click, anything elseignoreNot a reason to stop

The temptation is to suppress on every bounce. A soft bounce, though, is a full mailbox or a greylisting server — suppressing it would quietly drop a real subscriber forever because their inbox was full one Tuesday.

One event or a batch

Mailjet can send each event on its own or group the events of the last second into a JSON array. The handler doesn’t care:

const events = (Array.isArray(body) ? body : [body])
  .filter((e): e is MailjetEvent => !!e && typeof e === "object");
const result = await applyEvents(env.LEADS, events);

applyEvents lowercases and validates each address, runs decide(), and for a suppression writes the same key the unsubscribe link writes — suppress:<sha256(email)> — with the reason recorded as the source:

{ "at": "2026-…Z", "site": null, "source": "mailjet:hard-bounce" }

No address is stored. When I look at the list later, I can still tell an unsubscribe from a bounce from a complaint.

Authentication: a key in the URL

I didn’t find an HMAC signature header to verify on Mailjet’s event calls (the kind Cal.com or Stripe send), so the endpoint is protected by a long random key in the query string (Basic auth credentials in the URL would be the other option):

https://www.jalcocertech.com/api/forms/mailjet-events?key=…

The Worker compares it in constant time against a secret:

if (!env.MAILJET_WEBHOOK_KEY || !sameKey(key, env.MAILJET_WEBHOOK_KEY))
  return json({ error: "forbidden" }, 403);

Two details matter more than they look:

  • A wrong key gets 403. If I paste the URL wrong in Mailjet’s settings, the failure shows up immediately instead of silently swallowing events.
  • Anything authenticated gets 200, even junk. Providers retry non-2xx answers for a long time (my notes say up to a day for Mailjet; check their current docs). Returning 400 for a malformed body would just make Mailjet send the same garbage again and again. A body that isn’t JSON gets {"ok":true,"ignored":"invalid_json"}.

The key itself was generated locally, never printed, and piped straight into the Worker:

(umask 077; openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n' \
  > ~/.config/leads-hub/mailjet_webhook_key)
npx wrangler secret put MAILJET_WEBHOOK_KEY < ~/.config/leads-hub/mailjet_webhook_key

The full URL (with the key) went into a second chmod 600 file, which I copied from into Mailjet. If it ever leaks, the damage is limited — someone could add addresses to my do-not-send list — and rotating it is one command.

A complaint is worth a notification

Bounces are routine. A spam complaint means someone I emailed felt I shouldn’t have. That one deserves a phone notification, but without the person’s address:

if (result.complaints.length) {
  const domains = [...new Set(result.complaints)].map((d) => `@${d}`).join(", ");
  ctx.waitUntil(notifyOps(env, "Spam complaint - suppressed",
    `Mailjet reported ${result.complaints.length} complaint(s) from ${domains}`));
}

The alert goes to my self-hosted ntfy (locked down behind a Cloudflare Tunnel) and names only the domain, the same rule every other alert in the pipeline follows.

Configuring Mailjet

On Mailjet’s Event API page each event type has its own URL field. I pasted the same URL into Bounce, Spam, Blocked and Unsub, left Sent, Open and Click empty (open and click tracking are switched off anyway — see the provider adapter), and ticked group events, which Mailjet recommends so the endpoint is called at most once per second.

Testing it

The test suite covers the decision table, the 403 paths, a grouped batch with every case mixed together, and the property I care most about: after a batch, no stored value contains an @.

Live, two checks:

  1. A harmless sent event with the real key: 200, {"ok":true,"received":1,"suppressed":0}. A wrong key: 403.
  2. Mailjet’s own test button for Spam and Bounce. The Worker logs showed two POSTs from a Go HTTP client, both answered 200. The do-not-send list stayed empty — Mailjet’s test payloads don’t carry a real address, so there was nothing to suppress and nothing to clean up.

Takeaways

  • Suppress on permanent signals only: hard bounces, complaints, recipient blocks, unsubscribes. Never on soft bounces.
  • Reuse the same suppression key as your unsubscribe link, and record the source so you can tell them apart later.
  • Answer 403 to a wrong key, 200 to everything else authenticated — retries of garbage help nobody.
  • Alert on complaints, by domain only.

Next: a weekly ntfy digest with counts only.