> ## Documentation Index
> Fetch the complete documentation index at: https://docs.linkupapi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks Introduction

> Receive real-time notifications for account activity

Webhooks allow you to receive real-time notifications when events happen on your connected accounts — new messages, connection acceptances, incoming connection requests, and more.

## Delivery Modes

Webhooks support two delivery modes depending on your setup:

<CardGroup cols={2}>
  <Card title="Hosted Mode" icon="cloud">
    No server needed. Events are delivered via **SSE stream** or **polling**. Perfect for bots, scripts, and local apps.

    Create a webhook **without** a `url` to use hosted mode.
  </Card>

  <Card title="Custom Mode" icon="server">
    Events are sent as `POST` requests to your server. Requires a publicly accessible HTTPS endpoint.

    Create a webhook **with** a `url` to use custom mode.
  </Card>
</CardGroup>

| | Hosted | Custom |
| - | - | - |
| **Setup** | No server required | HTTPS endpoint required |
| **Real-time delivery** | SSE stream (`GET /stream`) | HTTP POST to your URL |
| **Delivery guarantee** | Pull at your pace | At-least-once: retries with backoff, delivery log, replay |
| **Event history** | 30 days stored events (`GET /events`) | 30 days stored events (`GET /events`) + replay |
| **Best for** | Bots, scripts, local dev | Production servers |

<Note>
  **Custom mode** supports optional HMAC-SHA256 [signature verification](/api-reference/v2/webhooks/signature) so your server can reject spoofed payloads. Opt in with `enable_signature: true` at creation time.
</Note>

## Delivery & retries (custom mode)

Custom webhooks are delivered **at least once**. Every envelope carries a stable top-level `event_id` (also sent as `X-Linkup-Event-Id`, with `X-Linkup-Attempt`) — dedupe on it. A POST is successful on any `2xx`. On timeout (10 s), connection error, `5xx`, `408`/`425`/`429` we retry with backoff: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h (8 attempts). Any other `4xx` or a `3xx` is recorded as `failed` and not retried. Turn retries off per webhook with `retry: false`.

Everything we sent, or still owe, is visible for 30 days, and replayable, from the API:

| Endpoint | Purpose |
| - | - |
| `GET /v2/webhooks/{id}/deliveries?state=failed` | Delivery log: each attempt, the status code you returned, timeouts, next retry (`cursor` paging, `since`/`until`, `event_id`) |
| `GET /v2/webhooks/{id}/deliveries/{delivery_id}` | One delivery with the exact payload we POSTed |
| `POST /v2/webhooks/{id}/deliveries/{delivery_id}/replay` | Re-POST it now (header `X-Linkup-Replay: true`) |
| `POST /v2/webhooks/{id}/replay` | `{"event_id": "…"}` re-POSTs one event now; `{"from": "…", "to": "…", "only_failed": true}` queues a time range (max 5000) |
| [`GET /v2/webhooks/{id}/events?after=<event_id>`](/api-reference/v2/webhooks/events) | Pull the stored events yourself (30 days), no LinkedIn traffic |
| `GET /v2/webhooks/retry-policy` | The policy above, as data |

## How It Works

<Tabs>
  <Tab title="Hosted Mode">
    <Steps>
      <Step title="Connect your account">
        Use `POST /v2/login` to connect an account and get an `account_id`.
      </Step>

      <Step title="Create a hosted webhook">
        Call `POST /v2/webhooks` **without** a `url`. You'll receive a `webhook_id`, `stream_url`, and `events_url`. Monitoring starts automatically.
      </Step>

      <Step title="Receive events">
        Connect to the SSE stream or poll the events endpoint.
      </Step>
    </Steps>
  </Tab>

  <Tab title="Custom Mode">
    <Steps>
      <Step title="Connect your account">
        Use `POST /v2/login` to connect an account and get an `account_id`.
      </Step>

      <Step title="Create a webhook with URL">
        Call `POST /v2/webhooks` with your server's `url`. Monitoring starts automatically.
      </Step>

      <Step title="Receive events">
        Events are sent as `POST` requests to your URL in real time.
      </Step>
    </Steps>
  </Tab>
