---
title: "Verify Linkbreakers webhook signatures"
description: "Every webhook Linkbreakers sends is signed with HTTP Message Signatures (RFC 9421, Web Bot Auth). How to check the signature, the body digest and the timestamp, with code for Node.js and Python."
canonical: "https://linkbreakers.com/help/article/verify-webhook-signatures"
last-updated: 2026-10-10
---

# Verify Linkbreakers webhook signatures

> Every webhook Linkbreakers sends is signed with HTTP Message Signatures (RFC 9421, Web Bot Auth). How to check the signature, the body digest and the timestamp, with code for Node.js and Python.

## Short answer

Every webhook Linkbreakers delivers carries a cryptographic signature. Check it before you trust the payload: rebuild the signed text from the request, verify it with the Ed25519 public key Linkbreakers publishes at `https://linkbreakers.com/.well-known/http-message-signatures-directory`, and compare the body with its `Content-Digest`. A request that fails any check did not come from Linkbreakers, or was changed on the way.

## Quick summary

- Signatures follow [RFC 9421 HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) as profiled by Web Bot Auth, with Ed25519 keys
- Three headers carry them: `Signature-Agent`, `Signature-Input` and `Signature`, plus `Content-Digest` for the body
- The public keys are at `https://linkbreakers.com/.well-known/http-message-signatures-directory`. Find the key whose `kid` equals the `keyid` in `Signature-Input`
- A signature covers the method, the host, the path, the content type, the body digest and the `Signature-Agent` header
- Signatures are valid for 5 minutes and carry a random nonce, so a captured request cannot be replayed later or to another host
- There is no shared secret to configure: verification uses public keys only

## What a signed webhook looks like

```http
POST /linkbreakers HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
Content-Digest: sha-256=:UDAC0RpGSuOzN4JT7Td8yjj86GmePP3+oXRN9wMS/Tw=:
Signature-Agent: sig1="https://linkbreakers.com"
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest" "content-type" "signature-agent";key="sig1");created=1791656529;keyid="kh4su9z1MEdg7fG5dFncvPvZ4suelLjKNnD_HQ0vJDs";alg="ed25519";expires=1791656829;nonce="...";tag="web-bot-auth"
Signature: sig1=:dP69GlhzXKuF...:
```

`Signature-Agent` names where the keys are published. `Signature-Input` lists what was signed, in order, and the signature parameters. `Signature` is the Ed25519 signature, base64 between colons.

## Step-by-step

1. **Keep the raw body.** Verify against the exact bytes you received, before any JSON parsing or reformatting.
2. **Check the digest.** Compute `sha-256=:<base64 of SHA-256(body)>:` and compare it with `Content-Digest`.
3. **Read `Signature-Input`.** Take the component list between the parentheses and the parameters after it. Refuse anything whose `tag` is not `web-bot-auth`, or whose `expires` has passed.
4. **Rebuild the signature base.** One line per component, in the listed order, as `"<component>": <value>`, then a last line `"@signature-params": <everything after sig1=>`. Lines are joined with a single newline and there is no trailing newline. The values are:
   - `"@method"`: the method in upper case, `POST`
   - `"@authority"`: the host of your webhook URL, lower case, with the port only if it is not 443
   - `"@path"`: the path of your webhook URL, without the query string
   - `"content-digest"` and `"content-type"`: the header values
   - `"signature-agent";key="sig1"`: the `Signature-Agent` value after `sig1=`, quotes included: `"https://linkbreakers.com"`
5. **Verify.** Fetch the directory, pick the key whose `kid` matches `keyid`, and check the Ed25519 signature over the base. Cache the directory and fetch it again only when you meet an unknown `keyid`: that is how a key rotation reaches you.
6. **Check it is your webhook.** The payload names the webhook it was sent for in `webhook.id`. Compare it with yours.

Use the URL you configured in Linkbreakers for `@authority` and `@path`, not the one your framework sees behind a proxy, which may differ.

## Node.js

