Skip to content

Verifying signatures

Preview Every webhook managed.dev sends carries a Forge-Signature header so you can prove it came from us and wasn’t tampered with or replayed. The signature is an HMAC-SHA256 of the raw request body keyed by your endpoint’s signing secret, with a timestamp folded into the signed string. Your job is to recompute it and compare — in constant time — before you trust the payload.

Each delivery includes a header in this shape:

Forge-Signature header
Forge-Signature: t=1782192302,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

It’s a comma-separated list of key=value pairs:

Element Meaning
t Unix timestamp (seconds) when managed.dev signed and sent the delivery.
v1 The signature: a hex HMAC-SHA256 digest produced with the v1 scheme.

Future schemes add more v keys to the same header; verify against the v1 value and ignore versions you don’t recognize.

Your endpoint’s signing secret is returned once, in the signing_secret field of the create-endpoint response, prefixed whsec_. Store it as a secret in your application’s config — it’s the key both sides use, so anyone who has it can forge a valid signature.

If you lose the secret or suspect it leaked, rotate the endpoint to mint a new one; the old secret stops verifying once rotation completes. You can’t read an existing secret back from the API.

The signed payload is the literal string {t}.{raw_body} — the timestamp, a period, then the raw, unparsed request body. Reconstruct that string, HMAC it with your secret, and compare to v1.

  1. Read the raw body. Capture the exact bytes you received. Don’t parse to JSON and re-serialize first — re-encoding reorders keys and whitespace and breaks the digest.

  2. Parse the header into t and v1.

  3. Reject stale timestamps. If t is older than your tolerance (for example, 5 minutes), reject it. This is your replay protection.

  4. Recompute and compare. HMAC-SHA256 the string {t}.{raw_body} with your signing secret and compare the hex digest to v1 using a constant-time comparison.

  5. Only then trust the payload. Parse the JSON and handle the event.

Each example reads the raw body, enforces a 5-minute timestamp tolerance, and compares in constant time. The tolerance window is yours to set — 300 seconds is a reasonable default.

verify.go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// Verify checks the Forge-Signature header against the raw request body.
func Verify(rawBody []byte, sigHeader, secret string) error {
var ts, v1 string
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
v1 = kv[1]
}
}
if ts == "" || v1 == "" {
return errors.New("malformed Forge-Signature header")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("bad timestamp")
}
if time.Since(time.Unix(sec, 0)) > tolerance {
return errors.New("timestamp outside tolerance window")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%s.%s", ts, rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
return errors.New("signature mismatch")
}
return nil
}

The timestamp turns a captured-and-resent request into a detectable replay. An attacker who records one valid delivery can resend it verbatim — the signature is still valid because the body is unchanged — but the t value ages out of your tolerance window and your verifier rejects it. Keep the window tight (minutes, not hours) and your endpoint idempotent by deduping on the event id.