> ## Documentation Index
> Fetch the complete documentation index at: https://docs.weelkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signature verification

> Verify every delivery with HMAC-SHA256 and a constant-time compare before you trust the payload.

Every delivery is signed. The `X-Weelkit-Signature` header carries `v1=` followed by the lowercase hex HMAC-SHA256 of the string `{timestamp}.{raw body}`, keyed with your endpoint's signing secret:

```text theme={null}
v1 = "v1=" + hex( hmac_sha256( secret, timestamp + "." + rawBody ) )
```

* `secret` is the whole secret string, `whsec_` prefix included, taken as UTF-8 bytes. Do not base64-decode it.
* `timestamp` is the value of `X-Weelkit-Timestamp` (unix seconds). It is regenerated on every attempt, including retries, so the tolerance window below also holds for a retry that happens hours later.
* `rawBody` is the **exact request body bytes**. Compute the HMAC before any JSON parsing or re-serialization — a parsed-and-re-encoded body will not match.
* Compare signatures with a **constant-time** comparison, never `==` on strings in languages where that short-circuits.

## Verify a delivery

Both examples use the placeholder secret `whsec_examplesecretexamplesecretexample` — substitute the secret shown when you created or rotated the endpoint.

```javascript Node.js theme={null}
import crypto from "node:crypto";

const SECRET = "whsec_examplesecretexamplesecretexample";
const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * @param {Record<string, string>} headers - lowercased header names
 * @param {Buffer | string} rawBody - the exact request body bytes
 * @returns {boolean}
 */
export function verifyWeelkitWebhook(headers, rawBody) {
  const signature = headers["x-weelkit-signature"] ?? "";
  const timestamp = headers["x-weelkit-timestamp"] ?? "";

  // Replay protection: reject stale or malformed timestamps.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return false;
  }

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", SECRET)
      .update(timestamp + ".")
      .update(rawBody)
      .digest("hex");

  const received = Buffer.from(signature, "utf8");
  const computed = Buffer.from(expected, "utf8");
  return (
    received.length === computed.length &&
    crypto.timingSafeEqual(received, computed)
  );
}
```

```python Python theme={null}
import hashlib
import hmac
import time

SECRET = "whsec_examplesecretexamplesecretexample"
TOLERANCE_SECONDS = 300  # 5 minutes


def verify_weelkit_webhook(headers: dict, raw_body: bytes) -> bool:
    signature = headers.get("X-Weelkit-Signature", "")
    timestamp = headers.get("X-Weelkit-Timestamp", "")

    # Replay protection: reject stale or malformed timestamps.
    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = "v1=" + hmac.new(
        SECRET.encode("utf-8"),
        timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected)
```

> [!WARNING]
>
> Make sure your framework hands you the raw body. Many frameworks parse JSON before your handler runs — register a raw-body middleware for the webhook route, or the HMAC will never match.

## Check your verifier against a known request

This request is signed with the placeholder secret used above. Your verifier must accept it when its clock reads `1786702512` (12 seconds after the timestamp), and must refuse it when any byte of the body, the timestamp or the signature changes.

```json signature-vector theme={null}
{
  "secret": "whsec_examplesecretexamplesecretexample",
  "timestamp": "1786702500",
  "body": "{\"eventId\":\"7b1e9f4a-6c2d-4e8f-9a3b-1c5d7e9f0a2b\",\"eventType\":\"webhook.test\",\"article\":null}",
  "signature": "v1=22c7ed54cc59add5fd5a86c82a9abf034402be274d684452ba912f73dcaa2855"
}
```

## Replay protection

Signature verification alone does not stop an attacker from re-sending a captured, validly signed request later. Layer two checks on top:

1. **Timestamp tolerance** — reject deliveries whose `X-Weelkit-Timestamp` is older than your tolerance window (5 minutes is a sensible default, as in the examples above). The timestamp is covered by the signature, so it cannot be forged.
2. **Deduplicate by operation identity** — record each `X-Weelkit-Operation-Id` you have processed and skip duplicates. Weelkit's retries deliberately **reuse the same operation identity**, so this one check handles both replays and legitimate retries: process once, acknowledge every time.

## Secret rotation

Rotating an endpoint's secret bumps its secret version and issues a new `whsec_` value, shown once. **The old secret stops signing at the moment of rotation:** every later attempt, including the retry of an older delivery, is signed with the new secret only. A delivery your receiver refuses because it still holds the old secret is a definitive rejection (a 4xx) and is not retried.

To roll without refusing deliveries, deploy a verifier that accepts **both** secrets before you rotate, rotate, then drop the old secret. Accept whichever of the two matches.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.