Written for: developers who send or receive webhooks and want a simple, verifiable scheme.
Hey, fellow developer. Webhooks are easy to send and surprisingly easy to fake. Anyone who knows your receiver's URL can POST to it. So the sender needs to sign each message, and the receiver needs to check that signature before doing anything. This post shows how this boilerplate does it, with a few excerpts from the production module. A stripped-down, runnable version with tests is in the webhook-signing-sample repo. It uses X-Example-Signature instead of the production header name, so the code in the sample won't match the excerpts exactly.
The header
Every delivery carries one header:
X-CompanyName-Signature: t=<unix>,v1=<hex>
Two parts matter. t is the timestamp when the message was sent. v1 is an HMAC-SHA256 of the timestamp and the raw body, joined with a dot. Signing the timestamp is what lets receivers reject old messages.
Signing
def _digest(secret: str, raw_body: bytes, timestamp: int) -> str:
return hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
def sign(secret: str, raw_body: bytes, timestamp: int) -> str:
"""Signature header value ``t=<unix>,v1=<hex>`` for ``raw_body`` sent at ``timestamp``."""
return f"t={timestamp},v1={_digest(secret, raw_body, timestamp)}"
Two details are easy to get wrong. The signature covers the raw bytes you sent, not the parsed JSON, so re-serializing the body on either side will break the check. And the timestamp is part of the signed string, so an attacker can't reuse a valid signature with a new time.
Secrets look like whsec_ followed by 32 random hex characters. The module also computes a short, non-secret prefix for display, so the UI can identify a secret without revealing it.
Verifying
The receiver's side is verify:
if abs((int(time.time()) if now is None else now) - timestamp) > tolerance:
return False
expected = [_digest(secret, raw_body, timestamp) for secret in secrets]
return any(constant_time_equals(sig, exp) for sig in signatures for exp in expected)
Three things happen here. First, the timestamp must be within 300 seconds of the receiver's clock, which stops replays of old captures. Second, the function accepts a list of secrets, not just one. Third, the comparison uses constant_time_equals, so an attacker can't learn the signature one character at a time from response timing.
Rolling secrets without downtime
Secrets get rotated, and you don't want a deploy to drop messages. The header comment describes the approach: during a roll, deliveries are signed with the new secret, while verification accepts any secret still inside its overlap window. That's why verify takes a list. Receivers that already have the new secret keep working, and receivers that still have the old one keep working until the window closes.
Putting it together
Here is the shape of a delivery, using the same helper the sender uses:
headers = delivery_headers(
secret,
event_id=event_id,
raw_body=raw_body,
timestamp=int(time.time()),
)
It returns the content type, a user agent, the event ID, and the signature header. Setting test=True adds a header that marks the call as a test, so receivers can tell test traffic from live traffic.
A limit to know about
verify does not remember which signatures it has already accepted. Within the 300-second window, a captured message can be replayed and will still pass. For that reason, receivers should deduplicate on the X-CompanyName-Event-Id header and treat repeated events as no-ops. The signature stops forgery; deduplication stops replay.
If you want the full webhook pipeline, delivery retries, and the rest of the backend, the boilerplate includes it.
Get the full boilerplate: Byteforge
Top comments (0)