Verifying webhook signatures
Your webhook endpoint sits on the public internet, so anyone who discovers the URL can POST to it. Without a check of some kind, your server has no way to tell a real Mobile Message webhook from a payload someone made up. Signing solves that. When you set a signing secret, we add a signature to every webhook we send, and your server can recompute that signature to prove the request came from us and that the body wasn't altered on the way.
Signing is optional. If you don't set a secret, your webhooks keep working exactly as they do now, with no extra headers and nothing to change on your side.
Generating a signing secret
- Go to Settings > API
- Scroll down to Webhook URLs
- Find the Webhook Signing Secret section
- Click Generate Signing Secret
The secret is shown masked. Use the reveal button to see it and the copy button to copy it, then store it on your server the same way you'd store an API key, in an environment variable or your secret manager rather than in your source code.
Once a secret exists, signing applies to both webhook types, inbound messages and message status updates. The buttons change to Regenerate and Remove so you can rotate or turn off signing later.
The two headers
Every signed webhook POST carries two extra headers.
X-MM-Timestampis the unix timestamp in seconds at the moment the request was signed.X-MM-Signatureis an HMAC-SHA256, written as lowercase hex.
The value that gets signed is the timestamp, then a full stop, then the raw request body exactly as it arrived:
{timestamp}.{raw_request_body}
That string is hashed with HMAC-SHA256 using your signing secret as the key, and the hex result is what lands in X-MM-Signature.
Two things matter when you verify.
Use the raw body. Sign the bytes you received, not a re-encoded version of them. If your framework parses the JSON and you then serialise it again, key order and spacing can change and the signature won't match. Most frameworks give you a way to keep the raw body, and the examples below show it for each language.
Compare in constant time. Use hash_equals, crypto.timingSafeEqual or hmac.compare_digest rather than ==. A plain comparison returns faster on an early mismatch, which leaks information about the correct signature over many attempts.
Rejecting old requests
Check the timestamp as well as the signature. A signed request that someone captured is still a validly signed request, so without a time check it could be replayed at your endpoint later. Reject anything where X-MM-Timestamp is more than 5 minutes away from your own clock. That's a wide enough window to allow for normal delays and a little clock drift, and narrow enough that a captured request is useless within minutes.
PHP
<?php
$secret = getenv('MM_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MM_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_MM_SIGNATURE'] ?? '';
if ($timestamp === '' || $signature === '') {
http_response_code(400);
exit;
}
// Reject anything more than 5 minutes old
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$payload = json_decode($body, true);
// Your handling goes here
http_response_code(200);
Node.js
This example uses Express. express.raw() keeps the body as a Buffer, which is what you need for the signature. If you have express.json() applied globally, put the raw parser on the webhook route so it wins for that path.
const express = require('express');
const crypto = require('crypto');
const SECRET = process.env.MM_WEBHOOK_SECRET;
const app = express();
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
const body = req.body.toString('utf8');
const timestamp = req.get('X-MM-Timestamp') || '';
const signature = req.get('X-MM-Signature') || '';
if (!timestamp || !signature) {
return res.sendStatus(400);
}
// Reject anything more than 5 minutes old
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const payload = JSON.parse(body);
// Your handling goes here
res.sendStatus(200);
});
app.listen(3000);
Python
This example uses Flask. request.get_data() returns the raw bytes, so call it before anything parses the JSON.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["MM_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhook")
def webhook():
body = request.get_data()
timestamp = request.headers.get("X-MM-Timestamp", "")
signature = request.headers.get("X-MM-Signature", "")
if not timestamp or not signature:
return "", 400
# Reject anything more than 5 minutes old
if abs(time.time() - int(timestamp)) > 300:
return "", 400
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return "", 401
payload = json.loads(body)
# Your handling goes here
return "", 200
A test vector to check your code against
If your verification keeps failing and you're not sure whether the problem is your code or the request, run these fixed values through it. With the secret abc123, a timestamp of 1754640000 and a body of {"test":1}, the signing string is:
1754640000.{"test":1}
And the signature is:
52344b9592722e0241d82036e0920f4286bc0d47ba4624c5a1588193490a1efb
If your function produces that hex from those three inputs, your HMAC is right and the problem is elsewhere, usually the raw body. The most common cause is a framework that parses and re-serialises the JSON before you get to it, which adds or removes a space and changes the hash completely.
You can produce the same value on the command line to confirm:
php -r 'echo hash_hmac("sha256", "1754640000.{\"test\":1}", "abc123");'
Rotating or removing the secret
Regenerate replaces the secret with a new one and Remove turns signing off entirely. Both take effect on the next delivery attempt, so update the value stored on your server at the same time. If your server is still checking against the old secret, every webhook that arrives after the change will fail verification.
If your endpoint can hold two secrets at once, add the new one first and accept a match against either, then drop the old one after you've confirmed signed traffic is arriving. If it can only hold one, make the change in both places together and expect a small number of failures in between. Those get retried, so nothing is lost.
Signatures are computed fresh on each attempt, not stored with the queued webhook. A delivery that's retried after you regenerate will carry a signature made with the current secret, and its timestamp is the time of that attempt rather than the original one.