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
- Read the
Wefunder-Signatureheader and split it intotand thev1value(s). Treat the header as invalid — returnfalse, don't raise — if it is missing, has notor nov1,tis not a plain integer, or av1is not 64 lowercase hex characters. Ignore keys you don't recognise (a futurev2), and ignore components without an=. - Reject the request if
tis more than 5 minutes from now (replay protection). - Compute
HMAC-SHA256(secret, "{t}.{raw_body}")over the raw request body — the exact bytes we sent, before any JSON re-serialization. - Compare your hex digest to each
v1using 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.
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.
| Field | Value |
|---|---|
| Secret | whsec_docvector_2f9c1a4b8e7d6c5a |
t | 1705334400 |
| 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 v1 | 0385f3fa13024fc4d851034779083ea49483162d32b668d7646e2b94fccd84db |
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.
| Header | Expect | Why |
|---|---|---|
t=1705334400,v1=<v1> | accept | the canonical vector |
t=1705334400,v1=<other>,v1=<v1> | accept | rotation: any matching v1 is enough |
t=1705334400, v1=<v1> or t=1705334400,v2=x,v1=<v1> | accept | whitespace and unknown keys are ignored |
header absent / "" / garbage / sha256=deadbeef | reject | nothing to verify |
t=1705334400 or v1=<v1> | reject | missing v1 or t |
t=1705334400,v1 or t=1705334400,v1= | reject | v1 has no value |
t=abc,v1=<v1> / t=1.5,v1=<v1> / t=-1,v1=<v1> / a 30-digit t | reject | t is not a plain integer |
t=1705334400,v1=ab or 64 non-hex characters | reject | v1 is not a hex SHA-256 |
t=1705334400,v1=<v1> with now 301 s away | reject | outside the window, either direction |
t=1705334400,v1=<v1> with the body changed by one byte | reject | signature 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.