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 ) ) |
How to implement it
Section titled “How to implement it”- 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). - Read both headers from the incoming request. If either is missing, reject the request.
- 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).
- 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 exactX-API-Timestampvalue. - Compute HMAC-SHA256 of that string using your secret (as UTF-8 bytes), then Base64-encode the result.
- Compare your result with
X-API-Signatureusing a constant-time comparison (hmac.compare_digestin Python). - Respond. If verification fails, return
401and do nothing else. If it passes, process the message and return200.
import base64, hashlib, hmac, os, time
SECRET = os.environ["VAST_WEBHOOK_SECRET"] # shown once in the web app; never hard-code itMAX_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)Check your implementation
Section titled “Check your implementation”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= |
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="Common mistakes
Section titled “Common mistakes”| 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. |