Signature
The algorithm is HmacSHA256. The key is the full secret as UTF-8, including the whsec_ prefix. The secret is not hex-decoded.
The signed payload is timestamp + "." + raw body, also UTF-8. timestamp is the positive integer from the header, in Unix seconds. The header value is exactly:
t=<timestamp>,v1=<64 lowercase hex characters>
The accepted pattern is ^t=([0-9]+),v1=([0-9a-f]{64})$. An empty header, a header longer than 512 characters, timestamp 0, or hex outside that format is invalid. Comparison covers the entire header in constant time (MessageDigest.isEqual), not only the v1 portion.
X-Gateway-Timestamp repeats the same epoch. X-Gateway-Event-Id repeats id. X-Gateway-Signature repeats MuPag-Signature; the alias is temporary.
Tolerance
The verifier chooses toleranceSeconds. The window is symmetric: now ± tolerance. The sender does not store a production tolerance. Unit tests pass 300. Treat 300 seconds as a test example, not as a sender constant.
Example
The body below must be the raw bytes received. whsec_example is a placeholder, not a real secret.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = "whsec_example";
const toleranceSeconds = 300;
const header = request.headers["mupag-signature"] ?? "";
const match = /^t=([0-9]+),v1=([0-9a-f]{64})$/.exec(header);
if (!match || header.length > 512) {
throw new Error("invalid signature");
}
const timestamp = Number(match[1]);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestamp) || timestamp <= 0 || Math.abs(now - timestamp) > toleranceSeconds) {
throw new Error("stale signature");
}
const expected =
"t=" +
timestamp +
",v1=" +
createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const valid =
expected.length === header.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(header));
Log id and type. Do not log the secret or the full header.
Did this page unblock the next step?
Feedback stays inside the docs flow and should never include customer or card data.