Skip to content

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.

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.

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.)

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.

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 (5 minutes is the SDK default), 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.

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.

handler.go
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.

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.