Signature verification
Because your webhook URL is publicly reachable on the internet, you should verify the cryptographic signature on every incoming request to ensure it genuinely came from Mekante.
How requests are signed
Every webhook request includes an X-Mekante-Signature header:
X-Mekante-Signature: t=1792749428,v1=794b3947df267c3354af75b5d8931538c765d768e5ca83983dcd52f7c6f93270
t: Unix timestamp in seconds when the request was signed.v1: lowercase hexadecimal HMAC-SHA256 digest of:
HMAC_SHA256(signing_secret, "<t>.<raw_body>")
signing_secret is the whsec_… secret shown when you created the webhook endpoint. raw_body is the exact raw byte stream sent by the server: do not parse and re-serialize the JSON before verifying, or whitespace and key ordering differences will invalidate the signature.
Verification steps
- Split the
X-Mekante-Signatureheader to extracttandv1. - Check that
tis within 5 minutes (300 seconds) of the current clock to prevent replay attacks. - Compute
HMAC_SHA256(signing_secret, "<t>.<raw_body>"). - Compare your computed hex digest with
v1using a constant-time comparison function (never plain===or==). - If valid, process the event and return
200; otherwise return400.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MEKANTE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Raw body is required: do NOT use express.json() on this route.
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Mekante-Signature") ?? "")) return res.sendStatus(400);
const event = JSON.parse(raw);
// Deduplicate by event.id, then handle event.event.
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["MEKANTE_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE = 300
app = Flask(__name__)
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > TOLERANCE:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks")
def hooks():
raw = request.get_data() # raw bytes
if not verify(raw, request.headers.get("X-Mekante-Signature", "")):
abort(400)
event = request.get_json()
# Deduplicate by event["id"] before processing.
return "", 200
PHP
<?php
$secret = getenv('MEKANTE_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // raw request body
$header = $_SERVER['HTTP_X_MEKANTE_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
$fresh = ctype_digit($t) && abs(time() - (int) $t) <= 300;
if (!$fresh || !hash_equals($expected, $v1)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Deduplicate by $event['id'] before processing.
http_response_code(200);
Troubleshooting
- If the signature never matches, check that your framework isn't parsing and re-serializing the JSON body or mutating line endings before verification.
- Make sure there is no leading or trailing whitespace in your
whsec_…environment variable. - Use Send test (Test gönder) in the dashboard to trigger a
pingevent and verify your handler end-to-end.