Hypeline Docs

Verifying webhook signatures

How to verify the HMAC signature on every delivered webhook so you can trust it came from us and was not tampered with.

Every webhook we deliver is signed with HMAC-SHA256 using the shared secret you saw once when you created the endpoint. Verifying the signature proves two things: the delivery came from us, and the body was not altered in transit. The scheme is Standard Webhooks, so a generic library works too, but the recipe below is all you need.

The headers on every delivery

Each POST carries three signing headers:

HeaderValue
webhook-idThe event UUID (also your idempotency / dedup key).
webhook-timestampThe unix-seconds time the delivery was signed.
webhook-signatureOne or more space-delimited v1,<base64> tokens.

The recipe (language-agnostic)

  1. Read the raw request body as bytes. Do not parse and re-serialize it first: you must verify the exact bytes you received.
  2. Build the signed message by concatenating, with literal dots between them: webhook-id + . + webhook-timestamp + . + the raw body.
  3. Compute HMAC-SHA256(secret, message) and base64-encode the digest (standard base64). Prefix it with v1, to get your expected token.
  4. Split webhook-signature on spaces and compare your expected token against each one using a constant-time comparison. Accept if any token matches. (Multiple tokens appear only during a secret rotation, so both the old and new secret verify during the overlap.)
  5. Reject the delivery if |now - webhook-timestamp| is more than 5 minutes. This is your replay defense.
  6. Dedup on webhook-id: the same event UUID may be delivered more than once (we guarantee at-least-once), so treat a repeated id as already handled.

Verify the exact bytes

Sign and compare over the raw body you received. Any re-encoding (key reordering, added whitespace) changes the bytes and the signature will not match.

Go reference verifier

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strings"
	"time"
)

// verify returns true when the signature header carries a token that matches the
// HMAC over id.timestamp.body with the shared secret.
func verify(secret []byte, id, ts string, body []byte, header string) bool {
	mac := hmac.New(sha256.New, secret)
	fmt.Fprintf(mac, "%s.%s.", id, ts) // id "." timestamp "."
	mac.Write(body)                    // then the raw body bytes
	want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, got := range strings.Fields(header) { // space-delimited during rotation
		if hmac.Equal([]byte(got), []byte(want)) {
			return true
		}
	}
	return false
}

// fresh is the replay defense: reject a timestamp more than 5 minutes from now.
func fresh(ts int64, now time.Time) bool {
	d := now.Sub(time.Unix(ts, 0))
	if d < 0 {
		d = -d
	}
	return d <= 5*time.Minute
}

Wire it into an HTTP handler like this:

func handle(secret []byte) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("webhook-id")
		ts := r.Header.Get("webhook-timestamp")
		sig := r.Header.Get("webhook-signature")
		body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))

		n, err := strconv.ParseInt(ts, 10, 64)
		if err != nil || !fresh(n, time.Now()) {
			http.Error(w, "stale or bad timestamp", http.StatusBadRequest)
			return
		}
		if !verify(secret, id, ts, body, sig) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		// alreadyHandled(id) => 200 and stop (at-least-once dedup on webhook-id).
		w.WriteHeader(http.StatusOK)
	}
}

Node reference verifier

import crypto from "node:crypto";

function verify(secret, id, ts, body, header) {
  const mac = crypto.createHmac("sha256", secret);
  mac.update(`${id}.${ts}.`);
  mac.update(body); // body is a Buffer of the raw bytes
  const want = "v1," + mac.digest("base64");
  for (const got of header.split(" ")) {
    // timingSafeEqual needs equal-length buffers.
    const a = Buffer.from(got);
    const b = Buffer.from(want);
    if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
  }
  return false;
}

Reject the request if Math.abs(Date.now() / 1000 - Number(ts)) > 300 (the same 5-minute skew check), and dedup on webhook-id.

On this page