> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cromos.so/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 ours.

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. Ours does not, and
neither should yours.

We always send **exactly one `v1`**. If a header ever carries two, our parser — and the
examples below — silently keep the last one rather than reject it; a stricter receiver may
reject outright instead, since nothing we send 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 you back a string that looks identical — but 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

`hmac.compare_digest`, `crypto.timingSafeEqual`, `hmac.Equal` — not `==`. 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. These are
asserted against our own signer in CI, so if your code reproduces this digest, it agrees with
what we actually send.

|               |                                                                                            |
| ------------- | ------------------------------------------------------------------------------------------ |
| 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 our CI, so it cannot drift from
what we send.

```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 — we would rather say so than imply a guarantee we do
  not have.
</Note>

## Storing and rotating the secret

Keep the secret in an environment variable or a secret manager, one per endpoint. We store it
encrypted, but we can still read it back — the server has to, in order to sign — so it is not
recoverable-proof the way an API key is. 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.

The way 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.
