Verify Webhook Signatures

Check HMAC-SHA256 signatures on event and decision requests.

Penny uses the same outbound signing scheme for asynchronous event deliveries and synchronous Live Decisioning requests. Configure a signing secret on each endpoint whose requests you want to verify. Signing is separate from the optional bearer token.

Configure signing

Webhook creation leaves signing unconfigured. After setup finishes, call POST /notifications/webhooks/{notification_endpoint_id}/signing_secret to create a secret (201). Creation fails with 422 if a secret already exists. Use POST /notifications/webhooks/{notification_endpoint_id}/signing_secret/rotate to replace an existing secret (200); rotation fails with 422 if no secret exists. When a signing_secret is present, each event or decision request carries two additional headers:

HeaderValue
Penny-Webhook-TimestampThe time Penny sent the request, as a Unix timestamp in seconds (a string such as 1756130591)
Penny-Webhook-Signaturesha256=<hex digest> — an HMAC-SHA256 over the timestamp and the raw request body, keyed with the endpoint’s signing_secret

The signing secret itself is never sent in a request; only the timestamp and signature are. The signed payload is the timestamp, a literal period, and the raw request body, all as UTF-8 bytes exactly as received:

signed_payload = <Penny-Webhook-Timestamp> + "." + <raw request body>

To verify a request:

  1. Read the Penny-Webhook-Timestamp and Penny-Webhook-Signature headers.
  2. Reject the request if the timestamp is more than a few minutes (5 is a reasonable window) away from your current time. Because the timestamp is part of the signed payload, an attacker can’t change it without invalidating the signature — this is what stops a captured delivery from being replayed later.
  3. Recompute HMAC-SHA256 over signed_payload using your signing_secret, hex-encode the digest, and prefix it with sha256=.
  4. Compare the result to the Penny-Webhook-Signature header using a constant-time comparison. Only process the request if they match.

Always hash the raw body bytes as they arrived — parse the JSON only after the signature checks out. Re-serializing the parsed object can change whitespace or key order and produce a different digest.

import hmac
import hashlib
import time
TOLERANCE_SECONDS = 300
def verify_penny_signature(headers: dict, raw_body: bytes, signing_secret: str) -> bool:
timestamp = headers.get("Penny-Webhook-Timestamp")
signature = headers.get("Penny-Webhook-Signature")
if not timestamp or not signature:
return False
# Replay protection: reject deliveries outside the tolerance window.
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
return False
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
digest = hmac.new(
signing_secret.encode("utf-8"),
signed_payload,
hashlib.sha256,
).hexdigest()
expected = f"sha256={digest}"
return hmac.compare_digest(expected, signature)

Signing-secret creation and rotation return only:

{ "signing_secret": "penny_whsec_...", "algorithm": "HMAC-SHA256" }

Store this value securely when it is returned. Ordinary webhook responses expose has_signing_secret and never include the secret, even with reveal_sensitive=true or when retrieving history. Use the entire penny_whsec_... string as the HMAC key. Rotation takes effect immediately for new requests; there is no overlap period. Requests already in flight may still carry the previous signature. If you lose the response, rotate again to obtain a new secret.

The endpoint must already be provisioned before you create its signing secret. Rotate the secret if it is lost or compromised, then update your receiver to use the new value. If you operate multiple endpoints, store the appropriate secret for each endpoint; do not choose one based only on unverified body data.