---
title: "Webhooks in API Platform"
description: "Receive real-time Sent, Delivered, Open, Bounce, Spam, Unsubscribe and Reject events for your Email API and SMTP transactional emails — including your own custom data on every event."
url: https://help.mailercloud.com/en/articles/221-webhooks-in-api-platform
collection: "API Platform"
updated: 2026-11-06
language: en
---

# Webhooks in API Platform

Receive real-time Sent, Delivered, Open, Bounce, Spam, Unsubscribe and Reject events for your Email API and SMTP transactional emails — including your own custom data on every event.

## **What is a webhook?**

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.

## **Part 1 · Set up a webhook**

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #eef4ff; padding: 4pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Transactional › Settings › Webhooks</strong></p></td></tr></tbody></table>

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.](https://staticfiles.gleap.io/p62bdc487f00b73005bd20639/Kbnm99KqF3jLdd2QojUU2tKd522zolNhsSpB2C0stxqZL02PawVmH9GbNpvho6iZFUTYmLKeqCS.jpg)

_Transactional Settings — the Webhooks tab listing existing webhooks._

### **Create a webhook**

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.](https://staticfiles.gleap.io/p62bdc487f00b73005bd20639/BGG9LeofdoqXD1w2THGpzBB1NZaF6ouE0N4VNMMGAOvK4ndnIlNiS0BV34KqxDw2jcnbkpTQuFS.jpg)

_The Create new webhook form — Name, Callback URL and event checkboxes._

## **Part 2 · Events**

Each ticked event becomes a separate POST to your Callback URL. Pick only what you need.

<table style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #f3f4f6; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Event</strong></p></td><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #f3f4f6; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Fires when…</strong></p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Sent</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The message is accepted and sent from Mailercloud.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Delivery</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The receiving mail server accepts the message for the recipient.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Open</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The recipient opens the email. Requires a tracking domain on your sending domain.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Bounce</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The message is returned — a hard bounce is a permanent failure.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Spam</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The recipient or their provider marks the message as spam.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Unsubscribe</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The recipient opts out.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><code>Reject</code></p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>The message is rejected before delivery, e.g. a suppressed address or a policy block.</p></td></tr></tbody></table>

![The Webhook Event Overview panel describing each event.](https://staticfiles.gleap.io/p62bdc487f00b73005bd20639/E2uFwPiVBNC9ymn9DjXrP0PknDLKvsJ2gpIaM9SmVS5Foy3omt4DmTtrxOv2LRLdqYRHBehwLaY.jpg)

_The Webhook Event Overview panel describing each event._

## **Part 3 · Payload signing**

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.](https://staticfiles.gleap.io/p62bdc487f00b73005bd20639/6AWO8InWgVGI8KwvXE0EyiVqDHtqBA8QVP77caB4uZsuQewv0nYMd021djFTu1MPEfEwCEAfoWx.jpg)

_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.
    

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #eef4ff; padding: 6pt 9pt; overflow: hidden; overflow-wrap: break-word;"><p><span data-name="memo" data-type="emoji">📝</span><strong> Note: </strong>If signing is off, the payload is byte-identical to before and no signature headers are added, so existing receivers keep working unchanged.</p></td></tr></tbody></table>

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #fff6e6; padding: 6pt 9pt; overflow: hidden; overflow-wrap: break-word;"><p><span data-name="pushpin" data-type="emoji">📌</span><strong> Tip: </strong>Use <strong>Send sample data</strong> to fire a test payload at your URL before going live.</p></td></tr></tbody></table>

## **Part 4 · The webhook payload**

Each event is delivered as an HTTP POST with a JSON body. A typical Delivered event looks like this:

```javascript
{
    "event": "Delivered",
    "message_id": "CASE123-8f3a2c",
    "email": "officer@example.com",
    "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.
    

### **Fields on specific events**

-   **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`.
    

```javascript
{
    "event": "Opened",
    "message_id": "CASE123-8f3a2c",
    "email": "officer@example.com",
    "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
}
```

## **Part 5 · Custom data on every event**

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.

### **Sending custom data**

**Email API** — add a `custom` object inside `metadata`:

```javascript
"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-`:

```javascript
X-MC-Custom-user_ref: U-45821
X-MC-Custom-case_id:  C-9981
X-MC-Custom-branch:   PUNE
```

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #eef4ff; padding: 6pt 9pt; overflow: hidden; overflow-wrap: break-word;"><p><span data-name="memo" data-type="emoji">📝</span><strong> Note: </strong>The <code>X-MC-Custom-*</code> 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.</p></td></tr></tbody></table>

### **What you get back**

Every event for that message carries a `custom` object with your values, always returned as strings:

```javascript
{
    "event": "Delivered",
    "message_id": "CASE123-8f3a2c",
    "email": "officer@example.com",
    "custom": {
        "user_ref": "U-45821",
        "case_id":  "C-9981",
        "branch":   "PUNE"
    }
}
```

### **Limits**

<table style="min-width: 75px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #f3f4f6; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Rule</strong></p></td><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #f3f4f6; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Limit</strong></p></td><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #f3f4f6; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p><strong>Over-limit behaviour</strong></p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Keys per message</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>10</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>First 10 (sorted by key) kept; the rest listed in <code>custom_dropped</code>.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Key name</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>≤ 50 chars · A–Z a–z 0–9 - _</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Invalid key dropped and named.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Value length</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>≤ 256 characters</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Oversized value dropped and named.</p></td></tr><tr><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Value type</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>text / number / true-false</p></td><td colspan="1" rowspan="1" style="vertical-align: top; padding: 5pt 8pt; overflow: hidden; overflow-wrap: break-word;"><p>Returned as a string (123 → "123").</p></td></tr></tbody></table>

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #eef4ff; padding: 6pt 9pt; overflow: hidden; overflow-wrap: break-word;"><p><span data-name="memo" data-type="emoji">📝</span><strong> Note: </strong>If a key breaks a rule it is dropped and named in <code>custom_dropped</code> — <strong>the email is always sent</strong>. Nothing about custom data can cause a send to fail.</p></td></tr></tbody></table>

When keys are refused, you also get a `custom_dropped` array so you can spot and fix the problem:

```javascript
{
    "event": "Delivered",
    "message_id": "CASE123-8f3a2c",
    "email": "officer@example.com",
    "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**

-   [Email API reference — Send email](https://apidoc.mailercloud.com/api-reference/email/send-email)
    
-   [SMTP relay guide](https://apidoc.mailercloud.com/guides/smtp-relay)
    

## **Part 6 · Verifying the signature**

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.
    

```javascript
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)));
}
```

<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1" style="vertical-align: top; background-color: #fff6e6; padding: 6pt 9pt; overflow: hidden; overflow-wrap: break-word;"><p><span data-name="pushpin" data-type="emoji">📌</span><strong> Tip: </strong>Keep your signing secret private and respond <strong>2xx</strong> quickly — do heavy work asynchronously. A non-2xx response is treated as a failed delivery and retried.</p></td></tr></tbody></table>

## **Need help?**

If you have any questions about webhooks or custom data, contact us at **support@mailercloud.com** — the Mailercloud support team is ready to help.

Happy emailing! 🚀
