# Get webhooks for email events

Source: https://help.meisa.io/articles/set-up-webhooks

A webhook makes Meisa send a POST request to your app when something happens, like an email bouncing or a contact being tagged. Go to **Settings**, open **API keys and webhooks**, switch to the **Webhooks** tab, click **Add webhook**, enter your URL and pick the events.

## Add a webhook

1. In the sidebar, click **Settings**, then **API keys and webhooks** under **Connect**.
2. Open the **Webhooks** tab.
3. Click **Add webhook**.
4. Fill in:
   - **Name**, for example "Billing sync".
   - **Address**: the HTTPS URL in your app, for example `https://yourapp.com/hooks/meisa`.
   - **Events**: tick the events you want (see below).
5. Click **Add webhook**.
6. Copy the secret shown in **Your signing secret** and store it in your app's secrets. You need it to check that requests really come from Meisa.

## Events you can subscribe to

| Group | Events |
|---|---|
| Email | `email.sent`, `email.delivered`, `email.opened`, `email.clicked` (Link clicked), `email.bounced`, `email.complained` (Marked as spam) |
| Contacts | `contact.created` (Contact added), `contact.updated`, `contact.tagged` (Tag added), `contact.untagged` (Tag removed) |
| Sequences | `sequence.enrolled` (Added to a sequence), `sequence.completed` (Finished a sequence), `sequence.unenrolled` (Removed from a sequence) |
| Broadcasts | `campaign.sent` (Broadcast started), `campaign.completed` (Broadcast finished) |

A common setup is `email.bounced` and `email.complained`, so your app can flag bad addresses on its side too.

## What Meisa sends

Each delivery is a POST with a JSON body:

```json
{
  "event": "email.bounced",
  "timestamp": "2026-11-03T10:15:00.000000+00:00",
  "data": {
    "email_send_id": "9b1c...",
    "contact_id": "3f2a...",
    "bounce_type": "Permanent",
    "bounce_subtype": "General",
    "auto_unsubscribed": true
  }
}
```

And these headers:

- `X-Webhook-Signature`: HMAC-SHA256 of the raw request body, using your signing secret, as hex.
- `X-Webhook-Timestamp`: when it was sent.
- `X-Webhook-Event`: the event name.
- `User-Agent: Meisa-Webhook/1.0`.

## Verify the signature

Always check the signature before trusting a request. Compute the HMAC over the raw body exactly as received, before parsing the JSON.

Node.js (Express):

```js
const crypto = require('crypto');

app.post('/hooks/meisa', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.MEISA_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const received = req.get('X-Webhook-Signature') || '';
  const ok = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body);
  // handle event.event and event.data
  res.status(200).end();
});
```

Python:

```python
import hmac, hashlib, os

def is_valid(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(os.environ["MEISA_WEBHOOK_SECRET"].encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

## Retries

Meisa counts any response below `400` as delivered. Respond quickly (within 30 seconds) and do slow work in a background job. If the request fails or times out, Meisa retries up to 5 attempts in total, waiting longer each time (about 1, 2, 4 and 8 minutes). Because of retries, the same event can arrive twice, so make your handler safe to run more than once.

## Test and monitor

From the webhook's menu:

- **Send a test**: sends a sample request with the event `test`.
- **See deliveries**: the latest 50 deliveries with status codes, to debug failures.
- **Edit**, or switch the webhook off with its toggle.
- **Change signing secret**: the old secret stops working at once, so update your app right after.
- **Delete**.

## Troubleshooting

- **Signature never matches**: you're hashing the parsed and re-serialized JSON. Hash the raw body bytes instead.
- **Deliveries fail with a timeout**: return `200` first, then process the event in the background.
- **No deliveries at all**: check the webhook is on and the event is ticked.

## Related articles

- [Create and manage API keys](/articles/create-an-api-key)
- [Bounces and complaints](/articles/bounces-and-complaints)
- [Send transactional emails with triggers](/articles/send-transactional-email)
- [API quickstart: your first API call](/articles/api-quickstart)
