# Guide to Mautic Webhooks: Features and Best Practices

Mautic webhooks send your other systems what happens to your contacts in Mautic: a new contact is identified, a form is submitted, an email is sent or opened, a landing page is visited, or a contact's points change. Mautic is the open-source marketing automation platform, and webhooks are how its contact activity reaches a CRM, a data warehouse or an internal tool without polling its API.

This guide covers how Mautic webhooks are configured and signed, how the payload is structured in real-time and queued modes, the events you can subscribe to, how Mautic handles failures, and the best practices for running a Mautic receiver in production.

## What are Mautic webhooks?

A Mautic webhook has a name, a POST URL, a set of events and a secret key. When one of those events happens, Mautic sends a JSON `POST` to the URL with a `Webhook-Signature` header: a base64 HMAC-SHA256 of the raw body, keyed with the secret.

Mautic has two delivery modes, set once for the whole instance. In real-time mode, Mautic sends each event as it happens, one event per request. In queued mode, Mautic stores events in its database and a cron job sends them in batches, so one request can carry many events of several types.

The header name trips people up. `Webhook-Signature` is also the header name the Standard Webhooks specification uses, but Mautic's value is a bare base64 HMAC of the body. There is no `webhook-id` or `webhook-timestamp`, nothing else is signed, and a Standard Webhooks library will reject every Mautic delivery.

Mautic is open source and usually self-hosted, so every installation is its own sender. Deliveries come from wherever your Mautic instance runs.

## Mautic webhook features

