Verifying signatures
Anyone can reach your webhook URL. The signature is how you tell an event from tmbilling apart from a request someone else sent to the same address.
Read the signature header
Every delivery carries:
Tmbilling-Signature: sha256=<hex>
The value is HMAC-SHA256(signing_secret, raw_request_body), hex-encoded and prefixed with
sha256=. Use the secret tmbilling returned when you created the endpoint.
Verify the signature
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: Buffer, header: string | undefined, secret: string): boolean {
if (!header) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
// Length must match before timingSafeEqual, which throws on a length mismatch.
return a.length === b.length && timingSafeEqual(a, b);
}
import hmac, hashlib
def verify(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
func Verify(rawBody []byte, header, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
Avoid two mistakes that make this pointless
JSON.stringify(req.body) is not the bytes we signed. Key order, whitespace and number
formatting survive our serialisation but not a parse-and-restringify round trip. Verification
then fails for legitimate events, and the usual "fix" is to stop verifying.Capture the raw body before your JSON middleware touches it. In Express, use
express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }). In Fastify, add a
content-type parser that keeps the buffer. In a Next.js route handler, call
await request.text() and parse it yourself.expected === received leaks how much of the signature was correct through how long the
comparison takes, which is enough to forge one given enough attempts. Use timingSafeEqual,
hmac.compare_digest or hmac.Equal — every language has one.Reject a failed verification
Answer 401 and nothing else. Give no detail about why it failed, and do not process the body.
Don't log the body of an unverified request as if it were an event — it is a stranger's input.
Rotate the secret
/v1/webhook_endpoints/{id}/rotate_secrettmbilling returns the new secret once.
To rotate without dropping events in flight, accept either secret for a short window. Verify against the new secret first, then against the old one on failure, until the old deliveries drain.
Related
Webhooks
Register an endpoint and receive every billing state change as a signed, retried webhook, so your systems learn about renewals, failures and disputes at once.
Delivery and retries
Learn what counts as a successful delivery, how the six-attempt retry schedule runs, how to handle unordered and duplicate events, and how to recover them.