```javascript
import crypto from "node:crypto";

const DIRECTORY_URL = "https://linkbreakers.com/.well-known/http-message-signatures-directory";
let keys = new Map();

async function loadKeys() {
  const response = await fetch(DIRECTORY_URL);
  const { keys: list } = await response.json();
  keys = new Map(list.map((jwk) => [jwk.kid, crypto.createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, format: "jwk" })]));
}

// method: "POST"; url: the URL Linkbreakers called; headers: lower-case names;
// rawBody: the exact bytes received, before any JSON parsing.
export async function verifyLinkbreakersWebhook({ method, url, headers, rawBody }) {
  const digest = "sha-256=:" + crypto.createHash("sha256").update(rawBody).digest("base64") + ":";
  if (headers["content-digest"] !== digest) throw new Error("body does not match Content-Digest");

  const input = headers["signature-input"].match(/^sig1=(\((.*?)\)(.*))$/);
  if (!input) throw new Error("no sig1 in Signature-Input");
  const [, signatureParams, componentList, paramList] = input;
  const params = Object.fromEntries([...paramList.matchAll(/;(\w+)=("([^"]*)"|\d+)/g)].map((m) => [m[1], m[3] ?? Number(m[2])]));
  const now = Math.floor(Date.now() / 1000);
  if (params.tag !== "web-bot-auth") throw new Error("unexpected tag");
  if (params.created > now + 60 || params.expires < now) throw new Error("signature expired or from the future");

  const target = new URL(url);
  const values = {
    '"@method"': method.toUpperCase(),
    '"@authority"': target.host,
    '"@path"': target.pathname,
    '"content-digest"': headers["content-digest"],
    '"content-type"': headers["content-type"],
    '"signature-agent";key="sig1"': headers["signature-agent"].replace(/^sig1=/, ""),
  };
  const lines = componentList.split(" ").map((component) => {
    if (!(component in values)) throw new Error(`unexpected component ${component}`);
    return `${component}: ${values[component]}`;
  });
  lines.push(`"@signature-params": ${signatureParams}`);

  if (!keys.has(params.keyid)) await loadKeys();
  const key = keys.get(params.keyid);
  if (!key) throw new Error("unknown keyid");
  const signature = Buffer.from(headers["signature"].match(/^sig1=:(.*):$/)[1], "base64");
  if (!crypto.verify(null, Buffer.from(lines.join("\n")), key, signature)) throw new Error("bad signature");
  return JSON.parse(rawBody);
}
```

With Express, read the body raw: `app.post("/linkbreakers", express.raw({ type: "application/json" }), ...)` and pass `req.body.toString("utf8")` as `rawBody`.

## Python

```python
import base64, hashlib, re, time, json, urllib.parse, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

DIRECTORY_URL = "https://linkbreakers.com/.well-known/http-message-signatures-directory"
_keys = {}

def _load_keys():
    with urllib.request.urlopen(DIRECTORY_URL) as response:
        for jwk in json.load(response)["keys"]:
            raw = base64.urlsafe_b64decode(jwk["x"] + "=" * (-len(jwk["x"]) % 4))
            _keys[jwk["kid"]] = Ed25519PublicKey.from_public_bytes(raw)

def verify_linkbreakers_webhook(method, url, headers, raw_body: bytes):
    """headers: lower-case names. Raises on any failure, returns the payload."""
    digest = "sha-256=:" + base64.b64encode(hashlib.sha256(raw_body).digest()).decode() + ":"
    if headers["content-digest"] != digest:
        raise ValueError("body does not match Content-Digest")

    match = re.match(r"^sig1=(\((.*?)\)(.*))$", headers["signature-input"])
    signature_params, components, param_list = match.group(1), match.group(2).split(" "), match.group(3)
    params = {k: v.strip('"') for k, v in re.findall(r';(\w+)=("[^"]*"|\d+)', param_list)}
    now = int(time.time())
    if params["tag"] != "web-bot-auth" or int(params["expires"]) < now or int(params["created"]) > now + 60:
        raise ValueError("wrong tag or outside the signature window")

    target = urllib.parse.urlsplit(url)
    values = {
        '"@method"': method.upper(),
        '"@authority"': target.netloc.lower(),
        '"@path"': target.path,
        '"content-digest"': headers["content-digest"],
        '"content-type"': headers["content-type"],
        '"signature-agent";key="sig1"': headers["signature-agent"].removeprefix("sig1="),
    }
    lines = [f"{c}: {values[c]}" for c in components] + [f'"@signature-params": {signature_params}']

    if params["keyid"] not in _keys:
        _load_keys()
    signature = base64.b64decode(re.match(r"^sig1=:(.*):$", headers["signature"]).group(1))
    _keys[params["keyid"]].verify(signature, "\n".join(lines).encode())  # raises InvalidSignature
    return json.loads(raw_body)
```

## Other requests Linkbreakers signs

Linkbreakers checks every link destination for malware before visitors reach it. Those checks fetch your URL with a `HEAD` request signed the same way, over `"@authority"` and `"signature-agent";key="sig1"`. Sites and CDNs that verify Web Bot Auth can tell them apart from a bot pretending to be Linkbreakers.

## Frequently asked questions

### Do I need to configure a secret in Linkbreakers?

No. Signatures use public-key cryptography: Linkbreakers signs with a private key only it holds, and anyone can verify with the published public key. Nothing is shared with you that could leak.

### What happens when Linkbreakers rotates its key?

The directory lists the next key before Linkbreakers starts signing with it, and keeps the old one until it expires. If you cache the directory and fetch it again whenever a `keyid` is unknown, a rotation needs no action from you.

### My verification fails for every request. What should I check first?

Most failures come from the base: a body that was parsed and re-serialised before hashing, a host or path taken from behind a proxy instead of the URL you configured, or quotes dropped from the `signature-agent` value. Print the base you rebuild and compare it line by line with the steps above.

### Can someone replay a webhook they captured?

Not to another endpoint, since the host and path are signed, and not later than five minutes after it was sent. Within that window, reject a nonce or an event you have already processed if replays matter to you.

### Is the signature checked against my webhook ID?

The payload carries `webhook.id` and the body is covered by the digest, so check that the ID is one of yours.
