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.
In the dashboard, go to Transactional → Settings → Webhooks. You will see every webhook on your account with its URL, status and last-modified date.

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 Select All Event.

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

Leave Payload signing on so your endpoint can verify each request genuinely came from Mailercloud. When enabled, every request carries two headers and you receive a signing secret to copy right after creating the webhook.

Webhook-Signature — HMAC-SHA256 of the request, keyed by your signing secret.
Webhook-Timestamp — Unix time the request was signed; reject requests that are too old to prevent replay.
If signing is off, the payload is byte-identical to before and no signature headers are added, so existing receivers keep working unchanged. 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.
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: PUNEThe X-MC-Custom-* headers are consumed by Mailercloud and stripped before delivery, so the recipient never sees them. SMTP header names are lowercased in transit, so a key sent over SMTP comes back lowercase; the Email API preserves your original casing.
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"
}
}If a key breaks a rule it is dropped and named in custom_dropped — the email is always sent. Nothing about custom data can cause a send to fail.
Rule | Limit | Over-limit behaviour |
Keys per message | 10 | First 10 (sorted by key) kept; the rest listed in custom_dropped. |
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"). |
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.
When payload signing is on, verify each request before trusting it. Compute an HMAC-SHA256 over the signed payload using your secret, compare it in constant time to the Webhook-Signature header, and reject requests whose Webhook-Timestamp is too old.
const crypto = require("crypto");
function verify(req, secret) {
const sig = req.headers["webhook-signature"];
const ts = req.headers["webhook-timestamp"];
// reject anything older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(ts + "." + req.rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}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.