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

# Verifying deliveries

> Every delivery is signed. Check the signature before you trust the body.

Your endpoint URL is reachable by anyone who learns it. The signature is what makes a request
that arrives there provably genuine.

Each endpoint has its own secret, shown once when you create it and revealable afterwards in the
console under **Settings → Webhooks**. Treat it like a password: environment variable, never in
a repository, never in client-side code.

## The signature

Every delivery carries:

```
X-Signature: t=1785283200,v1=7e4306381ed67b399b28fe307a71749c9bd38dfbbf83a38f4755af6cc08eff5a
```

| Part | Meaning                                                                   |
| ---- | ------------------------------------------------------------------------- |
| `t`  | The Unix second the delivery was signed. Matches `^\d{1,10}$`.            |
| `v1` | The signature. **Exactly 64 lowercase hex characters**, `^[0-9a-f]{64}$`. |

Reject anything that does not match those two shapes before you do any crypto. The `v1` length
rule is not pedantry: some hex decoders silently drop a trailing odd character, so accepting
"any hex" means several different header strings verify the same payload.

A delivery always carries **exactly one `v1`**. If a header ever carries two, the examples below
keep the last one rather than reject it. A stricter receiver may reject outright instead, since
nothing sent to you will ever trip that check.

## Computing it

Two steps, in this order.

**1. Build the signed string.** Join the timestamp and the raw body with a single `.`:

```
1785283200.{"id":"evt_test","name":"ping","created_at":"2026-07-29T00:00:00Z","data":{}}
```

**2. HMAC it with the whole secret.** The key is the **whole secret string, including its
`whsec_` prefix**. Not the part after the prefix, the whole thing, exactly as the console showed
it to you. Take `HMAC-SHA256`, render it lowercase hex, and compare it to `v1` in constant time.

### Use the raw body

Sign the **exact bytes** that arrived. Not a parsed object, not a re-serialised one.

This is the mistake that costs an afternoon. Most web frameworks parse JSON for you, and
`JSON.stringify` on the result gives back a string that looks identical. Key order, spacing and
number formatting are all free to differ, and `created_at` carries milliseconds that a
round-trip through a date type will quietly drop. One changed byte changes the whole digest, so
**every** signature mismatches and nothing in the error tells you why.

In Express, that means `express.raw({ type: 'application/json' })` on the webhook route. In
Flask, `request.get_data()`, not `request.json`. In Go, read `r.Body` yourself before decoding.

### Check the timestamp

Reject anything more than **five minutes** away from your own clock, in either direction. A
future timestamp is as much a red flag as a stale one, and clamping only the past leaves a
clock-skewed replay valid indefinitely.

Retries are re-signed with a fresh `t` at send time, so a legitimate retry sixteen hours later
still arrives inside the window.

### Compare in constant time

Use `hmac.compare_digest`, `crypto.timingSafeEqual` or `hmac.Equal`, never `==`. A
byte-at-a-time comparison leaks how much of a guess was right.

## Test vector

Fixed values you can check your implementation against before wiring anything up. They are
asserted against the real signer in CI, so a digest your code reproduces here agrees with what
arrives at your endpoint.

|               |                                                                                            |
| ------------- | ------------------------------------------------------------------------------------------ |
| Secret        | `whsec_TESTVECTORdoNotUseThisSecretAnywhereReal000`                                        |
| Timestamp     | `1785283200`                                                                               |
| Body          | `{"id":"evt_test","name":"ping","created_at":"2026-07-29T00:00:00Z","data":{}}`            |
| Signed string | `1785283200.{"id":"evt_test","name":"ping","created_at":"2026-07-29T00:00:00Z","data":{}}` |
| Expected `v1` | `7e4306381ed67b399b28fe307a71749c9bd38dfbbf83a38f4755af6cc08eff5a`                         |

<Note>
  These are **fixed test values**, not a specimen delivery. A real payload's `created_at` carries
  milliseconds (`2026-07-29T06:15:02.128Z`); this one is a constant chosen so the vector never
  moves.
</Note>

## Node

This exact snippet is executed against the vector above in CI, so it cannot drift from what
arrives at your endpoint.

