Verifying Webhook Signatures¶
To ensure that the webhook events you receive are truly from ScrapeNest and haven't been tampered with, you must verify the request signature. ScrapeNest uses a Svix-style signing scheme.
Headers¶
Every webhook request includes the following mandatory headers:
Svix-Id: A unique identifier for the message.Svix-Timestamp: The Unix timestamp (in seconds) when the message was sent.Svix-Signature: One or more space-separatedv1,<base64>HMAC-SHA256 signatures.
Use the raw secret as the HMAC key
The HMAC key is your signing secret exactly as issued, including the whsec_ prefix (for example whsec_a1b2c3...). Do not strip the prefix and do not base64-decode it. This differs from upstream Svix, and it is the most common cause of failed verification.
Verification Steps¶
- Validate the Timestamp: Prevent replay attacks by verifying that the
Svix-Timestampis within a 5-minute window of your current system time. - Construct the Signed Content: Concatenate the
Svix-Id, theSvix-Timestamp, and the raw unparsed request body, separated by dots: - Compute the HMAC-SHA256: Generate the HMAC-SHA256 of the signed content, keyed on your Webhook Secret (the full
whsec_...string, found in the Customer Console), then base64-encode the result. - Compare Signatures: Use a constant-time comparison to compare your computed
v1,<base64>value against each signature in theSvix-Signatureheader.
Python SDK does this for you
If you use the Python SDK, call scrapenest.webhooks.verify_webhook(payload, headers, secret) instead of implementing the scheme by hand.
Code Examples¶
Node.js (Express)¶
Use express.raw() to ensure you have the original, unparsed body for signature verification.
const crypto = require('crypto');
app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
const payload = req.body.toString();
const headers = req.headers;
const svix_id = headers["svix-id"];
const svix_timestamp = headers["svix-timestamp"];
const svix_signature = headers["svix-signature"];
// 1. Validate Timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(svix_timestamp)) > 300) {
return res.status(400).send("Expired timestamp");
}
// 2. Compute Signature (key = the full whsec_ secret, verbatim)
const signed_content = `${svix_id}.${svix_timestamp}.${payload}`;
const secret = "whsec_YOUR_SECRET";
const hmac = crypto.createHmac('sha256', secret);
const signature = hmac.update(signed_content).digest('base64');
// 3. Constant-time Comparison
const expected = `v1,${signature}`;
const passed = svix_signature.split(' ').some(sig => {
if (sig.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
if (!passed) return res.status(400).send("Invalid signature");
// Process valid event...
res.status(204).send();
});
Python (Flask)¶
import hmac
import hashlib
import base64
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = "whsec_YOUR_SECRET"
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
svix_id = request.headers.get("Svix-Id")
svix_timestamp = request.headers.get("Svix-Timestamp")
svix_signature = request.headers.get("Svix-Signature")
payload = request.get_data(as_text=True)
# 1. Validate Timestamp
now = int(time.time())
if abs(now - int(svix_timestamp)) > 300:
return "Expired timestamp", 400
# 2. Compute Signature (key = the full whsec_ secret, verbatim)
signed_content = f"{svix_id}.{svix_timestamp}.{payload}"
computed_signature = hmac.new(
SECRET.encode(),
signed_content.encode(),
hashlib.sha256
).digest()
expected_signature = f"v1,{base64.b64encode(computed_signature).decode()}"
# 3. Constant-time Comparison
signatures = svix_signature.split(' ')
if not any(hmac.compare_digest(expected_signature, sig) for sig in signatures):
return "Invalid signature", 400
return "", 204