A webhook is a URL on your server that Mailercloud calls with a JSON payload whenever something happens to one of your transactional emails — sent, delivered, opened, bounced, marked as spam, unsubscribed or rejected. It works for emails sent through both the Email API and the SMTP relay, and you can attach your own reference data to each email and get it back on every event.
Transactional › Settings › Webhooks |
The Webhooks tab lists every webhook on your account with its URL, status and last-modified date.

Transactional Settings — the Webhooks tab listing existing webhooks.
Click Create Webhook and fill in the form:
Name — a label to recognise this webhook.
Callback URL — the HTTPS endpoint on your server that will receive the POST requests.
Select event — tick the events you want delivered, or choose Select All Event.

The Create new webhook form — Name, Callback URL and event checkboxes.
Each ticked event becomes a separate POST to your Callback URL. Pick only what you need.
Event | Fires when… |
| The message is accepted and sent from Mailercloud. |
| The receiving mail server accepts the message for the recipient. |
| The recipient opens the email. Requires a tracking domain on your sending domain. |
| The message is returned — a hard bounce is a permanent failure. |
| The recipient or their provider marks the message as spam. |
| The recipient opts out. |
| The message is rejected before delivery, e.g. a suppressed address or a policy block. |

The Webhook Event Overview panel describing each event.
Leave Payload signing on so your endpoint can verify each request genuinely came from Mailercloud. When enabled, every request carries three headers and you receive a signing secret to copy right after creating the webhook.

Events, the Payload signing toggle, and the Add / Send sample data buttons.
Webhook-Signature — the signature, in the form v1,<base64> (HMAC-SHA256 of the request, keyed by your signing secret — see Part 6). While a secret is being rotated it can hold more than one value, separated by spaces.
Webhook-Timestamp — Unix time the request was signed; reject requests that are too old to prevent replay.
Request-ID — a unique id for the request. It is part of the signed string, so you need it to verify the signature.
📝 Note: If signing is off, the payload is byte-identical to before and no signature headers are added, so existing receivers keep working unchanged. |
📌 Tip: Use Send sample data to fire a test payload at your URL before going live. |
Each event is delivered as an HTTP POST with a JSON body. A typical Delivered event looks like this:
{
"event": "Delivered",
"message_id": "CASE123-8f3a2c",
"email": "[email protected]",
"metadata": "example.com",
"date_event": "2026-10-01 11:08:47",
"ts": 1759310656,
"ts_event": 1759310648
}event — the event type: Sent, Delivered, Opened, Clicked, Bounced, Spam, Unsubscribed or Reject.
message_id — your messageId from the send (Email API). For SMTP sends with no Message-ID, Mailercloud generates one.
email — the recipient address for this event.
metadata — the recipient's email domain.
ts / ts_event — Unix timestamps: when the webhook fired and when the underlying event occurred.
date_event — human-readable event time in your account timezone.
Opened and Clicked also carry browser, device, os, userAgent and a geo object (city, country, country_code).
Clicked additionally carries clickedURL — the link that was clicked.
Bounced, Spam and Reject carry a description with the reason. A Reject caused by the suppression list also carries reason, scope and stream.
{
"event": "Opened",
"message_id": "CASE123-8f3a2c",
"email": "[email protected]",
"browser": "Chrome",
"device": "desktop",
"os": "Windows",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...",
"geo": {
"city": "Pune",
"country": "India",
"country_code": "IN"
},
"ts": 1759310702,
"ts_event": 1759310701
}Attach your own key–values to a transactional email and Mailercloud returns them on every webhook event for that message as a custom object — ideal for correlating an event back to a case id, order or user reference with no lookup on your side. It works on both the Email API and the SMTP relay.
Email API — add a custom object inside metadata:
"metadata": {
"campaignType": "TRANSACTIONAL",
"messageId": "CASE123-8f3a2c",
"custom": {
"user_ref": "U-45821",
"case_id": "C-9981",
"branch": "PUNE"
}
}SMTP relay — add one header per key, each prefixed X-MC-Custom-:
X-MC-Custom-user_ref: U-45821
X-MC-Custom-case_id: C-9981
X-MC-Custom-branch: PUNE📝 Note: The |
Every event for that message carries a custom object with your values, always returned as strings:
{
"event": "Delivered",
"message_id": "CASE123-8f3a2c",
"email": "[email protected]",
"custom": {
"user_ref": "U-45821",
"case_id": "C-9981",
"branch": "PUNE"
}
}Rule | Limit | Over-limit behaviour |
Keys per message | 10 | First 10 (sorted by key) kept; the rest listed in |
Key name | ≤ 50 chars · A–Z a–z 0–9 - _ | Invalid key dropped and named. |
Value length | ≤ 256 characters | Oversized value dropped and named. |
Value type | text / number / true-false | Returned as a string (123 → "123"). |
📝 Note: If a key breaks a rule it is dropped and named in |
When keys are refused, you also get a custom_dropped array so you can spot and fix the problem:
{
"event": "Delivered",
"message_id": "CASE123-8f3a2c",
"email": "[email protected]",
"custom": { "user_ref": "U-45821" },
"custom_dropped": ["bad key!", "case_note_2kb"]
}If you send no custom data, there is no custom key in the payload at all, and custom_dropped appears only when something was actually refused. The reserved keys inbox_tracking, campaign_id, store_content and tags are never echoed.
Further reading
When payload signing is on, verify each request before trusting it:
Signed string — Request-ID + "." + Webhook-Timestamp + "." + the exact raw request body (before any JSON parsing).
Key — your signing secret without the whsec_ prefix, base64-decoded.
Signature — HMAC-SHA256 of the signed string, base64-encoded and prefixed with v1,. Compare it in constant time to each space-separated value in the Webhook-Signature header; a match on any value is valid.
Replay protection — reject requests whose Webhook-Timestamp is too old.
const crypto = require("crypto");
function verify(req, secret) {
const sigHeader = req.headers["webhook-signature"];
const ts = req.headers["webhook-timestamp"];
const requestId = req.headers["request-id"];
if (!sigHeader || !ts || !requestId) return false;
// reject anything older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = "v1," + crypto
.createHmac("sha256", key)
.update(requestId + "." + ts + "." + req.rawBody)
.digest("base64");
// the header can hold several space-separated values during secret rotation
return sigHeader.split(" ").some(s =>
s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}📌 Tip: Keep your signing secret private and respond 2xx quickly — do heavy work asynchronously. A non-2xx response is treated as a failed delivery and retried. |
If you have any questions about webhooks or custom data, contact us at [email protected] — the Mailercloud support team is ready to help.
Happy emailing! 🚀