# 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=1793712600,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.

The key is the **whole secret string, `whsec_` included**, as UTF-8 bytes. Do not strip the prefix or base64-decode it. This is not the Standard Webhooks scheme, so libraries made for it (such as Svix's) will not verify Delivr's signatures.

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.

**Node.js**

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

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);
    });
}
```

**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
}
```

**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")
```

**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;
}
```

## Test vector

Check your code against this before you wire up the endpoint. The body is exactly these bytes, with no trailing newline.

| Input           | Value                                                                                                                                                   |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Secret          | `whsec_docsTestVectorNotARealSecret00000000000`                                                                                                         |
| Header          | `t=1793712600,v1=a7d3d6de1888ccb64601f9928769ed68ad4f7fc7beb5dc620c51fcecd879f8bc`                                                                      |
| Body            | `{"id":"6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90","type":"webhook.test","apiVersion":"2026-11-01","createdAt":"2026-11-03T13:30:00Z","data":{"test":true}}` |
| Now             | `1793712600` (2026-11-03T13:30:00Z)                                                                                                                     |
| Expected result | Valid. With `now` 301 seconds later, or any other secret, it is invalid.                                                                                |

The same check with OpenSSL:

```bash
printf '%s' '{"id":"6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90","type":"webhook.test","apiVersion":"2026-11-01","createdAt":"2026-11-03T13:30:00Z","data":{"test":true}}' > body.json
printf '1793712600.' | cat - body.json | openssl dgst -sha256 -hmac 'whsec_docsTestVectorNotARealSecret00000000000' -hex
# … a7d3d6de1888ccb64601f9928769ed68ad4f7fc7beb5dc620c51fcecd879f8bc
```

## 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.
- **Decoding the secret.** Use it as it is, `whsec_` and all.