Verifying signatures
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 delivery id
Section titled “The delivery id”Alongside the signature, every request carries a Forge-Delivery-Id header. Delivery
is at-least-once — retries and manual replays
can send the same delivery more than once — and this id stays stable across all of
them. Dedupe on it: record processed ids and skip repeats. (Each re-send is signed
fresh, so a legitimate redelivery always carries a current t — the timestamp check
never rejects it.)
The signing secret
Section titled “The signing secret”Your endpoint’s signing secret is returned once, in the 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, roll it with
POST /v1/webhook-endpoints/{id}/roll (or mf webhooks roll <id>) — the new secret
is revealed once and the old one stops verifying. 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 (5 minutes is the SDK default), 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”In Go, don’t hand-roll any of this — the Go SDK ships
forge.VerifyWebhookSignature, which parses the header, enforces the tolerance, and
compares in constant time. For other languages, the snippets below do the same by
hand with a 5-minute tolerance.
package main
import ( "errors" "io" "net/http" "os"
forge "terriblegit.com/terrible/forge-go")
func webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { w.WriteHeader(http.StatusBadRequest) return }
// Pass 0 as the tolerance to use forge.DefaultWebhookTolerance (5 minutes). err = forge.VerifyWebhookSignature( body, r.Header.Get("Forge-Signature"), os.Getenv("FORGE_WEBHOOK_SECRET"), // the whsec_… secret from create/roll forge.DefaultWebhookTolerance, ) if errors.Is(err, forge.ErrInvalidWebhookSignature) { w.WriteHeader(http.StatusBadRequest) return }
// Verified — dedupe on Forge-Delivery-Id, then parse and handle the event. w.WriteHeader(http.StatusNoContent)}VerifyWebhookSignature(payload, header, secret, tolerance) returns
forge.ErrInvalidWebhookSignature for a wrong secret, tampered body, malformed
header, or a timestamp outside the tolerance.
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. Legitimate retries and replays are
signed fresh at send time, so they always pass. Keep the window tight (minutes, not
hours) and your endpoint idempotent by
deduping on Forge-Delivery-Id.