Skip to content
Docs

Webhooks

Registering webhooks, every payload field by field, delivery and retries, authenticating deliveries, and timing.

Direct sends Unipile's webhooks for three sources: messaging (message_received), users (new_relation) and account_status. You register them through the API (Unipile also lets you add them in its dashboard; Direct has only the API). A webhook belongs to the workspace of the key that created it: a key lists and deletes its own workspace's webhooks only, and only that workspace's accounts send to them.

Register A Webhook#

Shell
curl -sS -X POST "https://direct.lempile.com/api/v1/webhooks" \
  -H "X-API-KEY: $DIRECT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request_url": "https://app.example.com/hooks/linkedin-messages",
    "source": "messaging",
    "name": "messages",
    "headers": [{ "key": "x-webhook-secret", "value": "your-own-secret" }]
  }'
JSON
{ "object": "WebhookCreated", "webhook_id": "a1B2c3D4e5F6g7H8i9J0kL" }
Field Required
request_url yes https. (http only to 127.0.0.1 or localhost, for a local test.)
source yes messaging, users or account_status. Anything else answers 400.
name no 200 characters at most; sent as webhook_name in messaging and users payloads.
headers no Up to 20 {key, value}, sent with every delivery. Values are kept encrypted and never shown again.
events no Default: every event of the source (see below).
account_ids no Only these accounts. Default: every account of your workspace. Another workspace's account named here never sends.
data no [{key, name}]: send only these payload keys, renamed (messaging and users only).
format no json (default) or form (application/x-www-form-urlencoded, each value a string or JSON-encoded).
enabled no Default true.

GET /api/v1/webhooks lists your workspace's (header values as ********), DELETE /api/v1/webhooks/{hook_id} deletes one (hook_id is the webhook_id it was created with) (its deliveries still waiting are dropped). There is no update: delete and create again.

Events#

Source Sent Accepted, never sent
messaging message_received, message_read, message_reaction, message_edited, message_deleted message_delivered
users new_relation
account_status ok, credentials, error, connecting, creation_success, reconnected, sync_success, deleted creation_fail, stopped, permissions

The "accepted, never sent" events are taken at registration so a Unipile setup registers unchanged, but Direct never sends them: LinkedIn has no delivery receipt of its own, and the others aren't states a Lempile account goes through. message_read, message_reaction, message_edited and message_deleted come only from the live stream (see Timing).

Authenticating Deliveries#

Direct doesn't sign payloads. Every delivery carries the headers you gave the webhook, exactly; put a secret there and check it on every request, comparing in constant time. That's how Unipile setups usually authenticate (for example a static x-unipile-signature header).

Each delivery also has user-agent: Lempile-Direct-Webhooks/1 and content-type: application/json (or the form type).

Payloads#

messaging: message_received#

A new message in one of the account's chats: received, or sent by the account from anywhere (Direct, LinkedIn's website or apps), as Unipile does.

JSON
{
  "account_id": "00000000-0000-4000-8000-0000000000a1",
  "account_type": "LINKEDIN",
  "account_info": { "type": "LINKEDIN", "feature": "classic", "user_id": "ACoAAAbcdefghijklmnop" },
  "event": "message_received",
  "chat_id": "2-QUFBQXRocmVhZPv_b25lMQ",
  "timestamp": "2026-10-29T09:41:07.000Z",
  "webhook_name": "messages",
  "message_id": "2-_vptc2cwMTMyIQ",
  "message": "Sounds good",
  "sender": {
    "attendee_id": "ACoAAAzyxwvutsrqponml",
    "attendee_name": "Bob Buyer",
    "attendee_provider_id": "ACoAAAzyxwvutsrqponml",
    "attendee_profile_url": "https://www.linkedin.com/in/ACoAAAzyxwvutsrqponml",
    "attendee_public_identifier": null,
    "attendee_specifics": {
      "provider": "LINKEDIN",
      "member_urn": "urn:li:member:123456789",
      "network_distance": "DISTANCE_1",
      "occupation": "Head of Sales at Example",
      "is_company": false
    }
  },
  "attendees": [
    {
      "attendee_id": "ACoAAAzyxwvutsrqponml",
      "attendee_name": "Bob Buyer",
      "attendee_provider_id": "ACoAAAzyxwvutsrqponml",
      "attendee_profile_url": "https://www.linkedin.com/in/ACoAAAzyxwvutsrqponml",
      "attendee_public_identifier": null,
      "attendee_specifics": {
        "provider": "LINKEDIN",
        "member_urn": "urn:li:member:123456789",
        "network_distance": "DISTANCE_1",
        "occupation": "Head of Sales at Example",
        "is_company": false
      }
    }
  ],
  "attachments": [],
  "is_sender": false,
  "subject": null,
  "provider_chat_id": "2-QUFBQXRocmVhZPv_b25lMQ",
  "provider_message_id": "2-_vptc2cwMTMyIQ",
  "is_event": 0,
  "chat_pinned": 0,
  "quoted": null,
  "reply_to": null,
  "is_forwarded": null,
  "is_view_once": null,
  "chat_content_type": null,
  "message_type": "MESSAGE",
  "is_group": false,
  "folder": ["INBOX", "INBOX_LINKEDIN_CLASSIC"],
  "event_type": null
}

