Webhook Events

Receive workflow, account, and LinkedIn activity events in real time. Instead of polling for a workflow result, register a webhook once and Linked API delivers an event to your endpoint whenever a workflow changes state, a LinkedIn account changes status, a message is observed in a monitored inbox, or a connection changes in a monitored network.

How it works

  • You register a single endpoint URL that receives events. A client may hold one active webhook at a time.
  • Every event is delivered as an HTTP POST with a JSON body (the event envelope below).
  • Respond with any 2xx status to acknowledge. A non-2xx response, or a timeout, is retried with exponential backoff for up to 8 attempts before the delivery is marked failed.
  • Two optional settings secure the delivery: custom headers, so an endpoint that requires authentication lets us in, and a signature, so you can prove a delivery came from Linked API. Both are off by default and neither changes anything until you turn it on.

Set this up either way: in the platform dashboard, or programmatically through the Admin API. Both reach the same webhook, so you can register it in the dashboard and change it from code, or the other way round.

Event envelope

Every delivery has the same shape:

json
{
  "id": "workflow.completed:wf-64835e7c-...",
  "type": "workflow.completed",
  "createdAt": "2026-06-25T12:00:00.000Z",
  "data": {
    "workflowId": "wf-64835e7c-...",
    "accountId": "f9b4346a-...",
    "status": "completed",
    "result": { }
  }
}
  • id – stable, unique event identifier. Use it to deduplicate: a retried delivery reuses the same id.
  • type – event type, one of the values listed below.
  • createdAt – ISO 8601 timestamp of when the event was produced.
  • data – event-specific payload, described per event type below.

Delivery order is best-effort and not guaranteed. Correlate events by type, data.status, and createdAt rather than by arrival order.

Workflow events

data carries workflowId, accountId, and status. All events for one run share the same workflowId, so you can correlate them.

  • workflow.created – your request was accepted and queued. status is pending.
  • workflow.started – the workflow actually started running. status is running.
  • workflow.completed – the workflow reached a terminal state. status is completed or failed.

data.result is included only on workflow.completed, and only in fat payload mode. Cancelled workflows do not emit a webhook.

Account events

data carries accountId and status, emitted when a connected LinkedIn account changes status.

  • account.reconnectionRequired – the account needs the user to reconnect. status is reconnection_required.
  • account.active – the account (re)connected and is operational. status is active.
  • account.frozen – the account was frozen, for example due to an unpaid subscription. status is frozen.
  • account.deleted – the account was deleted. status is deleted.

Inbox events

The inbox.* namespace covers messages observed in a connected account's inbox, as opposed to the state of your own Linked API entities. These events fire for accounts that have inbox monitoring enabled via st.syncInbox / nv.syncInbox, one event per inbox message.

  • inbox.messageReceived – an incoming message was observed in the inbox.
  • inbox.messageSent – an outgoing message from the account was observed. This covers messages sent through the LinkedIn UI and messages sent through the Linked API, so an API-sent message emits inbox.messageSent in addition to its workflow.completed.

data carries the message with the following fields:

  • accountId – the connected LinkedIn account the message belongs to.
  • type – inbox type the message belongs to (st or nv).
  • threadId – identifier of the conversation thread. Pass it to st.sendMessage / nv.sendMessage to reply.
  • personUrn – URN of the other participant, or null if LinkedIn does not expose it.
  • personHashedUrl – hashed LinkedIn URL of the other participant. This is the form the inbox exposes; every action that takes a person URL accepts it.
  • personPublicUrl – public LinkedIn URL of the other participant, or null if it has not been resolved. It can be null on any message, including the first one in a thread, so never treat it as guaranteed. See inbox polling for how it is resolved.
  • personUrl – deprecated, replaced by personHashedUrl, which carries the same value. Still sent so existing subscribers keep working; do not use it in new code.
  • messageId – unique identifier of the message, matching the id returned by inbox polling.
  • sender – us for inbox.messageSent, them for inbox.messageReceived.
  • text – message text.
  • time – ISO 8601 timestamp of the message.

Because inbox.messageSent also fires for outbound messages your own automations send, filter by data.sender and data.type rather than assuming every event is a fresh inbound reply.

Network events

