# Guide to Unipile Webhooks: Features and Best Practices

Unipile webhooks tell your application when something happens in the messaging and email accounts your users have connected: a LinkedIn or WhatsApp message arrives, an email is sent or received, a tracked email is opened, a LinkedIn invitation is accepted, or an account needs reconnecting. If you build outreach, CRM or inbox tools on Unipile's unified API, webhooks are how those changes reach you without polling each provider.

This guide covers how Unipile webhooks are created, how to authenticate deliveries that Unipile does not sign, the payloads for each kind of webhook, how Unipile retries, and the best practices for production.

## What are Unipile webhooks?

Unipile is a single API over LinkedIn, WhatsApp, Instagram, Messenger, Telegram, X, Google, Microsoft and IMAP accounts. Each customer gets their own API host, which Unipile calls a DSN.

A Unipile webhook has a `source`, which decides what kind of data it sends: `messaging`, `email`, `email_tracking`, `users` or `account_status`. You create one webhook per source, each with its own URL, and Unipile sends an HTTP `POST` to it when an event happens.

Unipile does not sign webhooks. There is no signature header, HMAC or signing secret. The only way to authenticate a delivery is to add a secret header of your own to the webhook when you create it.

## Unipile webhook features

| Feature | Details |
| --- | --- |
| Configuration | Unipile dashboard, or `POST https://{YOUR_DSN}/api/v1/webhooks` |
| Subscription | One `source` per webhook, with an optional `events` list |
| Authentication | No signature. Add a static header through the `headers` array |
| Content type | Dashboard webhooks send JSON with a JSON content type. API webhooks send no content type unless you add one |
| Event field | `event` in the body, except account status bodies |
| Response window | A `200` within 30 seconds |
| Retries | Five retry attempts, with an increasing delay between them |
| Delivery ID | None |
| Source IPs | Not published |

## Common events

| `source` | Events | Fires when |
| --- | --- | --- |
| `messaging` | `message_received` (default), `message_read`, `message_reaction`, `message_edited`, `message_deleted`, `message_delivered` | A chat message is received or sent, read, reacted to, edited, deleted or delivered |
| `email` | `mail_received` (default), `mail_sent`, `mail_moved` | An email is received, sent or moved |
| `email_tracking` | `mail_opened` (default), `mail_link_clicked` | A tracked email you sent is opened, or a link in it is clicked |
| `users` | `new_relation` | A new LinkedIn connection appears, including accepted invitations |
| `account_status` | `OK`, `CREDENTIALS`, `ERROR`, `STOPPED`, `CONNECTING`, `DELETED`, `CREATION_SUCCESS`, `RECONNECTED`, `SYNC_SUCCESS` and others | A connected account's status changes |

Messages you send are delivered as `message_received` too, whether they were sent from the API or from another device. To tell them apart, compare `account_info.user_id` with `sender.attendee_provider_id`. Email bodies have an `origin` field set to `unipile` or `external`.

`new_relation` is not real time. Unipile polls the LinkedIn relation list at random intervals, so the event can arrive up to 8 hours after the connection is accepted.

Most bodies are a flat object with an `event` field. Here is Unipile's `new_relation` example:

```json
{
  "event": "new_relation",
  "account_id": "SDF4tGaPSPSzNe1D1xsOs",
  "account_type": "LINKEDIN",
  "webhook_name": "",
  "user_full_name": "Satya Nadella",
  "user_provider_id": "ACoAAAEkwwAB9KEc2TrQgOLEQ-vzRyZeCDyc6DQ",
  "user_public_identifier": "satyanadella",
  "user_profile_url": "https://www.linkedin.com/in/satyanadella/",
  "user_picture_url": "https://media.licdn.com/dms/image/..."
}

```

Messaging bodies carry `account_id`, `account_type`, `account_info`, `chat_id`, `message_id`, `message`, `timestamp`, `sender`, `attendees` and `attachments`, plus `reaction` and `reaction_sender` on `message_reaction`. Email bodies carry `email_id`, `from_attendee`, `to_attendees`, `subject`, `body`, `folders` and `tracking_id`. Tracking bodies carry `event_id`, `tracking_id`, `email_id`, `ip`, `user_agent` and, for clicks, `url`.

Account status bodies are different. They have no `event` field at all:

```json
{
  "AccountStatus": {
    "account_id": "h_EKCy2lRLef5NzHp0iw4A",
    "account_type": "LINKEDIN",
    "message": "CREDENTIALS"
  }
}

```

`CREDENTIALS` means the account's sync stopped because its credentials are invalid or missing, for example because the user changed their password or revoked access. While an account is not `OK`, Unipile stops sending new-message webhooks for it. By default, an `account_status` webhook covers the usual statuses except `CONNECTING`, `OK` and `STOPPED`, so add those to `events` if you need them.

## Setting up Unipile webhooks

Generate a secret first, for example with `openssl rand -hex 32`, and store it as `UNIPILE_WEBHOOK_SECRET`. Then create a webhook through the API, adding the secret as a header. Add a `Content-Type` header at the same time, because API-created webhooks don't send one:

```bash
curl --request POST \
  --url "https://$UNIPILE_DSN/api/v1/webhooks" \
  --header "X-API-KEY: $UNIPILE_API_KEY" \
  --header "content-type: application/json" \
  --data '{
    "request_url": "https://example.com/webhooks/unipile",
    "source": "messaging",
    "name": "Messages",
    "headers": [
      { "key": "Content-Type", "value": "application/json" },
      { "key": "Unipile-Auth", "value": "YOUR_GENERATED_SECRET" }
    ]
  }'

```

`Unipile-Auth` is the name Unipile uses in its example. The name is up to you. Repeat the call for each `source` you need, with the same or a different URL.