Every key and every type is as in Unipile's own deliveries (checked against real ones). The ids are LinkedIn's (see below), as every Direct route answers them.

Key
account_id, account_type The account (LINKEDIN).
account_info.user_id The account's own provider id.
chat_id, provider_chat_id The chat's id, as GET /chats answers it (both the same; Unipile's chat_id is its own id).
message_id, provider_message_id The message's id, as GET /chats/{chat_id}/messages answers it (both the same).
timestamp The message's time.
message Its text (empty for a file alone).
sender Who sent it (below). The account itself when it sent it.
attendees Everyone in the chat but the account, in the same shape.
attachments [{attachment_id, attachment_name, attachment_size, attachment_type, attachment_unavailable, attachment_url}]: attachment_type img, file or audio (a voice note); a file has its attachment_name and its size in bytes, an image or a voice note attachment_size: null. attachment_url is LinkedIn's own link (Unipile's is an att:// reference to its attachments route, which Direct doesn't have).
is_sender true when the account sent it.
subject An InMail's subject, else null.
chat_content_type inmail, sponsored, linkedin_offer or null.
is_group A group conversation.
folder The chat's folders.
is_event, chat_pinned, quoted, reply_to, is_forwarded, is_view_once, message_type, event_type Always 0, 0, null, null, null, null, "MESSAGE", null, as Unipile's LinkedIn ones.

A person (sender, each of attendees, read_by, reaction_sender):