The network.* namespace covers changes to a connected account's connection graph. These events fire for accounts that have network monitoring enabled via st.syncNetwork, one event per connection change.

  • network.connectionRequestReceived – someone sent the account a connection request. A new incoming pending invitation was observed.
  • network.connectionAccepted – a new connection that matches a request the account sent. The other person accepted an outgoing invitation.
  • network.connectionAdded – a new connection that is not attributable to a request the account sent, for example an incoming request the account accepted, or a connection formed outside the API.

data carries the connection event with the following fields:

  • accountId – the connected LinkedIn account the event belongs to.
  • personUrn – URN of the other person, or null if LinkedIn did not expose an identifier on the row that produced the event.
  • personUrl – LinkedIn URL of the other person.
  • detectedAt – ISO 8601 timestamp of when Linked API observed the event, matching the detectedAt returned by network polling.

Payload modes

The payload mode controls how much data workflow.completed carries:

  • fat (default) – the full workflow result is inlined in data.result.
  • thin – data.result is omitted; fetch the result via the workflow API using data.workflowId.

You can switch the mode at any time when managing the webhook.

Custom delivery headers

If your endpoint requires authentication, put its credential here and Linked API sends it with every delivery — retries, test events and replays included. Up to 10 headers per webhook.

Add them in the dashboard under Advanced, or through webhook.setHeader. In the dashboard a saved header shows its name with the value masked; Replace value opens an empty field, because the value cannot be read back.

A webhook with no headers configured sends exactly what it always sent: content-type: application/json, plus the signature headers if signing is on.

Values are write-only. Reading the webhook returns headerNames — the names you configured — and never the values. That is also why headers are set one at a time: replacing the whole map would force you to re-enter every credential you were not changing.

Limits, all enforced when you save:

RuleValue
headers per webhook10
value length1024 characters
all headers, serialized4096 bytes
namean RFC 7230 token
valuetab and printable ASCII only

Reserved names, matched case-insensitively and rejected: content-type, content-length, host, connection, transfer-encoding, expect, upgrade, and any name beginning linked-api-. Also reserved: get, post, put, patch, delete, head and common — our HTTP client treats those as its own configuration keys rather than as headers, so one of them would be sent under the wrong name or dropped. Two names differing only in case are rejected for the same reason.

Header values are stored unencrypted, and our API responses never return them. One exception is worth knowing before you choose what to put here: if a request body is invalid JSON, the parser's error quotes about ten characters around the fault, and that text is both returned in the 400 and written to our logs. The usual way to hit it is a quoting slip in curl that sends the value unquoted. So prefer a credential scoped to webhook ingestion, and rotate it on your side as you would any shared secret. We never follow redirects, so your credential cannot be forwarded to another host by a 3xx from your endpoint.

Signature verification

Turn signing on and every delivery to your endpoint carries an HMAC-SHA256 signature you can verify. It is off by default, per webhook, and enabling it changes nothing for anyone else.

In the dashboard, open Advanced on your endpoint and turn on Sign deliveries. The signing secret appears there, with buttons to copy and to rotate it; after a reload it is masked behind Reveal, so it is never left on screen.

Programmatically, enable it with webhook.setSigning, which returns the secret. The secret already exists from the moment you register the webhook, so you can also retrieve it later with webhook.revealSecret, and replace a leaked one with webhook.rotateSecret.

Signed deliveries carry two extra headers:

  • linked-api-signature-timestamp – unix seconds when the delivery was signed.
  • linked-api-signature – HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hex encoded.

How the signature is computed

Four details decide whether your implementation agrees with ours. Three are conventional; the first is not, and it is the one that silently breaks a reasonable implementation.

  1. The secret is used as a UTF-8 string – do not hex-decode it. The secret is 64 hexadecimal characters, and the natural assumption is that it encodes 32 key bytes. It does not: those 64 characters are the key, passed to the hash as text. Decoding them first produces a different signature, and every delivery then fails verification with no clue as to why.
  2. The timestamp is the decimal unix time in seconds, exactly as it appears in the header.
  3. The separator between timestamp and body is a single period: "<timestamp>.<rawBody>".
  4. The body is the raw bytes we sent. Verify before parsing — a body that has been parsed and re-serialized will not verify, because key order and whitespace change the bytes.

Test vectors

Check your implementation against these before trusting it in production. Both come from the signer Linked API actually runs.

