Webhooks
Real-time event notifications for your integrations.
Last updated: May 9, 2026 · payload v2 · adds data.visit.id, data.reaction.id, and connection_status on profile visits
Overview
LeadShark sends webhooks via HTTP POST with JSON payloads. All webhooks include security headers for verification.
new_profile_visit and new_like remain Apex-only).email.captured
lead.sent
new.profile.visit
new.like
new.comment
Authentication & Security
Security Headers
X-Webhook-Signature— HMAC-SHA256 signatureX-Webhook-Event— Event typeContent-Type— application/jsonSignature Verification
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return signature === expected;
}Payload Schema (v2)
Back-compatEvery webhook body now ships a unified shape so a single handler can deal with new.profile.visit, new.like and new.comment without per-event branching. The unification is 100% additive — every legacy field you're consuming today is still emitted exactly as before.
Backwards-compat guarantee
- Existing fields (
commenter_id,reactor_id,post_id,post_url,visitor,profile_owner) keep firing on every payload — no consumer code change required. - v2 only adds fields (
payload_version,data.lead.actor_id,data.post,data.reaction, and an explicitdata.automation: nullwhen there is no automation context). - Legacy fields will be supported for at least two full quarters after this release. We'll announce a removal date in Docs and via the changelog before retiring anything.
Common envelope
{
"event": "new.like" | "new.comment" | "new.profile.visit" | "email.captured" | "lead.sent",
"timestamp": "2026-05-07T12:38:50.258Z",
"payload_version": 2,
"data": {
"lead": { /* always present, see below */ },
"post": { "id": "urn:li:activity:...", "url": "https://www.linkedin.com/feed/update/..." },
"automation": { "id": "uuid", "name": "..." } | null,
/* event-specific blocks: comment, reaction, visitor, profile_owner */
/* legacy fields (post_id, post_url, etc.) are still emitted alongside */
}
}payload_version: 2 applies to the LinkedIn-event payloads (new.comment, new.like, new.profile.visit). email.captured and lead.sent ship a slimmer payload — see their sections below for the exact shape.
Unified data.lead
| Field | Type |
|---|---|
| data.lead.actor_id | string |
| data.lead.name | string |
| data.lead.first_name | string |
| data.lead.title | string |
| data.lead.linkedin_url | string |
| data.lead.linkedin_username | string |
| data.lead.connection_status | string |
| data.lead.commenter_id | string · legacy |
| data.lead.reactor_id | string · legacy |
| data.lead.reaction_type | string · legacy |
Unified data.post · data.automation
| Field | Type |
|---|---|
| data.post.id | string |
| data.post.url | string? |
| data.automation | object | null |
| data.post_id | string · legacy |
| data.post_url | string? · legacy |
Email Captured
Proemail.captured— When a lead provides their email in email-gated Pages.Payload
{
"event": "email.captured",
"timestamp": "2026-05-09T11:43:42.659Z",
"data": {
"lead": {
"name": "John Doe",
"title": "Senior Marketing Manager",
"email": "john.doe@example.com",
"phone": "+15551234567",
"linkedin_url": "https://linkedin.com/in/john-doe-12345",
"linkedin_username": "john-doe-12345",
"commenter_id": "ACoAAEXAMPLE444555666"
},
"automation": {
"id": "58c132e6-648c-4079-8815-77a3b2ec1adc",
"name": "Q4 Marketing Guide"
},
"post_id": "urn:li:activity:7372270383585984512",
"link_index": 1,
"meta": {
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"referrer": "https://www.linkedin.com/",
"personalized_access": true
}
}
}Fields
| Field | Type |
|---|---|
| event | string |
| timestamp | string |
| data.lead.name | string |
| data.lead.title | string |
| data.lead.email | string |
| data.lead.phone | string? |
| data.lead.linkedin_url | string? |
| data.lead.linkedin_username | string? |
| data.lead.commenter_id | string |
| data.automation | object | null |
| data.post_id | string |
| data.link_index | number |
| data.meta.ip_address | string |
| data.meta.user_agent | string |
| data.meta.referrer | string |
| data.meta.personalized_access | boolean |
Lead Sent
Prolead.sent— Fires when you push a lead from the dashboard via “Send to webhook”. Manual, user-triggered — not LinkedIn-driven.Payload
{
"event": "lead.sent",
"timestamp": "2026-05-09T13:24:11.482Z",
"data": {
"lead": {
"name": "Sarah Wilson",
"first_name": "Sarah",
"last_name": "Wilson",
"title": "Content Marketing Specialist",
"email": "sarah@example.com",
"linkedin_url": "https://linkedin.com/in/sarah-wilson",
"linkedin_username": "sarah-wilson",
"commenter_id": "ACoAAEXAMPLE444555666",
"connection_status": "Connected",
"location": "San Francisco, CA",
"icp_fit": true,
"icp_score": 0.95
}
}
}Fields
| Field | Type |
|---|---|
| event | string |
| timestamp | string |
| data.lead.name | string |
| data.lead.first_name | string? |
| data.lead.last_name | string? |
| data.lead.title | string? |
| data.lead.email | string? |
| data.lead.linkedin_url | string? |
| data.lead.linkedin_username | string? |
| data.lead.commenter_id | string? |
| data.lead.connection_status | string? |
| data.lead.location | string? |
| data.lead.icp_fit | boolean? |
| data.lead.icp_score | number? |
lead.sent intentionally has no data.post / data.automation blocks — it is not tied to a LinkedIn surface event. Subscribe a webhook to lead_sent in Settings → Webhooks (or via the REST API) to receive it.
Profile Visit
Apexnew.profile.visit— When someone visits your profilePayload
{
"event": "new.profile.visit",
"timestamp": "2026-05-07T11:37:21.709Z",
"payload_version": 2,
"data": {
"lead": {
"name": "Jane Smith",
"first_name": "Jane",
"title": "Product Manager at Innovation Labs",
"linkedin_url": "https://linkedin.com/in/jane-smith",
"linkedin_username": "jane-smith",
"connection_status": "2nd Connection",
"actor_id": "ACoAAEXAMPLE987654321",
"is_anonymous": false
},
"visit": { "id": "vi_a3f5b8c2e1d0a7f4" },
"visit_timestamp": "2026-05-07T11:37:21.709Z",
// ── Legacy fields (kept for back-compat) ──
"visitor": {
"id": "ACoAAEXAMPLE987654321",
"name": "Jane Smith",
"title": "Product Manager at Innovation Labs",
"linkedin_url": "https://linkedin.com/in/jane-smith",
"linkedin_username": "jane-smith",
"connection_status": "2nd Connection",
"is_anonymous": false
},
"profile_owner": {
"id": "ACoAAEXAMPLE123456789",
"name": "Your Name",
"linkedin_url": "https://linkedin.com/in/your-name"
},
"meta": { "source": "linkedin", "timestamp": "2026-05-07T11:37:21.709Z" }
}
}Anonymized visitors (LinkedIn Premium)
When a LinkedIn Premium user views your profile in private mode, LinkedIn never reveals their identity. We pass that through honestly: linkedin_username and first_name are null, name is the descriptive ICP label LinkedIn returns (e.g. “Founder in the Tech industry from Hamburg”), linkedin_url is a search-results URL, and is_anonymous is true. Branch on data.lead.is_anonymous to skip handle/URL lookups for these rows.
{
"event": "new.profile.visit",
"timestamp": "2026-05-10T08:38:01.042Z",
"payload_version": 2,
"data": {
"lead": {
"name": "Founder in the Technology, Information and Internet industry from Greater Hamburg Area",
"first_name": null,
"title": "",
"linkedin_url": "https://www.linkedin.com/search/results/people/?keywords=Founder&origin=WHO_VIEWED_ME&industry=6",
"linkedin_username": null,
"connection_status": "Not Connected",
"actor_id": "visitor-1778402641042-0.17692646244689203",
"is_anonymous": true
},
"visit": { "id": "vi_d4c8e9a2b1f7e3d0" },
"visit_timestamp": "2026-05-10T08:38:01.042Z"
}
}data.visit.id is a deterministic synthetic id (sha256 of visitor_id::visit_timestamp, truncated, prefixed vi_). Stable across retries — use it as an idempotency token, the same way you'd use data.comment.id on new.comment.
data.lead.connection_status is now populated on profile visits — read from the connection-degree badge LinkedIn shows next to each visitor on the “Who Viewed My Profile” feed (same source the dashboard already surfaces on the Profile page). Visitors with no badge are reported as "Not Connected".
Post Like
Apexnew.like— Auto-detected every ~2 hours from your 10 most recent posts. No per-post setup required.Payload
{
"event": "new.like",
"timestamp": "2026-05-07T12:38:50.258Z",
"payload_version": 2,
"data": {
"lead": {
"name": "Michael Johnson",
"first_name": "Michael",
"title": "Sales Director",
"linkedin_url": "https://linkedin.com/in/michael-johnson",
"linkedin_username": "michael-johnson",
"connection_status": "Connected",
"actor_id": "ACoAAEXAMPLE111222333",
// ── Legacy fields (kept for back-compat) ──
"reactor_id": "ACoAAEXAMPLE111222333",
"reaction_type": "like"
},
"post": {
"id": "urn:li:activity:7372270383585984512",
"url": "https://www.linkedin.com/feed/update/urn:li:activity:7372270383585984512"
},
"reaction": {
"id": "re_b2e1d0a7f4a3f5b8",
"type": "like"
},
"automation": null,
// ── Legacy fields (kept for back-compat) ──
"post_id": "urn:li:activity:7372270383585984512",
"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:7372270383585984512"
}
}data.reaction.id is a deterministic synthetic id (sha256 of post_id::reactor_id::reaction_type, truncated, prefixed re_). Stable across retries — use it as an idempotency token. Note that if a reactor changes their reaction (e.g. like → praise), the id changes too, so you'll see a new payload rather than an update to the old one.
data.automation is always null on new.like — the worker that detects reactions runs independently of comment-automations, so there is no automation context to attach. The field is kept present (just null) so a consumer destructuring data.automation across like and comment events doesn't have to special-case it.
reaction.type reflects the actual LinkedIn reaction value — like, empathy, appreciation, interest, praise, entertainment, or maybe. The webhook fires for any of them.
Post Comment
Pronew.comment— When someone comments on your postPayload
{
"event": "new.comment",
"timestamp": "2026-05-07T12:42:27.124Z",
"payload_version": 2,
"data": {
"lead": {
"name": "Sarah Wilson",
"first_name": "Sarah",
"title": "Content Marketing Specialist",
"linkedin_url": "https://linkedin.com/in/sarah-wilson",
"linkedin_username": "sarah-wilson",
"connection_status": "2nd Connection",
"actor_id": "ACoAAEXAMPLE444555666",
// ── Legacy field (kept for back-compat) ──
"commenter_id": "ACoAAEXAMPLE444555666"
},
"comment": {
"id": "7371940875070795776",
"text": "Great insights! Thanks for sharing.",
"created_at": "2026-05-07T16:21:08.656Z"
},
"post": {
"id": "urn:li:activity:7372270383585984512",
"url": "https://www.linkedin.com/feed/update/urn:li:activity:7372270383585984512"
},
"automation": { "id": "uuid", "name": "Content Engagement" },
// ── Legacy fields (kept for back-compat) ──
"post_id": "urn:li:activity:7372270383585984512",
"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:7372270383585984512"
}
}Setup Guide
- Configure webhook endpoint in Dashboard Settings or via the REST API
- Select event types to receive
- Save your webhook secret for verification (returned only at creation / rotation)
- Ensure endpoint returns 2xx status
- Optional: send a test event from the dashboard or
POST /api/v1/webhooks/:id/test
Important
- • Webhooks timeout after 5-10 seconds
- • Return 2xx status to acknowledge
Code Examples
Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
const sig = req.headers['x-webhook-signature'];
const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body)).digest('hex');
if (sig !== expected) return res.status(401).json({ error: 'Invalid' });
// v2 unified shape — same field names across like / comment / visit.
// Falls back to legacy field names if a v1 payload arrives.
const { event, data } = req.body;
const actorId =
data.lead?.actor_id ??
data.lead?.commenter_id ?? // legacy: new.comment
data.lead?.reactor_id ?? // legacy: new.like
data.visitor?.id; // legacy: new.profile.visit
const postId = data.post?.id ?? data.post_id;
switch (event) {
case 'email.captured':
console.log('Email captured:', data.lead.email);
break;
case 'new.comment':
console.log('Comment from', actorId, 'on post', postId, ':', data.comment.text);
break;
case 'new.like':
console.log(actorId, 'reacted', data.reaction?.type ?? data.lead.reaction_type, 'on', postId);
break;
case 'new.profile.visit':
console.log('Profile visited by', actorId);
break;
}
res.json({ received: true });
});Python / Flask
from flask import Flask, request, jsonify
import hmac, hashlib, json, os
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
sig = request.headers.get('X-Webhook-Signature')
secret = os.environ.get('WEBHOOK_SECRET')
expected = hmac.new(secret.encode(),
json.dumps(request.json, separators=(',',':')).encode(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return jsonify({'error': 'Invalid'}), 401
return jsonify({'received': True})Need Help?
Contact our team for webhook support.