You can also create webhooks in the Unipile dashboard. Unipile's docs show custom headers only for API-created webhooks, so if the dashboard form has no place for a header, create the webhook through the API instead.

For local development, `hookdeck listen 3000 unipile --path /webhooks/unipile` gives you a public HTTPS URL to use as `request_url`.

## Securing Unipile webhooks

Nothing is signed, so check the header you configured. Compare it in constant time, fail closed if the secret is not configured, and check the type and length first, because `crypto.timingSafeEqual` throws on buffers of different lengths. Since there's no body signature, the order of parsing and checking doesn't matter. Reading the raw body is still useful, because API-created webhooks arrive without a JSON content type.

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

const SECRET_HEADER = 'unipile-auth'; // the name you used in the headers array

function verifyUnipileSecret(provided, expected) {
  if (typeof provided !== 'string' || !expected) return false;
  const a = Buffer.from(provided, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

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

  if (!verifyUnipileSecret(req.get(SECRET_HEADER), secret)) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(req.body.toString('utf8'));

  if (body.AccountStatus) {
    // Account status: body.AccountStatus.message is OK, CREDENTIALS, ...
  } else {
    // Everything else: route on body.event
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_unipile_secret(provided, expected) -> bool:
    if not isinstance(provided, str) or not expected:
        return False
    return hmac.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))

# In FastAPI:
# verify_unipile_secret(request.headers.get("unipile-auth"), os.environ.get("UNIPILE_WEBHOOK_SECRET"))

```

This proves the caller knows your secret. It does not prove the body is unchanged, and a captured request can be replayed, because the header is the same on every delivery.

> **Make Unipile webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header before anything reaches your code, answers Unipile inside its 30 second window, and durably queues every message, email and account event.

## Unipile webhook limitations and pain points

### No signature, only a header you add

**The Problem:** A webhook created without a secret header accepts anything sent to its URL, and even with one, a single leaked request exposes the secret.

**Why It Happens:** Unipile issues no signing secret. Its only documented authentication is a header you put in the webhook's `headers` array, which it repeats verbatim on every delivery.

**Workarounds:**

* Add a long random secret header to every webhook, and reject requests without it.
* Serve the endpoint over HTTPS only, and never log the header.
* To rotate, accept both the old and new values while you re-create the webhooks.

**How Hookdeck Can Help:** Hookdeck's Unipile source type checks a header name and value you choose, for example `Unipile-Auth` and your secret, and stops requests that fail before they reach your service. Hookdeck compares the whole header value, so enter it exactly as you set it in Unipile.

### Two payload shapes

**The Problem:** A handler that switches on `event` silently ignores every account status change.

**Why It Happens:** Account status webhooks send `{"AccountStatus": {...}}` with the status in `AccountStatus.message`, while every other source sends a flat object with `event`.

**Workarounds:**

* Check for `AccountStatus` first, then fall back to `event`.
* Use separate URLs for `account_status` and the other sources if that keeps your code simpler.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `AccountStatus.message` or on `event`, so account health and message traffic can go to different services from one source.

### A short retry window

**The Problem:** Messages received during a deploy or an outage are lost.

**Why It Happens:** Unipile expects a `200` within 30 seconds. Anything else gets five retry attempts with increasing delays. Unipile doesn't publish the delays, so you can't tell how long an outage it covers.

**Workarounds:**

* Acknowledge first, then process.
* After an outage, re-read recent chats and emails through the API for the affected accounts.

**How Hookdeck Can Help:** Hookdeck accepts each delivery and stores it, then [retries delivery](/docs/retries) to your service on a schedule you set, with replay for anything still failing. Unipile's five attempts no longer bound how long you can be down.

### Disconnected accounts go quiet

**The Problem:** Messages stop arriving for one user, and no error appears anywhere in your app.

**Why It Happens:** When an account's status is anything other than `OK`, Unipile stops sending new-message webhooks for it. LinkedIn and Instagram sessions also expire after a year, and providers can restrict accounts that make too many sensitive requests.

**Workarounds:**

* Subscribe to `account_status` and prompt the user to reconnect on `CREDENTIALS`.
* After `RECONNECTED`, expect the messages that arrived while the account was disconnected.

**How Hookdeck Can Help:** Route `CREDENTIALS` and `ERROR` statuses to an alerting destination with a Hookdeck [filter](/docs/filters), so a dead account becomes a notification rather than silence.

## Best practices

### Always send a JSON content type

Add `Content-Type: application/json` to the `headers` of every API-created webhook, and parse the raw body yourself in case one is missing.

### Deduplicate on payload IDs

There is no delivery ID header. Use `message_id` for messaging, `email_id` for email, `event_id` for tracking, and `account_id` with the status for account events. Combine each with `event` where one ID can appear in several events, such as a message that is received and later read. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Separate your own messages from incoming ones

Sent messages and emails arrive as `message_received` and `mail_received`. Check `sender.attendee_provider_id` against `account_info.user_id`, or `origin` for email, before you trigger replies or notifications, or you may react to your own messages.

### Don't rely on `new_relation` for timing

Accepted invitations can take up to 8 hours to arrive. If you need to react sooner to an invitation that included a note, Unipile suggests watching for the new chat it creates through the messaging webhook.

### Acknowledge fast and process asynchronously

Verify the header, store the event, return `200`, and do the work in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Unipile doesn't sign its webhooks, so the secret header you add at creation is your endpoint's only protection. Add it, and a JSON content type, to every webhook, check it in constant time, handle the separate `AccountStatus` shape, and deduplicate on the IDs in each payload, because Unipile sends no delivery ID and gives up after five retries.

[Hookdeck Event Gateway](https://hookdeck.com) verifies your Unipile header and stops requests that fail before they reach your service, routes messages, emails and account statuses to the right services, and holds every event until your service has processed it.

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