# Verifying signatures

> Check the ShopAssist-Signature header with HMAC-SHA256 so you only accept genuine webhook deliveries.

Source: https://shopassist.coec.dk/docs/webhooks/verifying-signatures · Updated 2026-10-01

Every delivery is signed with your endpoint's secret. Always verify the signature before you trust the payload.

## The signature [#signature]

The `ShopAssist-Signature` header looks like `t=1791450000,v1=5f0c…e21a`:

* `t` is the Unix time of signing. Every retry is signed again with a new `t`.
* `v1` is `hex(HMAC-SHA256(key, "<t>.<raw body>"))`. The key is the **UTF-8 bytes of the full secret string** as issued, including the `whsec_` prefix.
* During a [secret rotation](#rotation) the header carries two `v1` values. Accept the request if any of them matches.

Reject requests where `t` is more than 300 seconds from your clock.

> **Use the raw body:** Compute the HMAC over the exact bytes you received. Parsing and re-serializing the JSON changes whitespace and key
> order, and the signature will not match.

## Python [#python]

```python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), None)
    sigs = [v for k, v in parts if k == "v1"]
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)
```

## Node.js [#nodejs]

```js
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(rawBody /* Buffer */, header, secret, tolerance = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="))
  const t = parts.find(([k]) => k === "t")?.[1]
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v)
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex")
  return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}
```

## PHP [#php]

```php
<?php
function shopassist_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $sigs[] = $v; }
    }
    if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) { return false; }
    $expected = hash_hmac('sha256', "$t." . $rawBody, $secret);
    foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { return true; } }
    return false;
}
```

## Deduplicate [#dedupe]

A valid event can arrive more than once (for example when your `2xx` was lost). Store `ShopAssist-Event-Id` and skip ids you have already processed.

## Rotating the secret [#rotation]

**Rotate secret** on the endpoint issues a new secret and shows it once. For 24 hours deliveries are signed with both the new and the previous secret, so you can deploy the new one without dropping events. Rotating again within 24 hours replaces the previous secret: only two secrets are ever valid.
