Skip to main content

Verifying Webhook Signatures

Every webhook we send is signed. Verify the signature before trusting a payload — it proves the request came from Wefunder and hasn't been tampered with or replayed.

The signature header​

Each delivery carries a Wefunder-Signature header:

Wefunder-Signature: t=1705334400,v1=0385f3fa13024fc4d851034779083ea49483162d32b668d7646e2b94fccd84db
  • t — Unix timestamp (seconds) when we signed the request.
  • v1 — hex HMAC-SHA256 of "{t}.{raw_request_body}", keyed with your endpoint's signing secret.

During a secret rotation the header carries more than one v1= value (one per active secret). Treat the signature as valid if any v1 matches.

How to verify​

  1. Read the Wefunder-Signature header and split it into t and the v1 value(s). Treat the header as invalid — return false, don't raise — if it is missing, has no t or no v1, t is not a plain integer, or a v1 is not 64 lowercase hex characters. Ignore keys you don't recognise (a future v2), and ignore components without an =.
  2. Reject the request if t is more than 5 minutes from now (replay protection).
  3. Compute HMAC-SHA256(secret, "{t}.{raw_body}") over the raw request body — the exact bytes we sent, before any JSON re-serialization.
  4. Compare your hex digest to each v1 using a constant-time comparison. Accept if any matches.

Every failure is a 400 from your handler, never a 500: a verifier that raises on a malformed header turns a request you should reject into a server error, and the bad request gets retried.

Use the raw body

Sign the bytes as received. Re-serializing the parsed JSON can reorder keys or change spacing and will break the signature.

Worked example (canonical test vector)​

Use this to confirm your implementation before going live. These exact values are asserted against our signing code in CI, so they always match production.

FieldValue
Secretwhsec_docvector_2f9c1a4b8e7d6c5a
t1705334400
Raw body{"id":"evt_0KZq8xExampleOpened01","event":"offering.opened","created_at":"2026-01-15T12:00:00Z","mode":"live","data":{"offering":"ofr_9m2ExampleRound00","status":"open","company":{"name":"Example Company","url":"example-company"}}}
Expected v10385f3fa13024fc4d851034779083ea49483162d32b668d7646e2b94fccd84db

If your code produces that v1 from that secret, timestamp, and body, your verification is correct.

Implementation​

Each snippet validates the header's shape, checks the timestamp, and does a constant-time compare against every v1. They return false for anything malformed instead of raising, so the handler that calls them answers 400 on bad input. The official SDKs ship the same check as constructEvent / construct_event; these are for when you can't use one.

Ruby​

require "openssl"

TOLERANCE = 300 # 5 minutes

def verify_webhook(raw_body, header, secret, now: Time.now.to_i)
return false unless header.is_a?(String)

timestamp = nil
signatures = []
header.split(",").each do |part|
key, eq, value = part.partition("=")
next if eq.empty? # not a key=value component

key = key.strip
value = value.strip
if key == "t"
return false unless value.match?(/\A[0-9]+\z/)

timestamp = value.to_i
elsif key == "v1"
return false unless value.match?(/\A[0-9a-f]{64}\z/)

signatures << value
end
# Any other key (a future v2) is ignored.
end
return false if timestamp.nil? || signatures.empty?
return false if (now - timestamp).abs > TOLERANCE

expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
signatures.any? { |sig| OpenSSL.secure_compare(sig, expected) }
end

Python​

import hashlib, hmac, re, time

TOLERANCE = 300 # 5 minutes

def verify_webhook(raw_body: bytes, header: str | None, secret: str, now: int | None = None) -> bool:
if not isinstance(header, str):
return False
now = now if now is not None else int(time.time())

timestamp = None
signatures = []
for part in header.split(","):
key, eq, value = part.partition("=")
if not eq: # not a key=value component
continue
key, value = key.strip(), value.strip()
if key == "t":
if not re.fullmatch(r"[0-9]{1,20}", value):
return False
timestamp = int(value)
elif key == "v1":
if not re.fullmatch(r"[0-9a-f]{64}", value):
return False
signatures.append(value)
# Any other key (a future v2) is ignored.
if timestamp is None or not signatures:
return False
if abs(now - timestamp) > TOLERANCE:
return False

signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(sig, expected) for sig in signatures)

Node.js​

const crypto = require("crypto");

const TOLERANCE = 300; // 5 minutes

function verifyWebhook(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
if (typeof header !== "string") return false;

let timestamp;
const signatures = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq < 0) continue; // not a key=value component
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") {
if (!/^[0-9]{1,20}$/.test(value)) return false;
timestamp = Number(value);
} else if (key === "v1") {
if (!/^[0-9a-f]{64}$/.test(value)) return false;
signatures.push(value);
}
// Any other key (a future v2) is ignored.
}
if (timestamp === undefined || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > TOLERANCE) return false;

// rawBody may be a string or a Buffer; update() takes either without re-encoding.
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
return signatures.some((sig) => crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}

Go​

package webhooks

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
)

const tolerance = 300 // 5 minutes

func VerifyWebhook(rawBody []byte, header, secret string, now int64) bool {
var t int64
var sigs []string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
sigs = append(sigs, kv[1])
}
}
if now-t > tolerance || t-now > tolerance {
return false
}

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range sigs {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}

Inputs to test against​

Before going live, feed your verifier these and confirm each one is simply rejected (returns false, no exception). Only the first row is accepted. now is 1705334401 and the secret, body and v1 are the canonical vector above; <v1> stands for its expected signature.

HeaderExpectWhy
t=1705334400,v1=<v1>acceptthe canonical vector
t=1705334400,v1=<other>,v1=<v1>acceptrotation: any matching v1 is enough
t=1705334400, v1=<v1> or t=1705334400,v2=x,v1=<v1>acceptwhitespace and unknown keys are ignored
header absent / "" / garbage / sha256=deadbeefrejectnothing to verify
t=1705334400 or v1=<v1>rejectmissing v1 or t
t=1705334400,v1 or t=1705334400,v1=rejectv1 has no value
t=abc,v1=<v1> / t=1.5,v1=<v1> / t=-1,v1=<v1> / a 30-digit trejectt is not a plain integer
t=1705334400,v1=ab or 64 non-hex charactersrejectv1 is not a hex SHA-256
t=1705334400,v1=<v1> with now 301 s awayrejectoutside the window, either direction
t=1705334400,v1=<v1> with the body changed by one byterejectsignature mismatch

The snippets on this page are run against exactly this list in CI (api-docs/scripts/webhook-verifier-samples.test.js), including the Ruby and Python copies in the tutorial, so the published code can't drift from it.

Why the timestamp?​

Binding t into the signature and rejecting old requests limits the replay window: an attacker who captures a delivery can't resend it later. Five minutes is enough slack for clock skew and network delay without leaving a meaningful attack window.

The window bounds how old a replay can be, not whether one can happen: a captured delivery resent within five minutes still verifies. That is what deduplicating on the envelope id plus installation.id is for — the signature proves the request is ours and recent; your inbox's unique index makes a repeat a no-op.

Testing your endpoint​

Once your handler verifies signatures, use the test action to send a real, signed example event and confirm your endpoint accepts it end-to-end.

Was this page helpful?