# Guide to MailerLite Webhooks: Features and Best Practices

MailerLite webhooks tell your systems what is happening on your mailing list: a subscriber signs up, unsubscribes, bounces or joins a group, an automation starts or finishes, or a campaign goes out and gets opened and clicked. If you run email marketing on MailerLite, webhooks are how that activity reaches your own systems without polling the API.

This guide covers webhooks in the new MailerLite API (`connect.mailerlite.com`), not MailerLite Classic: how they are created and signed, the events and their two payload shapes, batched deliveries, and the best practices for running a MailerLite receiver in production.

## What are MailerLite webhooks?

A MailerLite webhook is a URL plus a list of events. When one of those events happens, MailerLite sends an HTTP `POST` to the URL with a JSON body describing it. Each webhook has its own secret, and every request carries a `Signature` header: the hex HMAC-SHA256 of the JSON body, keyed with that secret.

Two details shape every MailerLite receiver. First, the event name is not always in the same field: some payloads put it in `event`, others in `type`. Second, MailerLite's delivery window is tight. You have 3 seconds to answer, and a failed event is retried only three more times, all within about 20 minutes.

## MailerLite webhook features

| Feature | Details |
| --- | --- |
| Configuration | The webhooks API: `https://connect.mailerlite.com/api/webhooks` (list, get, create, update, delete) |
| Subscription model | A list of `events` per webhook, plus an optional `batchable` flag |
| Signature header | `Signature` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | The JSON payload as sent |
| Secret | One per webhook, generated by MailerLite and returned in the webhook object |
| Replay window | None. No timestamp is sent or signed |
| Response window | 3 seconds. A `2xx` counts as delivered |
| Retries | Three more attempts, after 10, 100 and 1000 seconds |
| Batching | `{"events": [...], "total": n}` for batchable webhooks |
| Account state | Webhooks are only delivered for active accounts |
| Source IPs | Not published |

## Common events

MailerLite documents 14 events.

| Event | Fires when |
| --- | --- |
| `subscriber.created` | A new subscriber is added to the account |
| `subscriber.updated` | Any of a subscriber's custom fields are updated, or they confirm their subscription |
| `subscriber.unsubscribed` | A subscriber becomes unsubscribed |
| `subscriber.added_to_group` | A subscriber is added to a group |
| `subscriber.removed_from_group` | A subscriber is removed from a group |
| `subscriber.bounced` | An email address bounces |
| `subscriber.automation_triggered` | A subscriber starts an automation |
| `subscriber.automation_completed` | A subscriber finishes an automation |
| `subscriber.spam_reported` | A subscriber marks a campaign as spam |
| `subscriber.deleted` | A subscriber is set as deleted or forgotten |
| `subscriber.active` | A subscriber's status is set to active |
| `campaign.sent` | A campaign is sent |
| `campaign.open` | A subscriber opens a campaign |
| `campaign.click` | A subscriber clicks a link in a campaign |

The payloads come in two shapes. For `subscriber.created`, `subscriber.updated`, `subscriber.unsubscribed`, `subscriber.bounced`, `subscriber.spam_reported`, `subscriber.active` and `campaign.sent`, the body is the subscriber or campaign object itself, with `event` and `account_id` added. Here is MailerLite's `subscriber.created` example, trimmed:

```json
{
  "id": "100000000000000000",
  "email": "john.doe@example.com",
  "status": "active",
  "source": "ecommerce",
  "subscribed_at": "2024-05-08T08:26:04.000000Z",
  "created_at": "2024-05-08T08:26:04.000000Z",
  "updated_at": "2024-05-28T10:30:29.000000Z",
  "fields": { "name": "", "last_name": "", "company": "", "country": "" },
  "event": "subscriber.created",
  "account_id": 0
}

```

The other seven events relate a subscriber to something else, and use a nested body with the event name in `type`. Here is `campaign.open`, trimmed:

```json
{
  "type": "campaign.open",
  "subscriber": {
    "id": "100000000000000000",
    "email": "john.doe@example.com",
    "status": "active"
  },
  "campaign": {
    "id": "100000000000000000",
    "name": "Campaign Example",
    "total_recipients": 100,
    "date": "2024-05-28T07:11:08.000000Z"
  },
  "account_id": 0
}

```

`subscriber.added_to_group` and `subscriber.removed_from_group` carry `group`, the automation events carry `automation`, and `campaign.open` and `campaign.click` carry `campaign`.

A webhook with `batchable` set to `true` wraps its events in an array: `{"events": [...], "total": 2}`. The only batched example MailerLite publishes holds two `campaign.open` events, each with `type`. It does not show whether a batched flat event keeps its `event` field, so read both.

## Setting up MailerLite webhooks

Create an API token in MailerLite, then create the webhook:

```bash
curl -X POST "https://connect.mailerlite.com/api/webhooks" \
  -H "Authorization: Bearer $MAILERLITE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Subscriber sync",
    "url": "https://example.com/webhooks/mailerlite",
    "events": ["subscriber.created", "subscriber.updated", "subscriber.unsubscribed"],
    "enabled": true
  }'

```

