Guide to Mailjet Webhooks: Features and Best Practices
Mailjet webhooks tell you what happened to the emails you sent: whether the receiving server accepted them, whether they were opened or clicked, and whether they bounced, were blocked, were marked as spam or led to an unsubscribe. If you send transactional or marketing email through Mailjet, the Event API is how you keep suppression lists, CRM records and your own statistics up to date without polling.
This guide covers how Mailjet webhook URLs are registered, how to authenticate them when Mailjet doesn't sign its requests, the seven event types and their payloads, the retry and suspension rules, and the best practices for running a Mailjet receiver in production.
What are Mailjet webhooks?
Mailjet calls its webhooks the Event API. You register a URL for an event type, and Mailjet makes an HTTP POST to it "for each event as it occurs", with a JSON object describing the event. It works for both transactional and marketing email.
A URL is registered per event type. The /eventcallbackurl resource takes a single EventType, so to receive all seven events through the API you create seven registrations. They can all point at the same URL.
Mailjet does not sign webhook requests. Its recommendation is to use "a secure (HTTPS) URL in combination with a basic authentication", with the credentials in the URL itself, such as https://username:password@www.example.com/mailjet_.
Mailjet webhook features
| Feature | Details |
|---|---|
| Configuration | Event Tracking in account preferences, or POST /v3/REST/eventcallbackurl |
| Subscription model | One registration per event type |
| URL rules | Cannot be a root URL. A custom port is allowed |
| Authentication | Basic auth credentials in the URL |
| Signature | None |
| Event field | event in the body |
| Batching | Version 1 sends one event per request. Version 2 (Group Events) groups them |
| Success | 200 OK |
| Retries | Every 30 seconds for up to 24 hours |
| Backup URL | IsBackup, used when the primary URL is suspended |
| Reactivation | A suspended URL is reactivated by updating it to a new URL |
| Event ID | None |
Mailjet publishes no source IP list for webhooks.
Common events
| Event | Fires when | Extra fields |
|---|---|---|
sent | The destination SMTP server accepted the message | smtp_reply, and the deprecated mj_message_id |
open | The recipient opened the message | ip, geo, agent |
click | The recipient clicked a tracked link | url |
bounce | The message bounced | blocked, hard_bounce, error_related_to, error, comment |
blocked | The message was blocked | error_related_to, error |
spam | The recipient reported the message as spam | source |
unsub | The recipient unsubscribed | mj_list_id, ip, geo, agent |
Every event shares these fields: event, time (Unix seconds), email, mj_campaign_id, mj_contact_id, customcampaign, MessageID, CustomID and Payload. CustomID and Payload echo values you set when sending, which is how you link an event back to your own records. Mailjet's samples also include Message_GUID. Here is its bounce example:
{
"event": "bounce",
"time": 1430812195,
"MessageID": 13792286917004336,
"Message_GUID": "1ab23cd4-e567-8901-2345-6789f0gh1i2j",
"email": "bounce@mailjet.com",
"mj_campaign_id": 0,
"mj_contact_id": 0,
"customcampaign": "",
"CustomID": "helloworld",
"Payload": "",
"blocked": false,
"hard_bounce": true,
"error_related_to": "recipient",
"error": "user unknown",
"comment": "Host or domain name not found. Name service error for name=lbjsnrftlsiuvbsren.com type=A: Host not found"
}
hard_bounce: false marks a temporary failure. Suppress addresses on hard bounces only.
MessageID is a large integer. In JavaScript it can exceed Number.MAX_SAFE_INTEGER, so parse it as a string or a BigInt if you store or compare it.
Setting up Mailjet webhooks
- Choose a username and a long random password for the webhook, and store them on your server as
MAILJET_WEBHOOK_USERNAMEandMAILJET_WEBHOOK_PASSWORD. - Build the URL with the credentials in it, percent-encoding any special characters:
https://<username>:<password>@example.com/webhooks/mailjet. Don't use a root URL. - Register it in Event Tracking in your Mailjet account preferences, or with the API, once per event type:
curl -X POST "https://api.mailjet.com/v3/REST/eventcallbackurl" \
-u "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
-H "Content-Type: application/json" \
-d '{
"EventType": "bounce",
"Url": "https://'"$MAILJET_WEBHOOK_USERNAME:$MAILJET_WEBHOOK_PASSWORD"'@example.com/webhooks/mailjet",
"Version": 2
}'
- For
sent, which can produce a lot of traffic, use Version 2 or tick the Group Events checkbox, as Mailjet advises. - Optionally register a second URL with
IsBackupset totrue, to take over if the primary is suspended.
For local development, the Hookdeck CLI (hookdeck listen 3000 mailjet --path /webhooks/mailjet) gives you a public HTTPS URL that forwards to your local server, with no account required. Add your credentials to that URL when you register it.
Securing Mailjet webhooks
The credentials in the URL are the only protection. HTTP clients send URL credentials as an Authorization: Basic header holding base64(username:password), which is what Hookdeck's Mailjet source checks too. Mailjet's docs don't spell this step out, so confirm the header on your first real delivery.
Decode the header, split on the first colon (passwords can contain :), and compare the username and password in constant time. Check both before deciding, so the response time doesn't reveal which one was wrong.
const crypto = require('crypto');
const express = require('express');
function safeEqual(a, b) {
const x = Buffer.from(a, 'utf8');
const y = Buffer.from(b, 'utf8');
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyMailjetAuth(authHeader, username, password) {
if (!username || !password || typeof authHeader !== 'string') return false; // fail closed
const match = authHeader.match(/^Basic\s+(.+)$/i);
if (!match) return false;
const decoded = Buffer.from(match[1].trim(), 'base64').toString('utf8');
const colon = decoded.indexOf(':');
if (colon < 0) return false;
const userOk = safeEqual(decoded.slice(0, colon), username);
const passOk = safeEqual(decoded.slice(colon + 1), password);
return userOk && passOk;
}
const app = express();
app.post('/webhooks/mailjet', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyMailjetAuth(
req.get('authorization'),
process.env.MAILJET_WEBHOOK_USERNAME,
process.env.MAILJET_WEBHOOK_PASSWORD
);
if (!ok) return res.set('WWW-Authenticate', 'Basic').sendStatus(401);
const parsed = JSON.parse(req.body.toString('utf8'));
const events = Array.isArray(parsed) ? parsed : [parsed]; // Version 2 may group events
// Store each event, then answer with 200 OK.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hmac
import os
def verify_mailjet_auth(auth_header, username, password) -> bool:
if not username or not password or not isinstance(auth_header, str):
return False # fail closed
scheme, _, encoded = auth_header.partition(" ")
if scheme.lower() != "basic" or not encoded:
return False
try:
decoded = base64.b64decode(encoded.strip(), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return False
user, sep, pwd = decoded.partition(":")
if not sep:
return False
user_ok = hmac.compare_digest(user.encode("utf-8"), username.encode("utf-8"))
pass_ok = hmac.compare_digest(pwd.encode("utf-8"), password.encode("utf-8"))
return user_ok and pass_ok
# In FastAPI:
# verify_mailjet_auth(request.headers.get("authorization"),
# os.environ.get("MAILJET_WEBHOOK_USERNAME"), os.environ.get("MAILJET_WEBHOOK_PASSWORD"))
Basic auth proves the sender knows the credentials. It does not protect the body and gives no replay protection, so keep the endpoint on HTTPS.
Make Mailjet webhooks production-ready. Hookdeck Event Gateway checks the Basic auth credentials on every Mailjet request, answers with
200 OKstraight away, and durably queues every event before your service sees it.
Mailjet webhook limitations and pain points
No signature, only URL credentials
The Problem: Anyone who learns your webhook URL and credentials can post fake bounces or unsubscribes.
Why It Happens: Mailjet doesn't sign requests and documents no way to add a custom header. The only protection it describes is Basic auth in the URL, which travels with every request.
Workarounds:
- Always use HTTPS so the credentials are encrypted in transit.
- Use a long random password. To change it, let your server accept both the old and new credentials, update the registered URLs, then drop the old pair.
- For destructive actions such as suppressing an address, consider confirming through the Mailjet API first.
How Hookdeck Can Help: Hookdeck's Mailjet source checks the Basic auth credentials on every request, stops requests that fail before they reach your service, and logs every request for inspection.
Retries every 30 seconds for a day
The Problem: A slow or failing endpoint turns into a flood of retried requests.
Why It Happens: "Any other HTTP code will result in our server retrying the request later. Our system will retry every 30s and will stop after 24h, unless a new event is generated." Mailjet's best practices warn that "all non 200 errors would be retried and could cause an increasing volume of calls to your system".
Workarounds:
- Return exactly
200 OKonce the request is authenticated and stored. - Keep lookups against your database or Mailjet's API out of the request path, as Mailjet advises.
- Check your server logs for non-200 responses regularly.
How Hookdeck Can Help: Hookdeck answers Mailjet with 200 OK as soon as it stores the event, then delivers it to your service at a max delivery rate with retries on your own schedule.
A suspended URL needs a new URL
The Problem: Events stop arriving, and updating the existing URL doesn't bring them back.
Why It Happens: Mailjet suspends a failing URL, and "to reactivate a suspended endpoint URL, you need to update it with a new URL". Mailjet doesn't document when suspension happens.
Workarounds:
- Register a backup URL with
IsBackup, which Mailjet uses when the primary is suspended. - Keep a second path or query parameter ready so you can switch the URL quickly.
How Hookdeck Can Help: Because Hookdeck accepts Mailjet's requests even while your service is down, the Mailjet URL keeps succeeding and isn't suspended.
The grouped body isn't documented
The Problem: You switch to Version 2 for sent volume and your handler stops finding the event field.
Why It Happens: Version 2 delivers grouped events, "with a maximum frequency of once per second", but Mailjet doesn't show what the grouped body looks like.
Workarounds:
- Accept both a single object and an array, as the example above does.
- Log the first grouped delivery and check its shape before relying on it.
How Hookdeck Can Help: Hookdeck shows the exact body of every request in its log, so you can see the grouped format before you write code for it.
Best practices
Register every event type you need
Each registration covers one event type. Check that all of sent, open, click, bounce, blocked, spam and unsub that you depend on are registered, and route on event in the body.
Deduplicate deliveries
There is no event ID. Build a key from MessageID, event, time and email, and store it before processing. See our guide to webhook idempotency.
Tag messages when you send them
Set CustomID and Payload when sending, so every event carries your own identifiers back to you.
Acknowledge fast and process asynchronously
Mailjet's own best practice is to save the event and let "an asynchronous consumer process" do the rest. See why to process webhooks asynchronously.
Conclusion
Mailjet's Event API sends unsigned JSON events, one registration per event type. Put Basic auth credentials in an HTTPS URL and check them in constant time, accept both single and grouped bodies, route on event, deduplicate without an event ID, and return 200 OK quickly so retries every 30 seconds don't pile up.
Hookdeck Event Gateway checks the credentials, answers Mailjet immediately and holds every event until your service has processed it, so a slow database or a deploy doesn't lead to a day of retries or a suspended URL.
Get started with Hookdeck for free and handle Mailjet webhooks reliably in minutes.