Verifying webhooks
Check the signature before you act on an event.
Every webhook carries a signature header. Your signing secret is provided during onboarding and is separate from your API key.
X-Maly-Signature: t=1751020200,v1=<signature>What the parts mean#
| Part | Meaning |
|---|---|
t | Unix timestamp of when the event was signed |
v1 | The signature itself, for scheme version 1 |
How the signature is computed#
The signature is an HMAC-SHA256, keyed with your webhook signing secret, over the timestamp and the raw body joined by a dot. In other words the signed string is:
signed_payload = t + "." + raw_bodytis the timestamp from theX-Maly-Signatureheader (the value aftert=).raw_bodyis the exact bytes of the request body, before any JSON parsing or re-serialisation.- The two are joined by a single literal dot.
v1 in the header is that HMAC-SHA256, hex-encoded in lowercase. To verify, recompute it with your secret and compare it to the v1 value using a constant-time comparison.
Verification procedure#
- Capture the raw request body. Do not parse and re-serialise it first; any reordering or whitespace change breaks the signature.
- Parse
tandv1from theX-Maly-Signatureheader. - Reject the event if
tis more than 5 minutes old, to protect against replay. - Compute
HMAC-SHA256(secret, t + "." + raw_body)and hex-encode it. - Compare your value to
v1with a constant-time comparison. Act on the payload only if they match.
Example#
const crypto = require("crypto");
// secret: your webhook signing secret (whsec_...)
// header: the raw value of the X-Maly-Signature header
// rawBody: the exact request body bytes, before any JSON parsing
function verifyWebhook(secret, header, rawBody) {
const parts = Object.fromEntries(
header.split(",").map(kv => kv.split("=").map(s => s.trim()))
);
const t = parts.t;
const received = parts.v1;
if (!t || !received) return false;
// reject anything older than 5 minutes
const age = Math.floor(Date.now() / 1000) - Number(t);
if (!Number.isFinite(age) || age > 300) return false;
const signedPayload = `${t}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
// constant-time compare
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_webhook(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(
kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv
)
t = parts.get("t")
received = parts.get("v1")
if not t or not received:
return False
# reject anything older than 5 minutes
if abs(time.time() - int(t)) > 300:
return False
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# constant-time compare
return hmac.compare_digest(expected, received)<?php
// $secret: your webhook signing secret (whsec_...)
// $header: raw value of the X-Maly-Signature header
// $rawBody: exact request body string, before json_decode
function verify_webhook(string $secret, string $header, string $rawBody): bool {
$parts = [];
foreach (explode(",", $header) as $kv) {
[$k, $v] = array_map("trim", explode("=", $kv, 2));
$parts[$k] = $v;
}
$t = $parts["t"] ?? null;
$received = $parts["v1"] ?? null;
if (!$t || !$received) return false;
// reject anything older than 5 minutes
if (abs(time() - (int)$t) > 300) return false;
$signedPayload = $t . "." . $rawBody;
$expected = hash_hmac("sha256", $signedPayload, $secret);
// constant-time compare
return hash_equals($expected, $received);
}Use the raw body. Most frameworks hand you a parsed object. You need the untouched bytes as received. In Express use express.raw() for the webhook route; in Flask use request.get_data(); in PHP read php://input. Verifying against a re-serialised body will fail even when the webhook is genuine.
Maly Tech Ltd. This guide is provided for information. Where it differs from your signed agreement, the agreement applies.