The request takes `events` and `url` (both required), plus `name`, `enabled` and `batchable`. The response is the webhook object, including its `secret`, a short string such as `ybmcD7PQ9R`. Store it server-side, for example as `MAILERLITE_WEBHOOK_SECRET`. Unlike many providers, MailerLite returns the secret again whenever you list or fetch the webhook, so you can recover it later. Update a webhook with `PUT /api/webhooks/{webhook_id}` and remove it with `DELETE`.

Set `batchable` to `true` for any webhook that includes `campaign.open`, `campaign.click` or `subscriber.deleted`. The event table marks batching as required for all three, while the create parameter table names only the two campaign events; setting it for all three satisfies both.

The API documents no test-event endpoint. To test, trigger a real event, such as adding a subscriber to a test group.

## Securing MailerLite webhooks

Read the `Signature` header, compute an HMAC-SHA256 of the raw request body with the webhook's secret as the key, encode it as hex, and compare in constant time. MailerLite's own sample is PHP's `hash_hmac('sha256', $jsonPayload, $secret)`, which returns lowercase hex.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyMailerLiteSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/mailerlite', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.MAILERLITE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyMailerLiteSignature(req.body, req.get('signature'), secret)) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(req.body.toString('utf8'));
  // Batched deliveries wrap events in an array; single deliveries are the event itself.
  const events = Array.isArray(body.events) ? body.events : [body];
  for (const event of events) {
    const name = event.event ?? event.type; // flat payloads use "event", nested ones "type"
    // Queue the event for processing, keyed on name.
  }
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_mailerlite_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_mailerlite_signature(raw_body, request.headers.get("signature"),
#                             os.environ["MAILERLITE_WEBHOOK_SECRET"])

```

Each webhook has its own secret. If you send several webhooks to one URL, look up the secret for the webhook that sent the request, or use a separate path per webhook so you know which secret to use.

> **Make MailerLite webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies MailerLite's `Signature` header, answers MailerLite straight away, and durably queues each subscriber and campaign event before your service sees it.

## MailerLite webhook limitations and pain points

### Three seconds and three retries

**The Problem:** Events go missing after a short outage or a slow deploy.

**Why It Happens:** MailerLite treats any response slower than 3 seconds as a failure. A failed event is retried after 10 seconds, 100 seconds and 1000 seconds, and then it is gone. That whole window is under 20 minutes, so an outage longer than that loses events.

**Workarounds:**

* Verify, queue and return `200` straight away. MailerLite says not to do heavy processing in the request cycle.
* After an outage, reconcile against the subscribers API for the affected period.

**How Hookdeck Can Help:** Hookdeck answers MailerLite immediately and stores each event, then retries towards your service on a schedule you control, for as long as you need. An outage on your side no longer runs out MailerLite's retry budget.

### Two shapes, two field names

**The Problem:** Half your events are routed to the "unknown" branch.

**Why It Happens:** Flat payloads name the event in `event`; nested payloads name it in `type`. A router that reads only one of them handles seven of the fourteen events.

**Workarounds:**

* Read `event`, then `type`.
* For batched deliveries, iterate `events[]` and apply the same rule to each element.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on either field, so you can send each event family to the right destination without routing code.

### Business errors cause duplicates

**The Problem:** The same subscriber event is processed more than once.

**Why It Happens:** MailerLite retries any non-`2xx`, so a handler that returns `4xx` because a record is missing, or `5xx` because a downstream call failed, gets the same event again. MailerLite's docs warn about exactly this.

**Workarounds:**

* Return `2xx` once the signature is valid, and handle business failures in your own queue.
* Make handlers idempotent. There is no event ID, so key on the event name, the subscriber `id` and its `updated_at`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeats based on the body fields you choose, such as `event`, `type` and `subscriber.id`.

### No replay protection

**The Problem:** A captured request can be sent again later and still verifies.

**Why It Happens:** Only the body is signed, and there is no timestamp or nonce.

**Workarounds:**

* Make handlers idempotent so a replayed event has no effect.
* For sensitive actions, re-read the subscriber from the API before acting.

**How Hookdeck Can Help:** Hookdeck logs every request it receives, headers and body, so a suspicious repeat is easy to spot and trace.

## Best practices

### Verify against the raw body

The signature covers the JSON exactly as MailerLite sent it. In Express, mount `express.raw()` on the webhook route and parse only after verification. In Next.js, use `await request.text()`. In FastAPI, read `await request.body()`.

### Handle batched and single deliveries in one code path

Normalise every request into a list of events (`body.events` when present, otherwise `[body]`) and process each one the same way.

### Keep high-volume events on their own webhook

`campaign.open` and `campaign.click` can arrive in large numbers after a send, and they require batching. Put them on a separate webhook, and ideally a separate endpoint, from subscriber lifecycle events, so a burst of opens does not slow down sign-up handling.

### Acknowledge fast and process asynchronously

With a 3 second limit, any database write or API call in the request path is a risk. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

MailerLite signs every webhook with a hex HMAC-SHA256 of the JSON body in the `Signature` header, keyed with a per-webhook secret you can read back from the API. Verify the raw bytes, read the event name from `event` or `type`, unwrap batched deliveries, and answer within 3 seconds, because three quick retries are all MailerLite will try.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every MailerLite request, and holds each event until your service has processed it, so a slow deploy no longer costs you subscriber events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle MailerLite webhooks reliably in minutes.