Signature verification
The scheme
Every webhook request Zentake sends is signed:
- Zentake computes
HMAC-SHA256of the raw JSON request body (the exact bytes sent, before any parsing), using your webhook configuration'ssignature_secretas the key. - The result is hex-encoded.
- It's sent in the
X-Zentake-Signaturerequest header.
There is no timestamp component and no signing of headers, only the raw body is signed.
Get signature_secret from webhook creation
signature_secret is read-only and only returned in the response
body of Create a new webhook configuration
(POST /v2/webhooks). There's no endpoint to fetch it again later,
store it securely as soon as you create the webhook.
Verify in Node.js
const crypto = require('crypto');
function isValidSignature(rawBody, receivedSignature, secret) {
if (!receivedSignature) return false;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
const expected = Buffer.from(expectedSignature, 'utf8');
const received = Buffer.from(receivedSignature, 'utf8');
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
// Example with an Express route, use a raw body parser so `req.body`
// is the exact bytes Zentake signed, not a re-serialized object.
app.post(
'/webhooks/zentake',
express.raw({type: 'application/json'}),
(req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.header('X-Zentake-Signature');
if (!isValidSignature(rawBody, signature, process.env.ZENTAKE_WEBHOOK_SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// ... handle event ...
res.sendStatus(200);
},
);
Verify in Python
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here" # from POST /v2/webhooks response
def is_valid_signature(payload: str, received_signature: str, secret: str) -> bool:
if not received_signature:
return False
expected_signature = hmac.new(
key=secret.encode("utf-8"),
msg=payload.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, received_signature)
@app.route("/webhook", methods=["POST"])
def webhook_receiver():
payload = request.data.decode("utf-8")
received_signature = request.headers.get("X-Zentake-Signature")
if not is_valid_signature(payload, received_signature, WEBHOOK_SECRET):
abort(400, "Invalid signature")
# Process payload safely
print("Webhook received and verified:", payload)
return "", 200
Key points
- Use the raw body, not a re-serialized object. If your framework parses the JSON before you can access the original bytes, key ordering or whitespace differences will make the signature comparison fail even though the payload is legitimate, read the raw body explicitly (as both examples above do).
- Use a constant-time comparison (
crypto.timingSafeEqualin Node,hmac.compare_digestin Python) rather than===/==, to avoid timing attacks. - Reject requests with a missing or invalid signature before parsing or acting on the payload.
- Always use HTTPS for your
target_urlso the payload and signature aren't exposed in transit.
Next
- Webhook payloads. What to expect in the verified body.
- Receive completion webhooks