1. Register an endpoint
POST /api/webhooks takes a name, your target_url and the events[] you want:
POST /orchestration/v4/api/webhooks
nameis required (1 to 255 characters). Omitting it fails validation.target_urlmust behttps://and must not resolve to a loopback, private or link-local address. DNS names are resolved and checked too, not just IP literals.- Each
events[]value is validated against the platform event catalog, which currently carries 105 types across the services. - The same
target_urlcannot be registered twice in one team. events: ["*"]subscribes to the entire catalog, including event types added later.
secret, a 32-character hex string, exactly once. Store it: every later read masks it, and it is what you verify signatures with.
Webhooks are capped per plan. Creating one past the cap answers
402 with webhook_limit_reached and a body carrying used, limit and a suggested action. The free Sandbox plan’s cap is 0, so webhooks need a paid plan. See billing and plans.Filter what you receive
Two optional filters narrow deliveries at the source, so your endpoint is not woken by events it would discard anyway:filters.account_sidrestricts deliveries to one account (ln_ac_...for LinkedIn). The sid is matched verbatim, so channels added later work here without a change.filters.wheretakes a small filter expression over the event payload. It is validated strictly at write time: nesting depth is capped at 5 and the whole expression at 20 leaf conditions, answeringfilter_grammar_invalidorfilter_leaves_limit_exceededwhen it exceeds either.
2. Verify deliveries
Every real delivery is an HTTPSPOST with a JSON body and these headers:
The body is a fixed envelope, with the event’s own data nested under
payload:
HMAC-SHA256(secret, "{t}.{raw_body}") over the raw request body, with the timestamp mixed in to block replays. Verify before parsing, and reject signatures older than about 5 minutes:
3. Test before relying on it
POST /api/webhooks/{sid}/test fires a synthetic delivery at your endpoint, so you can confirm reachability, signature handling and parsing without waiting for a real event.
Three things to know about it: it is rate-limited to 10 calls per minute per caller (the budget is shared across all your webhooks), it is rejected with invalid_transition while the webhook’s status is off, and it writes no row to the delivery log. It also sets a subset of the headers above, so build your verifier on the signature, timestamp, event and id headers rather than requiring all six.
4. Retries and auto-disable
A delivery is attempted up to 5 times, with waits of 1 minute, 5 minutes, 30 minutes and 2 hours between attempts. The fifth failure is terminal, so a dead endpoint settles about 2.5 hours after the first attempt.
After 20 consecutive failed attempts, or a single
410 Gone, the subscription flips to failed and stops delivering. Its deliveries still waiting for a retry are cancelled with cancel_reason: "webhook_failed", and the failure counter stops moving from that point. That transition itself emits webhooks.failed, so a second webhook can page you when the first one dies; the failing endpoint also gets that one event, a single attempt with no retries.
Bringing it back is three calls: fix the endpoint, PATCH /api/webhooks/{sid} with {"status": "on"} (this resets the counter), then replay what was lost:
from whose status is failed or cancelled (by the platform, never by your own cancel) back into the queue, oldest first, one per second, so your endpoint is not hit by the whole backlog at once; the response says how many rows it re-armed and when the last one is due. to defaults to now, statuses to ["failed", "cancelled"] (pass ["success"] to re-fire delivered events into a rebuilt downstream), event_types to every type. The window spans at most 30 days and one call re-arms at most 5000 rows; beyond either cap the call is refused with the cap named, so narrow the window and call again. Replayed rows keep their webhook_log_sid, so a receiver that deduplicates on it sees the same delivery, not a new one.
5. Use the delivery log
The log holds one row per event per subscription, not one per attempt: a retry updates that row in place, bumpingretry_count and overwriting the response code. The row is the delivery, and its history is the counter.
POST /api/webhook-logs/searchlists deliveries with status, response code and timing; filter by webhook or event type.POST /api/webhook-logs/{sid}/retryre-sends. Allowed from every state butin_progress:pending,retrying,failed,success(a manual re-send of a delivered event is legitimate) andcancelled, whatever cancelled it. It re-arms the row without counting as an attempt, soretry_countstays the number of attempts that actually went out (at most 5). Only while the parent subscription is still live: a deleted oroffwebhook rejects the retry.POST /api/webhook-logs/{sid}/cancelstops a delivery that has not settled. Allowed frompending,retryingandin_progress.POST /api/webhook-logs/metricsaggregates outcomes.periodwithfromandtois required and may span at most 90 days.
retry and cancel answer 409 with invalid_transition when the row is not in a state that allows the verb, and the error names the state it found.
6. Read the payload
payload is the event’s own row, under the field names the REST API uses for that entity, plus a few fields that exist only on the wire:
client_referenceonlinkedin-connection-requests.sent,.accepted,.withdrawn,.expired-detectedand onlinkedin-messages.sentechoes theclient_referenceyou passed to the send. Match on it instead of on a LinkedIn identifier. It isnullon rows the sync picked up from LinkedIn’s own UI. Every send verb accepts it:send_linkedin_connection_requestand all six message sends (the basic message, voice, InMail, Sales Navigator, Recruiter and the group opener), and the comment, post and repost verbs, which emit no event of their own. A mass-action plan step’sargs.client_referenceT reaches every row the run creates asT:{item sid}:{step id}, so match theT:prefix.counterpartonlinkedin-conversations.created,linkedin-messages.receivedandlinkedin-messages.sentis the other side of the thread:{ "full_name", "headline", "picture_url" }, ornullwhen we hold no participant data for the thread yet.picture_urlis LinkedIn’s signed CDN link and expires within hours, so render it on arrival rather than storing it.created_by, where the row has one, is a reference to the actor:{ "actor_type", "actor_sid", "actor_name", "oauth_client_sid", "reason" }.actor_nameandoauth_client_sidname the OAuth client that acted (an MCP client such as Claude, or n8n) and arenullfor a user, an API key or a system job.reasonis set on system jobs, for examplesnapshot_capture_job.nickname, the profile’s vanity URL slug, is present only where LinkedIn exposes it: the connections list and received invitations. Followers, conversations, messages and sent invitations name people by hash id, so those rows carrynicknameonly once the same person is also a 1st-degree connection of the account. Match onln_member_id(stable) orln_id(theACoAA...profile id, present on every event) rather than on the slug.linkedin-account-activity-log.task-failedfires once for each call an account dispatched that endedfailed. Its payload is the activity-log row (sid,linkedin_account_sid,action_type,error_message,duration_ms,created_at,updated_at,trace_id) withpending_age_seconds, how long the call was pending, andfailure_source, what failed it:plugin(the call’s own answer),reconcile(nobody finished the call in time) orbrowser_deleted(its browser was deleted while it ran). On a send’s row,send_outcomeandsend_outcome_reasonsay what came of the send, and they arenullon every other action.not_sent(refused) means nothing reached LinkedIn.unknown(answer_lost) means the send may be out: a failed call is not a send that did not go out, so ask check-sent before you send it again (Sending once). To receive only the sends in doubt, subscribe with"filters": {"where": {"field": "payload.send_outcome", "op": {"eq": "unknown"}}}.deleted_aton a terminallinkedin-connection-requests.*event (accepted,withdrawn,expired-detected) is the moment we detected the transition, not LinkedIn’s own timestamp of it. Acceptance is detected by the next sync of the account’s pending invitations, so it can trail the real acceptance by up to the sync interval.removal_kindsays which transition it was.