- 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 event | Action | Why |
|---|---|---|
bounce with hard_bounce: true | suppress | The address doesn’t exist |
bounce, soft (mailbox full, temporary error) | ignore | Trying again later is fine |
spam | suppress + alert | The strongest “stop” a person can send |
blocked, error_related_to: "recipient" | suppress | Mailjet itself refuses that recipient |
blocked, other reasons (content, duplicate…) | ignore | Not the person’s choice |
unsub | suppress | Mailjet’s own unsubscribe, if it’s ever used |
sent, open, click, anything else | ignore | Not 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:
- A harmless
sentevent with the real key:200,{"ok":true,"received":1,"suppressed":0}. A wrong key:403. - 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.