</Tabs>

## Event Types

| Subscription filter | Emitted `event.type` | Description |
| - | - | - |
| `message_received` | `"message"` | A new message (LinkedIn) or email was received — use the top-level `platform` field to tell the two channels apart |
| `accepted_invitation` | `"accepted_invitation"` | One or more connection requests you sent were accepted |
| `invitation_received` | `"invitation_received"` | The account received one or more new connection requests |
| `email_bounced` *(email, via `all`)* | `"email_bounced"` | An email you sent bounced |
| `all` | — | Subscribe to all event types |

<Note>
  By default, webhooks listen to **all** event types. In **custom mode**, you can filter specific events when creating the webhook by passing an `events` array. **Hosted** webhooks (SSE stream and `GET /events`) always receive every event type — only the `message_type` direction filter applies — so filter on `event.type` on your side.
</Note>

<Warning>
  The subscription filter `message_received` is intentionally different from the actual `event.type`, which is `"message"`. Filter against `message_received` when creating the webhook. A LinkedIn message and an inbound email both arrive as `event.type === "message"` — read the top-level **`platform`** field (`"linkedin"` vs `"email"`) to tell them apart (see [Email events](#email-events)).
</Warning>

<Note>
  In addition to subscribed events, **every webhook also receives `disconnection` events** — this is not filterable. They fire whenever the underlying platform session expires so you can react (re-auth, alert, etc.).
</Note>

## Event Payload Format

All events share the same wrapper structure. Note that the outer `timestamp` is when the API emitted the event, while `event.timestamp` is when the underlying platform activity happened — they differ slightly (latency).

The top-level **`platform`** field (`"linkedin"`, `"email"` or `"whatsapp"`) tells you which channel the event came from — this is how you distinguish an inbound email from a LinkedIn DM, since both use `event.type: "message"`.

The top-level **`event_id`** is the stable identifier of the event: identical on every retry or replay, and the key to dedupe on (see [Delivery & retries](#delivery-retries-custom-mode)).

```json theme={null}
{
  "event_id": "6ab13cf890294b92776b5343",
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "...",
    "timestamp": "2026-04-28T08:07:45.525693"
    // event-specific fields below
  },
  "timestamp": "2026-04-28T08:07:45.525853"
}
```

### Message Received

Triggered in real-time when a new message is received via the platform's realtime stream (sub-second latency). The inner event `type` is `"message"` and `platform` is `"linkedin"`.

```json theme={null}
{
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "message",
    "inbox": "classic",
    "timestamp": "2026-04-28T10:07:45.525693",
    "message_text": "Hello! I'd love to connect.",
    "sender": {
      "name": "John Doe",
      "profile_url": "https://www.linkedin.com/in/ACoAAEMT_nsB5U1uon01_EjK9Yf4En52jDd2hGE"
    },
    "conversation": {
      "participants": [
        {
          "name": "Your Name",
          "profile_url": "https://www.linkedin.com/in/ACoAADxnv0oB_dN-NfpvEBlsVPU_W1omM-Xu24o"
        },
        {
          "name": "John Doe",
          "profile_url": "https://www.linkedin.com/in/ACoAAEMT_nsB5U1uon01_EjK9Yf4En52jDd2hGE"
        }
      ],
      "is_group_chat": false,
      "unread_count": 1
    },
    "metadata": {
      "entity_urn": "urn:li:msg_message:(urn:li:fsd_profile:ACoAADxnv0oB_dN-NfpvEBlsVPU_W1omM-Xu24o,2-MTc3NzM2MzY2NDkyMGI4MzE3Mi0xMDAmMDZiY2ExOTgtM2NjNy00OTU4LTgyNjUtZWFlMmIwZjM1NTlmXzEwMA==)",
      "conversation_urn": "urn:li:msg_conversation:(urn:li:fsd_profile:ACoAADxnv0oB_dN-NfpvEBlsVPU_W1omM-Xu24o,2-MDZiY2ExOTgtM2NjNy00OTU4LTgyNjUtZWFlMmIwZjM1NTlmXzEwMA==)",
      "delivered_at": 1777363664920,
      "read_status": false
    }
  },
  "timestamp": "2026-04-28T08:07:45.525853"
}
```

**Field reference**

| Field | Type | Description |
| - | - | - |
| `event.inbox` | `string` | Which inbox the message came from: `classic` for LinkedIn messaging, `sales_navigator` for a Sales Navigator InMail thread. See [Sales Navigator inbox](#sales-navigator-inbox). |
| `event.message_text` | `string` | Plain text content of the message |
| `event.sender.name` | `string` | Display name of the sender |
| `event.sender.profile_url` | `string` | Profile URL of the sender (ID-based, not vanity) |
| `event.conversation.participants` | `array` | All participants of the thread, sender included |
| `event.conversation.is_group_chat` | `boolean` | `true` if 3+ participants |
| `event.conversation.unread_count` | `integer` | Number of unread messages in the thread |
| `event.reply_to` | `object?` | **Only present when the message is a reply.** Contains `message_text`, `sender_name`, `sender_profile_url`, `timestamp` of the original message. Omitted (not `null`) for non-reply messages. |
| `event.metadata.entity_urn` | `string` | Unique identifier of the message |
| `event.metadata.conversation_urn` | `string` | Unique identifier of the conversation thread |
| `event.metadata.delivered_at` | `integer` | Unix milliseconds delivery time |
| `event.metadata.read_status` | `boolean` | Whether the conversation is marked read |
| `event.has_attachments` | `boolean` | `true` when the message carries at least one attachment |
| `event.attachments` | `array` | One entry per attachment. Empty when there are none. |
| `event.message_type` | `string` | What the message is. Classic inbox: `TEXT`, `MEDIA` (attachment without text) or `SYSTEM`. Sales Navigator inbox: `INMAIL`, `MESSAGE`, `INMAIL_DECLINE` or `INMAIL_ACCEPT`. An `INMAIL_DECLINE` is the prospect pressing "Not interested" on your InMail: it arrives with an empty `message_text` and is a refusal, not a reply. |

<Tip>
  When the message is a reply, an additional `event.reply_to` object is included with the original message details. For non-reply messages, the field is **omitted entirely** from the payload — don't expect a `null`.
</Tip>

**Attachments**

```json theme={null}
"has_attachments": true,
"attachments": [
  {
    "type": "file",
    "name": "proposal.pdf",
    "mime_type": "application/pdf",
    "byte_size": 184320,
    "asset_urn": "urn:li:digitalmediaAsset:D4E06AQ...",
    "url": "https://media.licdn.com/dms/document/...",
    "download_url": "https://api.linkupapi.com/v2/messages/attachment?account_id=...&conversation_id=...&asset_urn=..."
  }
]
```

`type` is `image`, `file`, `audio`, `video`, `gif` or `unknown`. Audio and video also carry `duration_ms` and `thumbnail_url`.

<Warning>
  `url` is LinkedIn's own media URL: it requires the account session and its signature expires, so it is not fetchable on its own. Use `download_url` — see [Download Attachment](/api-reference/v2/messages/download-attachment).
</Warning>

### Email events

For an [email mailbox](/api-reference/v2/accounts/login#email-mailbox), the monitor polls the inbox and emits two event types. Both are covered by the `message_received` filter / `all`. Email events carry the top-level **`platform: "email"`** — that, not `event.type`, is how you tell them from a LinkedIn DM.

**New email received** — inner `type` is `"message"` (same as a LinkedIn message; `platform` is what distinguishes them):

```json theme={null}
{
  "account_id": "6a75f0923a25191f7c8c5bba",
  "account_name": "you@company.com",
  "platform": "email",
  "event": {
    "type": "message",
    "timestamp": "2026-09-14T10:07:45.525693",
    "subject": "Re: Quick question",
    "message_id": "<CAF...@mail.gmail.com>",
    "in_reply_to": "<178928915191...@gmail.com>",
    "sender": { "name": "John Doe", "email": "john@example.com", "is_self_sent": false },
    "message_text": "Sure, let's chat Thursday.",
    "raw_data": { "entityUrn": "email:you@company.com:<CAF...@mail.gmail.com>" }
  },
  "timestamp": "2026-09-14T10:07:46.001000"
}
```

**Sent email bounced** — inner `type` is `"email_bounced"`:

```json theme={null}
{
  "account_id": "6a75f0923a25191f7c8c5bba",
  "account_name": "you@company.com",
  "platform": "email",
  "event": {
    "type": "email_bounced",
    "timestamp": "2026-09-14T10:10:00.000000",
    "subject": "Quick question",
    "message_id": "<...@gmail.com>",
    "sender": { "name": "Mail Delivery Subsystem", "email": "mailer-daemon@googlemail.com", "is_self_sent": false },
    "bounce_status": "5.1.1",
    "bounce_kind": "hard",
    "bounced_recipient": "wrong@example.com",
    "raw_data": { "entityUrn": "email:you@company.com:<...@gmail.com>" }
  },
  "timestamp": "2026-09-14T10:10:01.000000"
}
```

**Field reference**

| Field | Type | Description |
| - | - | - |
| `event.subject` | `string` | Email subject. |
| `event.message_id` | `string` | The email's Message-ID. |
| `event.in_reply_to` | `string?` | Message-ID this email replies to, when it is a reply. |
| `event.sender.name` / `event.sender.email` | `string` | Sender display name and address. |
| `event.sender.is_self_sent` | `boolean` | `true` when the mailbox is also the sender. |
| `event.message_text` | `string` | Snippet of the body (up to 500 chars). Inbound email (`type: "message"`) only. |
| `event.opt_out_detected` | `boolean?` | Present and `true` when the body/subject looks like an unsubscribe/opt-out. |
| `event.bounce_status` | `string` | RFC 3463 status code (e.g. `5.1.1`). `email_bounced` only. |
| `event.bounce_kind` | `string` | `hard` or `soft`. `email_bounced` only. |
| `event.bounced_recipient` | `string` | The address that bounced. `email_bounced` only. |

### Sales Navigator inbox

Sales Navigator has its own inbox, separate from LinkedIn messaging. A reply to an InMail lands there and **never** appears in the classic thread, so it used to be invisible to webhooks.

Both inboxes now feed the same webhook. Use `event.inbox` to tell them apart.

| `event.inbox` | Source |
| - | - |
| `classic` | LinkedIn messaging |
| `sales_navigator` | Sales Navigator InMail thread |

This is automatic: an account whose Sales Navigator seat is connected starts receiving both. Nothing to configure, and no extra credit is charged. An account without a connected seat is unaffected and keeps receiving `classic` events only.

```json theme={null}
{
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "message",
    "inbox": "sales_navigator",
    "timestamp": "2026-04-28T10:07:45.525693",
    "message_text": "Thanks for reaching out — happy to talk next week.",
    "message_type": "MESSAGE",
    "sender": {
      "name": "Douglas Collombat",
      "profile_url": "https://www.linkedin.com/in/douglas-collombat-8aa5863a7",
      "member_urn": "urn:li:member:1670586754",
      "degree": 1,
      "urn_raw": "urn:li:fs_salesProfile:(ACwAAGOTIYIBDvlWDchVYxaO35AeIjn5Jr9cW5Y,NAME_SEARCH,1aMb)"
    },
    "metadata": {
      "entity_urn": "urn:li:fs_salesMessage:2-MTc4ODU0NDYyMDY5M2IyNTg3Ny0xMDAmOTI0OTkzYTQt...",
      "conversation_urn": "urn:li:fs_salesMessagingThread:2-OTI0OTkzYTQtZjgwNy00OTJlLWJhY2YtNTMyNGRkNWFlYWIy...",
      "delivered_at": 1788544620693
    }
  },
  "timestamp": "2026-04-28T08:07:45.525853"
}
```

<Warning>
  A prospect can decline an InMail instead of answering it. That comes through as a `message` event with `"message_type": "INMAIL_DECLINE"` and an empty `message_text`. Treat it as a refusal: do not count it as a reply, and stop the sequence.
</Warning>

**Identifying the sender**

The realtime stream only gives the sender's Sales Navigator id, which is meaningless outside Sales Navigator. We resolve it for you, so the sender arrives in the same shape as on the classic inbox, plus two fields specific to this inbox.

| Field | Description |
| - | - |
| `sender.name` | Full name |
| `sender.profile_url` | Public profile URL — match it against your own records |
| `sender.member_urn` | `urn:li:member:<id>`, stable across LinkedIn |
| `sender.degree` | Connection degree, `1`, `2` or `3` |
| `sender.urn_raw` | The Sales Navigator id. Pass it as `recipient_urn` to reply. |

<Note>
  Resolution costs one lookup per **contact**, not per message, and is cached: the first message from someone takes about half a second longer, their next ones are instant. No credit is charged. If the lookup fails, the event is still delivered with `sender.urn_raw` alone.
</Note>

<Note>
  `metadata.conversation_urn` is a `fs_salesMessagingThread` URN, not a classic `msg_conversation` one. Pass its thread id to [List Inbox](/api-reference/v2/messages/list-inbox) or [Get Conversation](/api-reference/v2/messages/get-conversation) with `sales_nav: true` to read the thread, and reply with [Send Message](/api-reference/v2/messages/send) using `inmail: true`.
</Note>

### Accepted Invitation

Triggered when one or more connection requests you sent are accepted. Detected by a background worker that polls the connections list every \~6 hours and diffs against the previously stored snapshot. The payload batches all new connections detected in the same check.

```json theme={null}
{
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "accepted_invitation",
    "new_connections": [
      {
        "profile_url": "https://www.linkedin.com/in/maruthigudi/",
        "name": "Maruthi Gudi",
        "job_title": "Building High-Performance Tech & FinTech Teams | Headhunter @ HackerTrail",
        "profile_picture": "https://media.licdn.com/dms/image/v2/D4E03AQEEUE9iAq2LXA/profile-displayphoto-shrink_100_100/B4EZYY8bulGYAU-/0/1744175218426"
      },
      {
        "profile_url": "https://www.linkedin.com/in/musokeshaibu/",
        "name": "Musoke Shaibu",
        "job_title": "Building the definitive operating system for sales, marketing, and research.",
        "profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQGCOZ3SrVT5Tg/profile-displayphoto-shrink_200_200/B4DZZtP29UGcAY-/0/1745589597790"
      }
    ],
    "count": 2,
    "timestamp": "2026-04-27T14:52:59.970134"
  },
  "timestamp": "2026-04-27T14:52:59.970144"
}
```

**Field reference**

| Field | Type | Description |
| - | - | - |
| `event.new_connections` | `array` | Profiles that accepted your connection request since the last check |
| `event.new_connections[].profile_url` | `string` | Public profile URL of the connection |
| `event.new_connections[].name` | `string` | Display name |
| `event.new_connections[].job_title` | `string \| null` | Headline / current role as shown on the platform |
| `event.new_connections[].profile_picture` | `string \| null` | CDN URL of the profile picture (may expire) |
| `event.count` | `integer` | Length of `new_connections` |

<Note>
  When you create a webhook subscribed to `accepted_invitation` (or `all`), the API performs an initial sync of your existing connections in the background. The first detection check then runs \~6 hours later and every 6 hours thereafter.
</Note>

### Invitation Received

Triggered when the account receives one or more new connection requests. Detected by the same background worker as `accepted_invitation` (\~6 hour polling cadence by default), so it's suited for inbound automation — auto-accepting, routing to a CRM, or replying — rather than instant notifications. The payload batches all new invitations detected in the same check.

```json theme={null}
{
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "invitation_received",
    "new_invitations": [
      {
        "name": "Jane Cooper",
        "profile_url": "https://www.linkedin.com/in/jane-cooper",
        "profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFcP6361wcRgg/profile-displayphoto-scale_200_200",
        "subtitle": "Growth Marketing Lead @ Acme",
        "message": "Hi! I'd love to connect and exchange about your product.",
        "sent_time": "2 days ago",
        "invitation_id": "7498280075365183488",
        "shared_secret": "aBcDeFgH",
        "entity_urn": "urn:li:fsd_invitation:7498280075365183488"
      }
    ],
    "count": 1,
    "timestamp": "2026-08-29T15:58:43.629574"
  },
  "timestamp": "2026-08-29T15:58:43.629583"
}
```

**Field reference**

| Field | Type | Description |
| - | - | - |
| `event.new_invitations` | `array` | Connection requests received since the last check |
| `event.new_invitations[].name` | `string` | Display name of the sender |
| `event.new_invitations[].profile_url` | `string` | Profile URL of the sender |
| `event.new_invitations[].profile_picture` | `string \| null` | CDN URL of the profile picture (may expire) |
| `event.new_invitations[].subtitle` | `string \| null` | Headline of the sender |
| `event.new_invitations[].message` | `string \| null` | The note attached to the invitation, if any |
| `event.new_invitations[].sent_time` | `string` | Relative time the invitation was sent (e.g. `"2 days ago"`) |
| `event.new_invitations[].invitation_id` | `string` | Invitation ID **as a string** |
| `event.new_invitations[].shared_secret` | `string \| null` | Secret required to [accept](/api-reference/v2/network/accept) the invitation |
| `event.new_invitations[].entity_urn` | `string` | Invitation URN — pass it with `shared_secret` to [Accept Invitation](/api-reference/v2/network/accept) |
| `event.count` | `integer` | Length of `new_invitations` |

<Note>
  When you create a webhook subscribed to `invitation_received` (or `all`), the API stores a baseline of your currently pending invitations in the background — the existing backlog is never notified, only invitations received **after** the webhook was created.
</Note>

<Tip>
  To auto-accept an incoming invitation, call [Accept Invitation](/api-reference/v2/network/accept) with the payload's `entity_urn` and `shared_secret`. `invitation_id` is delivered as a **string** because the raw 19-digit number exceeds JavaScript's `Number.MAX_SAFE_INTEGER` — keep it as a string end to end.
</Tip>

### Disconnection

Triggered when the account's platform session expires, the cookie is invalidated, or the user logs out. Sent to **every** webhook attached to the account regardless of subscription filters.

```json theme={null}
{
  "account_id": "69ee261e41fc4cbecf9f34c9",
  "account_name": "your.email@example.com",
  "platform": "linkedin",
  "event": {
    "type": "disconnection",
    "reason": "LinkedIn cookie invalid or expired",
    "timestamp": "2026-04-28T09:15:30.123456"
  },
  "timestamp": "2026-04-28T09:15:30.123456"
}
```

**Field reference**

| Field | Type | Description |
| - | - | - |
| `event.reason` | `string` | Human-readable cause (e.g. `"Token expired (detected after 10 reconnections)"`, `"LinkedIn cookie invalid or expired"`) |

<Warning>
  When you receive a disconnection event, monitoring stops automatically and every webhook attached to the account is paused (with `auto_paused: true`). You need to re-authenticate the account via `POST /v2/login` (or `POST /v2/checkpoint` if a 2FA is required) — paused webhooks are auto-restarted on a successful re-login.
</Warning>

## Architecture

Unlike the V1 webhook system which required creating a separate "webhook account", the V2 system works with your existing accounts:

* **V2 accounts** are created via `POST /v2/login` (profiles, messages, network, etc.)
* **Webhooks** are configurations that attach to those accounts
* One account can have multiple webhooks with different event filters
* Starting/stopping monitoring is done per webhook
