Skip to content

Webhook signature verification

Each message carries two headers:

Header Value
X-API-Timestamp When the message was sent, in Unix epoch seconds (e.g. 1793212330).
X-API-Signature Base64( HMAC-SHA256( secret, "POST" + PATH + TIMESTAMP ) )
  1. Store the secret. Save the signing secret from the web app in your secrets manager or as an environment variable (e.g. VAST_WEBHOOK_SECRET).
  2. Read both headers from the incoming request. If either is missing, reject the request.
  3. Check the timestamp. Reject it if it is in the future or more than 300 seconds old. This stops old messages from being replayed. Keep your server clock synchronized (NTP).
  4. Build the signed string by joining, with no separators: POST, the path of the URL you entered in the recipe’s Webhook URL field (no host or query string, e.g. /webhooks/vast/out-of-inventory), and the exact X-API-Timestamp value.
  5. Compute HMAC-SHA256 of that string using your secret (as UTF-8 bytes), then Base64-encode the result.
  6. Compare your result with X-API-Signature using a constant-time comparison (hmac.compare_digest in Python).
  7. Respond. If verification fails, return 401 and do nothing else. If it passes, process the message and return 200.
verify.py
import base64, hashlib, hmac, os, time
SECRET = os.environ["VAST_WEBHOOK_SECRET"] # shown once in the web app; never hard-code it
MAX_AGE = 300 # seconds
def is_valid_vast_request(path: str, timestamp: str, signature: str) -> bool:
# Step 2: both headers must be present, and the timestamp must be digits
if not timestamp or not signature or not timestamp.isdigit():
return False
# Step 3: reject future timestamps and anything older than 5 minutes
now = int(time.time())
if int(timestamp) > now or now - int(timestamp) > MAX_AGE:
return False
# Step 4: build the signed string: METHOD + PATH + TIMESTAMP
message = f"POST{path}{timestamp}".encode("utf-8")
# Step 5: HMAC-SHA256 with your secret, then Base64
expected = base64.b64encode(
hmac.new(SECRET.encode("utf-8"), message, hashlib.sha256).digest()
).decode("ascii")
# Step 6: constant-time comparison
return hmac.compare_digest(expected, signature)

Use this known-good example in a unit test. With these inputs, your code must produce exactly this signature:

Input Value
Secret whsec_example_secret
Path /webhooks/vast/out-of-inventory
X-API-Timestamp 1793212330
Signed string POST/webhooks/vast/out-of-inventory1793212330
Expected X-API-Signature Sl6eaI9sIn1+be0QGbyowjzy7aB646E+aKP0hnhtp4U=
test_signature.py
import base64, hashlib, hmac
secret = "whsec_example_secret"
message = "POST" + "/webhooks/vast/out-of-inventory" + "1793212330"
sig = base64.b64encode(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()).decode()
assert sig == "Sl6eaI9sIn1+be0QGbyowjzy7aB646E+aKP0hnhtp4U="
Mistake Fix
Signing the full URL (https://your-app...) Sign the path only: /webhooks/vast/out-of-inventory.
Signing the path your framework sees behind a proxy Sign the path you entered in the recipe’s Webhook URL field. Store it as a constant.
Re-formatting the timestamp Use the header value exactly as received.
Comparing with == Use a constant-time comparison.
Changing inventory on test sends Check for "is_test": true and acknowledge without changing anything.
Returning 201, 202, or 204 Return exactly 200. Any other status counts as a failed delivery.
Doing slow work before responding Return 200 right away and process the message in the background.
Serving the endpoint over plain HTTP Use HTTPS only. The signature covers the method, path, and timestamp, not the body.