# Webhooks

> Receive a JSON POST each time something happens in your Emelia campaigns: email sent, opened, clicked, replied, bounced, LinkedIn invitation accepted, WhatsApp reply, campaign finished.

Source: https://docs.emelia.io/docs/webhooks

A webhook is a URL that Emelia calls with a JSON `POST` each time a selected event happens in a campaign: a reply, an acceptance, a bounce. Use it to update your CRM, alert your team or route replies the moment they arrive.

## Register a webhook

Webhooks are registered on a campaign, or on all your campaigns at once. Today they are created from the **Make** integration of Emelia (the "Watch Campaign Activities" and "Watch Campaign Ending" modules), and listed, tested and deleted in the Emelia app, in **Settings, API & Webhooks**.

> **Creating webhooks by API.** The registration routes are not part of this reference yet. If you need to register webhooks from your own code, contact [contact@emelia.io](mailto:contact@emelia.io).

## List your webhooks

[`GET /webhook`](https://docs.emelia.io/docs/webhooks/list-webhooks.md)

**curl**

```bash
curl https://api.emelia.io/webhook \
  -H "Authorization: $EMELIA_API_KEY"
```

**JavaScript**

```js
const res = await fetch('https://api.emelia.io/webhook', {
  headers: { Authorization: process.env.EMELIA_API_KEY },
});
const { webhooks } = await res.json();
for (const hook of webhooks) console.log(hook.campaignName, hook.url, hook.events.join(','));
```

**Python**

```python
import os
import requests

res = requests.get(
    "https://api.emelia.io/webhook",
    headers={"Authorization": os.environ["EMELIA_API_KEY"]},
)
for hook in res.json()["webhooks"]:
    print(hook["campaignName"], hook["url"], hook["events"])
```

```json title="Response"
{
  "webhooks": [
    {
      "id": "6702c1d4e5f6a7b8c9d0e1f2",
      "typeOfCampaign": "advanced",
      "campaignId": "6651f0c2a1b2c3d4e5f60718",
      "campaignName": "Q4 SaaS founders",
      "url": "https://hooks.yourcompany.com/emelia",
      "events": ["MAIL_REPLIED", "LINKEDIN_REPLIED"],
      "status": "active"
    }
  ]
}
```

There is one entry per campaign: a URL registered on all your campaigns appears once for each of them. `typeOfCampaign` is `advanced` for the campaigns of the current app, `email` or `linkedin` for [legacy campaigns](https://docs.emelia.io/docs/campaigns/legacy.md).

## Events of a campaign

These are the event names of the campaigns of the current app. The payload of each is on its page: [Campaign activity](https://docs.emelia.io/docs/webhooks/campaign-activity.md) and [Campaign finished](https://docs.emelia.io/docs/webhooks/campaign-finished.md).

| Event                                                                    | When                                                                                                                        |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `EMAIL_SENT`                                                             | An email step was sent. The name is `EMAIL_SENT`, not `MAIL_SENT`.                                                          |
| `OPENED`, `CLICKED`                                                      | The tracking pixel loaded, or a tracked link was clicked. Needs tracking on the campaign. Delivered 30 to 60 seconds later. |
| `MAIL_REPLIED`                                                           | First email reply of the contact, with `reply`, `sentiment`, `sender` and `messageId`.                                      |
| `BOUNCED`                                                                | A bounce was detected. No `campaign` field, `step` is 0.                                                                    |
| `UNSUBSCRIBED`                                                           | The contact unsubscribed.                                                                                                   |
| `VISITED`, `INVITED`, `ACCEPTED`, `MESSAGE_SENT`, `INMAIL_SENT`, `LIKED` | LinkedIn actions. `MESSAGE_SENT` carries the text in `content`.                                                             |
| `LINKEDIN_REPLIED`                                                       | LinkedIn reply, with `reply` and `sentiment`.                                                                               |
| `WHATSAPP_MESSAGE_SENT`, `WHATSAPP_REPLIED`                              | WhatsApp message sent, reply received.                                                                                      |
| `CAMPAIGN_ENDING`                                                        | The campaign was closed by the daily job, see [Campaign finished](https://docs.emelia.io/docs/webhooks/campaign-finished.md).                        |

Never sent for these campaigns: `SENT`, `MAIL_SENT`, `FOLLOWED`, `TASK_COMPLETED`, `TASK_FAILED`. The events of legacy campaigns have their own pages: [Legacy email activity](https://docs.emelia.io/docs/webhooks/legacy-email-activity.md), [Legacy LinkedIn activity](https://docs.emelia.io/docs/webhooks/legacy-linkedin-activity.md).

A `MAIL_REPLIED` delivery looks like this:

```json
{
  "event": "MAIL_REPLIED",
  "campaign": "Q4 SaaS founders",
  "campaignId": "6651f0c2a1b2c3d4e5f60718",
  "senderName": "Marie Martin",
  "sender": "marie.martin@yourcompany.com",
  "contact": {
    "id": "6703d2e5f6a7b8c9d0e1f2a3",
    "firstName": "Claire",
    "lastName": "Fontaine",
    "email": "claire.fontaine@nexora.io",
    "company": "Nexora"
  },
  "date": "2026-10-13T10:20:00.000Z",
  "step": 0,
  "reply": "Hi Marie, sounds interesting. Can we talk on Tuesday?",
  "sentiment": { "classification": "INTERESTED", "score": 0.92 },
  "messageId": "<CAF7x2y3z@mail.nexora.io>"
}
```

* `contact` starts with the custom variables of the contact, then the standard fields. `company` is the first custom variable whose name contains "company".
* `sentiment` is the AI classification of the reply: `INTERESTED`, `NOT_INTERESTED`, `NEUTRAL`, `OUT_OF_OFFICE`, `HOSTILE`, `DELEGATION`, `INCOMPREHENSION` or `QUESTION_ASK`, with a `score`. It is present only when reply classification runs on your account.
* `date` is an ISO 8601 date in UTC, usually the time Emelia processed the event.
* Absent fields are omitted, never `null`.

## Receive the events

Answer fast with a `2xx`, then do the work in the background.

**curl**

```bash
# Simulate a delivery to test your endpoint
curl -X POST https://hooks.yourcompany.com/emelia \
  -H "Content-Type: application/json" \
  -d '{"event": "MAIL_REPLIED", "campaign": "Q4 SaaS founders", "contact": {"email": "claire.fontaine@nexora.io"}, "date": "2026-10-13T10:20:00.000Z", "step": 0, "reply": "Can we talk on Tuesday?"}'
```

**JavaScript**

```js
import express from 'express';

const app = express();
app.use(express.json());

// Keep the path hard to guess: deliveries are not signed.
app.post('/emelia/9f2c4e1a7b', (req, res) => {
  res.sendStatus(200); // answer first
  const { event, contact, reply, sentiment } = req.body;
  if (event === 'MAIL_REPLIED' || event === 'LINKEDIN_REPLIED') {
    console.log(`${contact.email ?? contact.linkedinUrlProfile} replied (${sentiment?.classification}): ${reply}`);
  }
});

app.listen(3000);
```

**Python**

```python
from flask import Flask, request

app = Flask(__name__)

# Keep the path hard to guess: deliveries are not signed.
@app.post("/emelia/9f2c4e1a7b")
def emelia_webhook():
    payload = request.get_json(force=True)
    if payload.get("event") in ("MAIL_REPLIED", "LINKEDIN_REPLIED"):
        contact = payload.get("contact", {})
        print(contact.get("email"), "replied:", payload.get("reply"))
    return "", 200
```

## Delivery rules

Build your handler for these rules:

* **No signature.** Deliveries carry `Content-Type: application/json` and nothing else: no secret, no signature header. Use a URL that is hard to guess, and treat the payload as a hint: confirm important facts with the API before acting on them.
* **No retry on HTTP errors.** Your response is ignored. A few events are retried on network errors only. An endpoint that is down misses the events.
* **No ordering.** Events come from different services: an `OPENED` can arrive after the `MAIL_REPLIED`.
* **No event id.** To deduplicate, build a key from `event`, `campaign`, the contact and `step`. A URL registered twice on a campaign receives everything twice.
* **No timeout on Emelia's side**, but answer in a few seconds anyway.

The [LinkedIn scraper](https://docs.emelia.io/docs/linkedin-scraper.md) has its own, separate webhooks (`FINISHED` and `ERROR`).
