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

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:

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),
})
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.