# Verifying signatures

> Check that a webhook really comes from Delivr and has not been replayed.

Source: https://docs.delivr.no/webhooks/signatures/

Every webhook carries an `X-Delivr-Signature` header:

```
X-Delivr-Signature: t=1762175400,v1=5f2b9c…
```

- `t` is the attempt's Unix time in seconds.
- `v1` is the hex HMAC-SHA256 of `"<t>.<raw body>"`, keyed with your signing secret (`whsec_…`).

To verify:

1. Split the header on `,` and read `t` and every `v1`.
2. Reject the event if `t` is more than 5 minutes from now. This stops replays of captured requests.
3. Compute the HMAC over the **raw** body bytes, exactly as received. Re-serialized JSON will not match.
4. Accept if any `v1` matches, compared in constant time.

During a secret rotation, Delivr adds a second `v1` signed with the old secret for 24 hours, so accepting any match lets you rotate without dropping events.

<Tabs syncKey="lang">
<TabItem label="Node.js">

```js

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

</TabItem>
<TabItem label="Go">

```go
func Verify(secret, header string, body []byte, now time.Time) bool {
	var t string
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(part, "=")
		if k == "t" {
			t = v
		} else if k == "v1" {
			sigs = append(sigs, v)
		}
	}
	sec, err := strconv.ParseInt(t, 10, 64)
	if err != nil || now.Sub(time.Unix(sec, 0)).Abs() > 5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	want := []byte(hex.EncodeToString(mac.Sum(nil)))
	for _, sig := range sigs {
		if hmac.Equal([]byte(sig), want) {
			return true
		}
	}
	return false
}
```

</TabItem>
<TabItem label="Python">

```python
import hashlib, hmac, time

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

</TabItem>
<TabItem label="PHP">

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

</TabItem>
</Tabs>

## Common mistakes

- **Parsing before verifying.** Frameworks that parse JSON first lose the raw bytes. Keep the raw body for the route (for example `express.raw`).
- **Comparing with `==`.** Use a constant-time comparison.
- **Only checking the first `v1`.** During a rotation the matching signature may be the second.
- **Trusting the timestamp in the body.** Use `t` from the header.