Skip to main content

Signature verification

The scheme

Every webhook request Zentake sends is signed:

  1. Zentake computes HMAC-SHA256 of the raw JSON request body (the exact bytes sent, before any parsing), using your webhook configuration's signature_secret as the key.
  2. The result is hex-encoded.
  3. It's sent in the X-Zentake-Signature request 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.timingSafeEqual in Node, hmac.compare_digest in 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_url so the payload and signature aren't exposed in transit.

Next