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 Node SDK, verifyWebhookSignature() does this check for you – see Verifying a delivery.
In the Python SDK, verify_webhook_signature() does this check for you – see Verifying a delivery.
In the CLI, linkedin admin webhook set-signing <id> --on turns signing on, and reveal-secret <id> --print-secret writes the secret to stdout – capture it with $(...), as in the example below, so it goes into your endpoint's environment instead of onto the screen.
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.
The CLI keeps the signing secret out of your terminal history and logs:
set --signing,set-signing --onandrotate-secretwithout a flag show the secret as[REDACTED], with--jsonand--fieldstoo, and say on stderr how to read it.reveal-secretneeds one of two flags.--print-secretwrites only the secret and a newline to stdout, so it fits$(...).--secret-file <path>creates a new file with mode0600, refuses a path that already exists, and prints the path rather than the secret.rotate-secretwarns on stderr that the old secret stops working at once, and accepts the same two flags. With--secret-file, the file is created before the secret rotates, so a path that already exists stops the command before anything changes. Without a flag, read the new secret withreveal-secret.
# Hand the secret to your endpoint's environment without printing it
export LINKED_API_WEBHOOK_SECRET="$(linkedin admin webhook reveal-secret whs-... --print-secret)"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.
- 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.
- The timestamp is the decimal unix time in seconds, exactly as it appears in the header.
- The separator between timestamp and body is a single period:
"<timestamp>.<rawBody>". - 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.
| secret | 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f |
| timestamp | 1700000000 |
| raw body | {"id":"event-1","type":"webhook.test","data":{"message":"Hello"}} |
| expected signature | 8f93c19766fe42f27da56fcb5ae11a909c406683ccc0766bd29edc52c71fd209 |
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 signature | 87f5bc51d542ce4d8b61350b35807420dce7b3473bd7ecec87211b544c299c69 |
That body is 68 characters and 75 UTF-8 bytes. Hash the bytes.
Verifying a delivery
Without an SDK, copy verifyWebhookSignature from the reference implementation below – or port it to your language – and call it with the raw body and the two headers:
The SDKs ship this check, with the two header names exported as WEBHOOK_SIGNATURE_HEADER and WEBHOOK_SIGNATURE_TIMESTAMP_HEADER:
const isAuthentic = verifyWebhookSignature({
rawBody, // the 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),
})verifyWebhookSignature returns true or false and never throws: a missing or malformed header, an empty secret or a body of the wrong type is simply false. It uses the secret as the literal text you received, never hex-decoded, and accepts a timestamp up to 300 seconds away from your clock in either direction. Change that window with toleranceSeconds, and pass nowSeconds to check a stored delivery against the time it arrived. A complete endpoint that verifies and then parses a delivery is in Receiving events; the code below is the same check, for any other language.
verify_webhook_signature takes keyword arguments only, returns True or False and never raises: a missing or malformed header, an empty secret or a body of the wrong type is simply False. It uses the secret as the literal text you received, never hex-decoded, and accepts a timestamp up to 300 seconds away from your clock in either direction. Change that window with tolerance_seconds, and pass now_seconds to check a stored delivery against the time it arrived. A complete endpoint that verifies and then parses a delivery is in Receiving events; the code below is the same check, for any other language.
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"))
}
module.exports = { verifyWebhookSignature }
// 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),
// })The Node and Python SDKs ship this check as verifyWebhookSignature / verify_webhook_signature; this is the reference implementation for any other language. 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.