secret000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
timestamp1700000000
raw body{"id":"event-1","type":"webhook.test","data":{"message":"Hello"}}
expected signature8f93c19766fe42f27da56fcb5ae11a909c406683ccc0766bd29edc52c71fd209

If you get cf8a7cbd4da0130448492ced3d1aaffd608738b7fff2623e27eaecd4dd54a236 instead, you hex-decoded the secret — see point 1 above. That is the single most likely mistake, and this is how you recognise it in one comparison.

A second vector, because the first is pure ASCII and would not catch an implementation that hashes characters instead of bytes. Same secret and timestamp:

raw body{"id":"event-2","type":"webhook.test","data":{"message":"Zoë ♥ 日本"}}
expected signature87f5bc51d542ce4d8b61350b35807420dce7b3473bd7ecec87211b544c299c69

That body is 68 characters and 75 UTF-8 bytes. Hash the bytes.

Verifying a delivery

js
const { createHmac, timingSafeEqual } = require("node:crypto")

const TOLERANCE_SECONDS = 300

function verifyWebhookSignature({ rawBody, timestamp, signature, secret, nowSeconds }) {
  const sentAt = Number(timestamp)
  if (!Number.isInteger(sentAt)) {
    return false
  }
  // Number.isFinite also closes the case where nowSeconds was not passed: NaN > TOLERANCE
  // is false, so without this check a missing clock would accept any timestamp.
  if (!Number.isFinite(nowSeconds) || Math.abs(nowSeconds - sentAt) > TOLERANCE_SECONDS) {
    return false
  }
  // Validate shape and length BEFORE comparing: timingSafeEqual throws on a length
  // mismatch, so a malformed signature must be rejected here, not by an exception.
  if (typeof signature !== "string" || !/^[0-9a-f]{64}$/.test(signature)) {
    return false
  }
  // The same rule for the body, and it is not hypothetical: the sender picks its
  // Content-Type, so a body parser can hand you {} or undefined. update() would throw
  // on those, and an exception here is a crash rather than a rejection.
  // ArrayBuffer.isView covers Buffer, every TypedArray and DataView.
  if (typeof rawBody !== "string" && !ArrayBuffer.isView(rawBody)) {
    return false
  }
  // Hashed in two steps so rawBody is never stringified: a Uint8Array would become
  // "123,34,105,..." and an ArrayBuffer "[object ArrayBuffer]". update() takes a string,
  // a Buffer, a TypedArray or a DataView — wrap a bare ArrayBuffer in new Uint8Array(...).
  const expected = createHmac("sha256", secret).update(`${sentAt}.`).update(rawBody).digest("hex")
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))
}

// At the call site, nowSeconds is your own clock. Pass it in rather than reading the
// clock inside, so the function stays testable against the fixed vectors above:
//
//   verifyWebhookSignature({
//     rawBody,                                    // raw body: string, Buffer or TypedArray
//     timestamp: request.headers["linked-api-signature-timestamp"],
//     signature: request.headers["linked-api-signature"],
//     secret: process.env.LINKED_API_WEBHOOK_SECRET,
//     nowSeconds: Math.floor(Date.now() / 1000),
//   })

No SDK ships a verification helper yet, so this is the reference implementation. Two things in it are not decoration: the comparison is constant-time, and the signature's shape is validated before that comparison.

TOLERANCE_SECONDS is yours to choose; 300 is a reasonable default. Reject a timestamp outside the window in both directions — too old replays a captured delivery, too far in the future means a clock you cannot trust.

Keep your clock in sync, and do not tighten the window to save effort. Each retry is re-signed with a fresh timestamp, but a timestamp is only ever stale from your side, so a skewed clock rejects every attempt alike: all 8 are spent in about three minutes and the delivery ends as failed.

Rotation takes effect immediately and there is no overlap period. The old secret stops signing the moment the new one is stored, so rotate when you are ready to deploy the new value, not before.

Receiving events

Your endpoint should:

  1. If signing is on, verify the signature against the raw body, before parsing it.
  2. Read the JSON body and branch on type.
  3. Respond 2xx quickly to acknowledge. Do heavy work asynchronously – a slow endpoint will time out and be retried.
  4. Deduplicate on the envelope id. Deliveries are at-least-once, so the same event can arrive more than once.