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.
The signature header
Section titled “The signature header”Each delivery includes a header in this shape:
Forge-Signature: t=1782192302,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdIt’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.
The signing secret
Section titled “The signing secret”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.
How to verify
Section titled “How to verify”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.
-
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.
-
Parse the header into
tandv1. -
Reject stale timestamps. If
tis older than your tolerance (for example, 5 minutes), reject it. This is your replay protection. -
Recompute and compare. HMAC-SHA256 the string
{t}.{raw_body}with your signing secret and compare the hex digest tov1using a constant-time comparison. -
Only then trust the payload. Parse the JSON and handle the event.
Verify snippets
Section titled “Verify snippets”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.
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}import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/** Throws if the signature is missing, stale, or invalid. */export function verify(rawBody: string, sigHeader: string, secret: string): void { const parts = Object.fromEntries( sigHeader.split(",").map((p) => p.trim().split("=") as [string, string]), ); const { t, v1 } = parts; if (!t || !v1) throw new Error("malformed Forge-Signature header");
const age = Math.floor(Date.now() / 1000) - Number(t); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) { throw new Error("timestamp outside tolerance window"); }
const expected = createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(v1); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw new Error("signature mismatch"); }}import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify(raw_body: bytes, sig_header: str, secret: str) -> None: """Raise ValueError if the signature is missing, stale, or invalid.""" parts = dict( kv.strip().split("=", 1) for kv in sig_header.split(",") if "=" in kv ) ts, v1 = parts.get("t"), parts.get("v1") if not ts or not v1: raise ValueError("malformed Forge-Signature header")
if time.time() - int(ts) > TOLERANCE_SECONDS: raise ValueError("timestamp outside tolerance window")
signed = f"{ts}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1): raise ValueError("signature mismatch")Why the timestamp matters
Section titled “Why the timestamp matters”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.