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_ | A subscriber is added to a group |
subscriber.removed_ | A subscriber is removed from a group |
subscriber.bounced | An email address bounces |
subscriber.automation_ | A subscriber starts an automation |
subscriber.automation_ | 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:
{
"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:
{
"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_ and subscriber.removed_ 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:
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_. 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_ 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_, which returns lowercase hex.
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:
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 verifies MailerLite's
Signatureheader, 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
200straight 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, thentype. - For batched deliveries, iterate
events[]and apply the same rule to each element.
How Hookdeck Can Help: Hookdeck 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
2xxonce 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
idand itsupdated_at. See our guide to webhook idempotency.
How Hookdeck Can Help: Hookdeck's 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.
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 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 for free and handle MailerLite webhooks reliably in minutes.