Your endpoint URL is a public address. Anyone who learns it can POST whatever they like at it, so verify the signature before you act on a delivery.
The header
X-TMV-Signature: t=1756908311,sha256=a3f1…
Two parts: t is the Unix timestamp we signed at, sha256 is the digest.
How it's computed
HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with your endpoint's signing secret:
HMAC_SHA256(secret, "<t>.<raw body>")
Ruby
t, sig = request.headers["X-TMV-Signature"]
.split(",").map { |p| p.split("=", 2).last }
expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{t}.#{request.raw_post}")
# Constant-time compare — a plain == leaks the answer a byte at a time.
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, sig)
# Reject anything older than five minutes, so a captured delivery
# can't be replayed at you next week.
head :unauthorized if Time.now.to_i - t.to_i > 300
Node
const [t, sig] = req.get("X-TMV-Signature")
.split(",").map(p => p.split("=")[1]);
const expected = crypto.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.sendStatus(401);
}
Careful
Sign the raw body, exactly as received. Parsing the JSON and re-serialising it changes whitespace and key order, and the digest will never match — which is the single most common reason verification "doesn't work".
Two more things
- Compare in constant time. A plain
==returns early on the first differing byte, which is enough to guess a signature given patience. - Check the timestamp. A valid signature is valid forever without it, so reject deliveries older than a few minutes.
Lost the secret?
It's shown once and never again. If it's gone, delete the endpoint and create a new one — that issues a fresh secret. Rotate the same way if you think it's leaked.