# Signature Verification

Verify that a webhook delivery came from Linked API: how the HMAC-SHA256 signature is computed, test vectors, and verification code.

> The API Reference is also the SDK and CLI reference. Consecutive code fences marked `tab=HTTP`, `tab=Node`, `tab=Python` and `tab=CLI` are the same call on each surface (a fence marked `tab=HTTP,CLI` is the same text for both), and a block between `:::surface <names>` and `:::` applies only to the surfaces it names. Identifiers in the text are written as in Node and in HTTP JSON (camelCase); the Python SDK spells methods, parameters and result attributes in snake_case (`sendMessage` → `send_message`, `reconnectionLink` → `reconnection_link`), while names inside HTTP JSON stay camelCase.

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.

:::surface node
In the Node SDK, `verifyWebhookSignature()` does this check for you – see [Verifying a delivery](#verifying-a-delivery).
:::

:::surface python
In the Python SDK, `verify_webhook_signature()` does this check for you – see [Verifying a delivery](#verifying-a-delivery).
:::

:::surface cli
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](https://app.linkedapi.io/webhook), 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`](/docs/admin-webhooks#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`](/docs/admin-webhooks#webhook-revealsecret), and replace a leaked one with [`webhook.rotateSecret`](/docs/admin-webhooks#webhook-rotatesecret).

:::surface cli
The CLI keeps the signing secret out of your terminal history and logs:

- `set --signing`, `set-signing --on` and `rotate-secret` without a flag show the secret as `[REDACTED]`, with `--json` and `--fields` too, and say on stderr how to read it.
- `reveal-secret` needs one of two flags. `--print-secret` writes only the secret and a newline to stdout, so it fits `$(...)`. `--secret-file <path>` creates a new file with mode `0600`, refuses a path that already exists, and prints the path rather than the secret.
- `rotate-secret` warns 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 with `reveal-secret`.

```bash
# 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.

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.

| | |
| --- | --- |
| 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

:::surface http,cli
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:
:::

:::surface node,python
The SDKs ship this check, with the two header names exported as `WEBHOOK_SIGNATURE_HEADER` and `WEBHOOK_SIGNATURE_TIMESTAMP_HEADER`:
:::

```js tab=HTTP,CLI
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),
})
```

```typescript tab=Node
import {
  verifyWebhookSignature,
  WEBHOOK_SIGNATURE_HEADER,
  WEBHOOK_SIGNATURE_TIMESTAMP_HEADER,
} from "@linkedapi/node";

const isAuthentic = verifyWebhookSignature({
  rawBody, // the raw body: string, Buffer, Uint8Array or ArrayBuffer
  timestamp: request.headers[WEBHOOK_SIGNATURE_TIMESTAMP_HEADER],
  signature: request.headers[WEBHOOK_SIGNATURE_HEADER],
  secret: process.env.LINKED_API_WEBHOOK_SECRET,
});
```

```python tab=Python
import os

from linkedapi import (
    WEBHOOK_SIGNATURE_HEADER,
    WEBHOOK_SIGNATURE_TIMESTAMP_HEADER,
    verify_webhook_signature,
)

is_authentic = verify_webhook_signature(
    raw_body=raw_body,  # the raw body: str, bytes, bytearray or memoryview
    timestamp=request.headers.get(WEBHOOK_SIGNATURE_TIMESTAMP_HEADER),
    signature=request.headers.get(WEBHOOK_SIGNATURE_HEADER),
    secret=os.environ.get("LINKED_API_WEBHOOK_SECRET"),
)
```

:::surface node
`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](/docs/webhooks#receiving-events); the code below is the same check, for any other language.
:::

:::surface python
`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](/docs/webhooks#receiving-events); the code below is the same check, for any other language.
:::

```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"))
}

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.
