Verifying webhook signatures
Verify that an event came from MessageVia, with examples in PHP, Node and Python.
Signature verification
Your endpoint is a public URL. Anyone who learns it can POST to it. The signature is what separates a real event from someone else's JSON.
Verify before you parse. Not after, not sometimes.
The recipe
signed_string = MessageVia-Timestamp + "." + raw_request_body
expected = hex(hmac_sha256(signing_secret, signed_string))
Compare expected against the hex after v1= in MessageVia-Signature, using a
constant-time comparison.
Two rules that decide whether this works
Use the raw body. Not a re-serialised object. json.dumps() of a parsed
payload will differ from what we signed — key order, whitespace, unicode
escaping — and every signature will fail. Read the raw bytes before your
framework touches them.
Compare in constant time. == on strings leaks how many leading characters
matched, which is enough to forge a signature one byte at a time. Every language
ships a constant-time comparison; use it.
Reject stale deliveries
Also check that MessageVia-Timestamp is recent — five minutes is a sensible
window. Without it, a valid signed request captured once can be replayed forever.
Examples
PHP
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_MESSAGEVIA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_MESSAGEVIA_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (! hash_equals('v1=' . $expected, $signature)) {
http_response_code(400);
exit;
}
if (abs(time() - strtotime($timestamp)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
Node.js
import crypto from 'node:crypto';
// express.raw({ type: 'application/json' }) — req.body must be a Buffer
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('MessageVia-Timestamp') ?? '';
const signature = req.get('MessageVia-Signature') ?? '';
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.MESSAGEVIA_WEBHOOK_SECRET)
.update(timestamp + '.' + req.body)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
if (Math.abs(Date.now() - Date.parse(timestamp)) > 300_000) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
});
Python
import hmac, hashlib, time
from datetime import datetime, timezone
def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
expected = 'v1=' + hmac.new(
secret.encode(),
timestamp.encode() + b'.' + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
sent = datetime.fromisoformat(timestamp).timestamp()
return abs(time.time() - sent) <= 300
Rotating the secret
Dashboard → the endpoint → Edit → Rotate secret. The new secret is shown once and the old one stops working immediately — there is no overlap window.
Deliveries signed with the old secret will fail your verification until the new one is deployed. Plan the rotation for a moment you can deploy quickly, and use Redeliver afterwards to replay anything that failed in between.
The stored secret is encrypted and never shown again, so a lost secret is always a rotation, never a lookup.