```js theme={null}
const { createHmac, timingSafeEqual } = require('node:crypto')

const REPLAY_WINDOW_SECONDS = 300

function verify(secret, rawBody, signatureHeader) {
  const parts = new Map()
  for (const segment of String(signatureHeader).split(',')) {
    const index = segment.indexOf('=')
    if (index === -1) continue
    parts.set(segment.slice(0, index).trim(), segment.slice(index + 1).trim())
  }

  const t = parts.get('t')
  const v1 = parts.get('v1')
  if (!t || !/^\d{1,10}$/.test(t)) return false
  if (!v1 || !/^[0-9a-f]{64}$/.test(v1)) return false

  const now = Math.floor(Date.now() / 1000)
  // Written as a negated `<=` rather than `>` so a non-finite clock rejects instead of
  // silently disabling replay protection: NaN comparisons are always false, so `>` would
  // let anything through, while `!(NaN <= WINDOW)` is true.
  if (!(Math.abs(now - Number(t)) <= REPLAY_WINDOW_SECONDS)) return false

  // Two updates rather than one template string, so a Buffer body is hashed as its own bytes
  // instead of being stringified first.
  const mac = createHmac('sha256', secret)
  mac.update(`${t}.`)
  mac.update(rawBody)

  return timingSafeEqual(mac.digest(), Buffer.from(v1, 'hex'))
}

module.exports = { verify }
```

## Python

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

REPLAY_WINDOW_SECONDS = 300

T_PATTERN = re.compile(r"^\d{1,10}$")
V1_PATTERN = re.compile(r"^[0-9a-f]{64}$")


def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
    parts = {}
    for segment in signature_header.split(","):
        key, sep, value = segment.partition("=")
        if sep:
            parts[key.strip()] = value.strip()

    t = parts.get("t", "")
    v1 = parts.get("v1", "")
    if not T_PATTERN.match(t) or not V1_PATTERN.match(v1):
        return False

    if abs(int(time.time()) - int(t)) > REPLAY_WINDOW_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256
    ).hexdigest()

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

## Go

```go theme={null}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"regexp"
	"strconv"
	"strings"
	"time"
)

const replayWindowSeconds = 300

var (
	tPattern  = regexp.MustCompile(`^\d{1,10}$`)
	v1Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
)

func Verify(secret string, rawBody []byte, signatureHeader string) bool {
	var t, v1 string
	for _, segment := range strings.Split(signatureHeader, ",") {
		key, value, found := strings.Cut(segment, "=")
		if !found {
			continue
		}
		switch strings.TrimSpace(key) {
		case "t":
			t = strings.TrimSpace(value)
		case "v1":
			v1 = strings.TrimSpace(value)
		}
	}

	if !tPattern.MatchString(t) || !v1Pattern.MatchString(v1) {
		return false
	}

	sent, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	if delta := time.Now().Unix() - sent; delta > replayWindowSeconds || delta < -replayWindowSeconds {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)

	provided, err := hex.DecodeString(v1)
	if err != nil {
		return false
	}

	return hmac.Equal(mac.Sum(nil), provided)
}
```

<Note>
  Only the Node example is executed automatically. The Python and Go examples are checked against
  the same vector by hand rather than in CI, which is stated here rather than left to imply a
  guarantee that does not exist.
</Note>

## Storing and rotating the secret

Keep the secret in an environment variable or a secret manager, one per endpoint. It is stored
encrypted, but it stays readable in a way an API key does not, because signing needs it back. If
you think it has leaked, rotate it.

**Rotation happens immediately and it is a hard cutover.** The moment you rotate, the old secret
stops signing anything. Deliveries already in flight, and any retry of a delivery signed with
the old secret, will fail verification at your end. There is no overlap window, and the header
carries exactly one `v1`, so an endpoint cannot accept both secrets during a changeover.

To rotate without dropping a delivery:

1. Rotate in the console and copy the new secret.
2. Deploy it to your receiver.
3. If any delivery failed in between, resend it from the endpoint's delivery log. The resend is
   signed with the new secret.

There is no quiet hour to aim for. Deliveries fire when the change that caused them lands, and a
retry can arrive up to 15.8 hours after the delivery it belongs to, so the only thing that
narrows the exposure is keeping steps 1 and 2 close together and checking the log afterwards.
