Sign a message with a shared secret and read the HMAC in hex or base64, with the Web Crypto code to verify it on the other side.
// Node, and anywhere else with Web Crypto
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
Almost always because the message is not byte for byte what was signed. Sign the raw request body, before any JSON parsing and re-serialising, and check whether the sender prefixes the digest with something such as sha256=.
Whichever the other side expects, they carry the same bytes. Stripe and GitHub use hex; several others use base64.
The signing happens in this tab and nothing is sent anywhere, but a production secret should not be pasted into any web page as a habit. Use a throwaway value to work out the shape, then run the code on the last line.
With a constant-time comparison: crypto.timingSafeEqual in Node, hmac.compare_digest in Python. A plain === returns as soon as two bytes differ, and that difference in timing is enough to recover a signature byte by byte.