| Feature | Details |
| --- | --- |
| Configuration | Mautic admin: the cog menu, then **Webhooks** > **New** |
| Delivery modes | **Process Events Immediately** (real time) or **Queue Events Only - Process Via CLI Command** (batched by cron), set in **Configuration > Webhook Settings** |
| Batch order | Chronological or reverse chronological, for queued mode |
| Signature header | `Webhook-Signature` |
| Scheme | HMAC-SHA256, standard base64 |
| Signed content | The raw JSON body only |
| Secret | One per webhook, randomly generated by default and editable in the webhook form |
| Other headers | `Content-Type: application/json`, `X-Origin-Base-URL` (your instance's site URL) and `User-Agent: Webhook`, per Mautic's source |
| Replay window | None. No timestamp is signed |
| Event ID | None |
| Retries | Queued mode only: failed items are retried by later cron runs after `webhook_retry_delay` (default 3,600 seconds) |
| Auto-unpublish | A `410` response unpublishes the webhook immediately. A webhook with no successes in its recent log (default 100 entries) is unpublished unless auto-unpublish is disabled |
| Testing | **Send Test Payload** in the webhook form |

## Common events

Mautic documents 13 event types:

| Area | Event type | Fires when |
| --- | --- | --- |
| Contacts | `mautic.lead_post_save_new` | Mautic identifies a contact |
|  | `mautic.lead_post_save_update` | A contact is updated |
|  | `mautic.lead_points_change` | A contact's points change |
|  | `mautic.lead_post_delete` | A contact is deleted |
|  | `mautic.lead_channel_subscription_changed` | A contact's channel subscription status changes |
|  | `mautic.lead_company_change` | A contact is added to or removed from a company |
| Companies | `mautic.company_post_save` | A company is created or updated |
|  | `mautic.company_post_delete` | A company is deleted |
| Email and SMS | `mautic.email_on_send` | Mautic sends an email to a contact |
|  | `mautic.email_on_open` | A contact presumably opens an email |
|  | `mautic.sms_on_send` | Mautic sends a text message to a contact |
| Forms and pages | `mautic.form_on_submit` | A contact submits a Mautic form |
|  | `mautic.page_on_hit` | A contact visits a landing page or a tracked third-party page |

The body has no fixed envelope. Each top-level key is an event type, and its value is an array of events of that type:

```json
{
  "mautic.form_on_submit": [
    {
      "submission": {
        "id": 7,
        "form": { "id": 2, "name": "Contact us", "alias": "contactus" },
        "lead": { "id": 42 },
        "results": { "email": "jane@example.com" },
        "referer": "https://example.com/contact",
        "dateSubmitted": "2026-10-22T09:30:00+00:00"
      },
      "timestamp": "2026-10-22T09:30:00+00:00"
    }
  ]
}

```

In real-time mode you get one key with one item. In queued mode one request can hold several keys, each with several items. Every item carries its own `timestamp`, the time Mautic queued the event, in ISO 8601 format. The rest of the item depends on the event: `contact` for most contact, email and SMS events, `points.old_points` and `points.new_points` for points changes, `submission` for forms, `company` for company events, and just an `id` for deletions.

Mautic's documentation gives property tables for each event but no example deliveries, and the page itself says it "requires a major update". Treat the property tables as a guide, and capture a real payload from your own Mautic version with **Send Test Payload** before you build on it.

## Setting up Mautic webhooks

1. If you want batched delivery, open **Configuration** from the cog menu, go to the **Webhook Settings** tab, and set **Queue Mode** to **Queue Events Only - Process Via CLI Command**. Choose the order of queued events. Leave it on **Process Events Immediately** for real-time delivery.
2. Open **Webhooks** from the cog menu and click **New**.
3. Enter a name and the **Webhook POST URL**, and select the events that should trigger the webhook.
4. Keep the generated secret, or replace it with your own, and store it server-side, for example as `MAUTIC_WEBHOOK_SECRET`.
5. Click **Apply**, then **Send Test Payload** to send an example request.

In queued mode, nothing is sent until the cron job runs. Schedule it on the Mautic server:

```bash
php bin/console mautic:webhooks:process

```

Mautic recommends one webhook per app or script, so that each secret is exposed in as few places as possible.

Mautic 7 refuses to post to private network addresses unless they're allowed in the `webhook_allowed_private_addresses` setting, so a `localhost` URL won't work for local testing there. Use a public tunnel or a Hookdeck source URL instead.

If you use Hookdeck, create a source with the Mautic source type, set the webhook's secret on it, and use the Hookdeck source URL as the Webhook POST URL.

## Securing Mautic webhooks

Compute an HMAC-SHA256 of the raw request body, with the secret as a plain string, encode it as standard base64, and compare it in constant time with `Webhook-Signature`. Mautic's own sender does exactly this: `base64_encode(hash_hmac('sha256', $jsonPayload, $secret, true))`.

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

function verifyMauticSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(rawBody).digest('base64'), 'utf8');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/mautic', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyMauticSignature(req.body, req.get('webhook-signature'), process.env.MAUTIC_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  for (const [eventType, events] of Object.entries(payload)) {
    for (const event of events) {
      // Handle each event of each type. Persist first, then process async.
    }
  }
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_mautic_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = base64.b64encode(hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest())
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_mautic_signature(raw_body, request.headers.get("webhook-signature"),
#                         os.environ["MAUTIC_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `mautic-test-secret` and this exact body:

```
{"mautic.lead_post_delete":[{"id":42,"timestamp":"2026-10-22T09:30:00+00:00"}]}

```

The `Webhook-Signature` header should be:

```
q9x+uMz7H8YMlxcXzEPO0J1jngWkYIyJcCI0dAVAGCc=

```

> **Make Mautic webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Webhook-Signature` header on every Mautic request, accepts deliveries immediately, and retries them to your service, so a failed real-time delivery is no longer a lost event.

## Mautic webhook limitations and pain points

### Real-time deliveries are not retried

**The Problem:** If your endpoint is down when a contact submits a form, that event never arrives.

**Why It Happens:** In real-time mode, Mautic sends the event once while handling the action that triggered it. Its source only stores and retries events in queued mode, so a failed real-time delivery is logged and dropped.

**Workarounds:**

* Switch to queued mode if you can accept the delay of a cron interval. Failed queued items are retried by later cron runs once `webhook_retry_delay` (an hour by default) has passed.
* Reconcile from the Mautic API after an outage.

**How Hookdeck Can Help:** Put Hookdeck in front of your service. Hookdeck accepts every real-time delivery as soon as Mautic sends it and retries delivery to your service until it succeeds, so your own downtime no longer loses events.

### A Standard Webhooks verifier rejects everything

**The Problem:** You use a Standard Webhooks library because the header is called `Webhook-Signature`, and every request fails.

**Why It Happens:** Mautic signs only the body, with the secret as a plain string, and sends a bare base64 digest. Standard Webhooks expects `webhook-id` and `webhook-timestamp` headers, a `v1,` prefix and a base64-encoded secret.

**Workarounds:**

* Verify with a plain HMAC-SHA256 of the raw body, as in the code above.
* Don't decode the secret, and don't add any prefix.

**How Hookdeck Can Help:** Hookdeck's Mautic source type uses Mautic's scheme rather than Standard Webhooks, so verification works without custom code.

### One request, many events

**The Problem:** Your handler processes the first event and drops the rest, or breaks when two event types arrive together.

**Why It Happens:** In queued mode, Mautic aggregates events: every top-level key is an event type, and each holds an array. Even in real-time mode the single event is wrapped in an array.

**Workarounds:**

* Always iterate over every key and every item.
* Return `200` only after all items are persisted, so a failure doesn't drop part of a batch.

**How Hookdeck Can Help:** Hookdeck stores each batched request as one event, so if your handler fails partway through a batch, you can fix the bug and replay the whole request from Hookdeck.

### Endpoints get unpublished

**The Problem:** Webhooks stop firing and you only notice later.

**Why It Happens:** Mautic unpublishes a webhook immediately when your endpoint returns `410`, and also when its recent log (100 entries by default) contains no successful responses, unless auto-unpublish is disabled.

**Workarounds:**

* Never return `410` from your webhook route unless you mean "stop sending".
* Monitor whether your webhooks are still published, and fix endpoints before their failure log fills up.

**How Hookdeck Can Help:** Hookdeck answers Mautic with a `2xx` as soon as it has stored the request, so failures in your own service don't count against the webhook, and Hookdeck's issues and alerts tell you about delivery problems on your side.

### No event ID and no replay protection

**The Problem:** You can't reliably tell a retried batch from a new one, and a captured request can be replayed.

**Why It Happens:** Items carry no event ID, and the signature covers only the body. Queued retries resend the same items.

**Workarounds:**

* Build an idempotency key from the event type, the main object's ID (contact, submission, company) and the item's `timestamp`.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated requests before they reach your service, based on the fields you choose.

## Best practices

### Verify against the raw body

The signature covers the exact JSON bytes Mautic sent. Read the raw body (`express.raw()`, `await request.body()`) and parse only after verification.

### Iterate over every event type and item

Loop over `Object.entries(payload)` and then over each array. Don't assume one key, and don't assume one item per key.

### Treat `mautic.email_on_open` with care

Mautic itself says a contact "presumably" opens an email: the event depends on a tracking pixel, so mail clients that block images miss opens and privacy proxies can trigger them. Don't drive critical logic from it.

### Pick the delivery mode deliberately

Real-time mode is immediate but has no retries. Queued mode retries but adds the cron interval to every event. If you need both speed and reliability, use real-time mode behind Hookdeck.

### Deduplicate deliveries

Use a composite key such as event type, object ID and `timestamp`, and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

In Mautic's source, real-time mode sends the request inline while Mautic handles the action that caused it, so a slow endpoint slows Mautic down too. Persist the events, return `200`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Mautic signs every webhook with a base64 HMAC-SHA256 of the raw body in `Webhook-Signature`, keyed with the plain secret, whatever the header name suggests. Verify against the raw bytes, loop over every event type and item, build your own idempotency keys, and choose between real-time delivery without retries and queued delivery with cron latency.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Mautic signature, and retries delivery to your service, so real-time Mautic webhooks get the reliability that Mautic only gives queued ones.

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