Webhooks
Verify signatures
Each webhook delivery carries an MMoney-Signature header:
MMoney-Signature: t=1747000000,v1=<hex-hmac-sha256>
The v1 value is an HMAC-SHA256 over the string "<t>.<raw_body>"
with your webhook signing secret. Verify it on every delivery:
- Compute the HMAC over the raw request body. Do not re-encode the JSON first.
- Compare with a constant-time function.
-
Reject deliveries where
tis more than 5 minutes old. This stops replays.
Node.js
import crypto from "crypto";
function verifySignature(rawBody, headerValue, secret) {
const parts = Object.fromEntries(
headerValue.split(",").map(s => s.split("="))
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}
Python
import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]):
return False
return abs(time.time() - int(parts["t"])) <= 300
PHP
function verify_signature($raw_body, $header, $secret) {
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw_body, $secret);
return hash_equals($expected, $parts['v1']);
}
Elixir
def verify(raw_body, header, secret) do
parts =
header
|> String.split(",")
|> Map.new(fn p -> List.to_tuple(String.split(p, "=")) end)
expected =
:crypto.mac(:hmac, :sha256, secret, "#{parts["t"]}.#{raw_body}")
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, parts["v1"])
end
On a mismatch
Return 401. mMoney retries up to 5 times with exponential backoff.
Secret rotation
Rotation takes effect immediately. There is no overlap window. Deploy the new secret to all backend instances before you click rotate in the dashboard.