API reference

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.

Was this guide useful? Report an issue