Key
attendee_id, attendee_provider_id Their provider id (Unipile puts its own attendee id in attendee_id).
attendee_name Their name (a page's name for a page).
attendee_profile_url https://www.linkedin.com/in/<provider id>, as LinkedIn's messaging links people; null for a page.
attendee_public_identifier Always null, as Unipile's.
attendee_specifics provider (LINKEDIN), member_urn (urn:li:member:<id>; for a page urn:li:msg_messagingParticipant:urn:li:fsd_company:<id>), network_distance (SELF, DISTANCE_1, DISTANCE_2, DISTANCE_3, OUT_OF_NETWORK; members only), occupation (their headline; members only), is_company.

messaging: message_read, message_reaction, message_edited, message_deleted#

The same payload as message_received, about the message concerned, with its own event:

event When Extra keys
message_read Someone else in the chat read it (LinkedIn's read receipt; the account reading its own inbox isn't one). read_by: who read it, in the sender shape.
message_reaction Someone added a reaction to it. reaction: the emoji. reaction_sender: who, in the sender shape.
message_edited Its text changed after it was sent (message is the new text).
message_deleted Its sender deleted it (message is null, attachments empty, as Unipile's).

Each is sent once (a receipt once per message and reader, a reaction once per message, emoji and person).

users: new_relation#

A new connection: an invitation accepted, either way.

JSON
{
  "event": "new_relation",
  "account_id": "00000000-0000-4000-8000-0000000000a1",
  "account_type": "LINKEDIN",
  "webhook_name": "relations",
  "user_full_name": "Bob Buyer",
  "user_provider_id": "ACoAAAzyxwvutsrqponml",
  "user_public_identifier": "bob-buyer",
  "user_profile_url": "https://www.linkedin.com/in/bob-buyer/",
  "user_picture_url": "https://media.licdn.com/dms/image/x/400.jpg",
  "timestamp": 1793260931000
}

timestamp is when the connection was made, in milliseconds since 1970 (a number, to the second), as Unipile's. user_picture_url may be null.

account_status#

JSON
{
  "AccountStatus": {
    "account_id": "00000000-0000-4000-8000-0000000000a1",
    "account_type": "LINKEDIN",
    "message": "CREDENTIALS"
  }
}
message When
OK, CREDENTIALS, ERROR, CONNECTING The account's status changed (see Authentication And Accounts).
CREATION_SUCCESS A hosted auth link connected a new account.
RECONNECTED A hosted auth link reconnected an account.
SYNC_SUCCESS A resync worked.
DELETED DELETE /accounts/{account_id}.

The event names you filter on are the lowercase forms (credentials, creation_success…). A SYNC_SUCCESS also has "Product": "classic" beside AccountStatus, as every one of Unipile's does.

Hosted auth notify_url#

Not a registered webhook: a hosted auth link's notify_url gets exactly {"status": "CREATION_SUCCESS" | "RECONNECTED", "account_id", "account_type": "LINKEDIN", "name"} when the account works, delivered and retried like a webhook, with no extra header.

Delivery#

  • A POST, 30 seconds at most per try.
  • Any 2xx is delivered and never sent again.
  • A 5xx, 408, 425, 429, a timeout or a refused connection is tried again 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours later (5 more tries). Any other answer (a 4xx, a redirect) is final.
  • Deliveries waiting for a retry are kept encrypted on Direct's disk: a restart of Direct sends them.
  • Each delivery is independent: a slow receiver holds no other.
  • A delivery that timed out on Direct's side may still have reached you, so you can receive one twice. Make your handler idempotent on message_id (or user_provider_id, or the status and account).

Timing#

Messages arrive in real time, day and night. For each account some messaging or users webhook wants, Direct keeps LinkedIn's own live connection open (the one LinkedIn's messaging page keeps open), through the account's own session and proxy. A new message (received, or sent by the account from anywhere), a read receipt, a reaction, an edit or a deletion reaches your webhook within seconds of LinkedIn telling the account's browser.

Behind it, Direct still reads each account's inbox and relations, through the account's own session and pacing, and only for what some webhook wants (no webhook, no read). It catches anything the live connection missed:

What Awake (07:00 to 23:00, the account's timezone) At night
The inbox, while the live connection is up every 15 to 25 minutes every 45 to 75 minutes
The inbox, while it's down every 2 to 5 minutes every 20 to 40 minutes
Relations (newest 20) every 30 to 60 minutes, and within seconds of a hint on the live connection (below) every 2 to 3 hours, and the same
Account status, when only account_status webhooks exist every 15 to 30 minutes every 30 to 60 minutes
  • New connections: LinkedIn's live connection never names a new connection, but someone accepting an invitation is a notification. When it signals an invitation, the notifications or My Network badge going up, or an in-app alert, Direct reads the newest relations within seconds (one call; at most once every 90 seconds and 96 times a day an account): the new connection then reaches your users webhook within about a minute. Otherwise the regular read finds it. Unipile's own new_relation comes by polling, up to 8 hours after.
  • Every message is sent once, whichever path saw it first.
  • When the live connection drops, it reconnects within seconds, and the inbox is read within a minute after any gap of 30 seconds or more, as LinkedIn's own page re-syncs: messages from the gap arrive then, a minute or two late. While the connection stays down, the inbox is read every 2 to 5 minutes again.
  • A conversation whose last activity moved has its latest messages read: up to 8 conversations a read (the others next read), up to 20 new messages each, oldest first.
  • Status changes go out as soon as any call (yours, or a read) sees them.
  • The timezone is the account's own (America/New_York unless Lempile set another).
  • The first read for an account only remembers where things are: it sends nothing already there. An account whose reading was stopped for more than 6 hours starts again the same way, so messages from that gap aren't sent; read them with GET /chats and GET /chats/{chat_id}/messages.
  • The reads cost the account about 190 LinkedIn calls on a quiet day while the live connection is up (about 300 without it): one per read, one per conversation with new activity, one per (re)connection (LinkedIn closes its live connection every 12 to 14 minutes, as it does for its own page: about 100 a day), and for a message the live connection didn't fully describe, one or two to read it. They stop for the day at 450, and pause while the account has used 80% of its daily calls, so your own calls keep most of the budget.
  • A signed-out account (CREDENTIALS) isn't read until it's OK again; a rest after LinkedIn's "too many" is waited out.

Was this page helpful?