Verifying Webhook Signatures
Copy-paste examples in Node.js, Python and shell for validating the Moon-Signature header on webhook deliveries.
Moon signs webhook deliveries with an HMAC so your endpoint can prove a request came from us. This guide is the copy-paste implementation; for what the header means, how to obtain your secret and the delivery contract (timeout, retries, ordering), see the Webhooks reference.
What you are verifying
A signed delivery carries:
Moon-Signature: t=1786838985,v1=8f2a1c...64 hex characters...9e
Content-Type: application/jsonThe digest is HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded. The timestamp is inside the signed string, so it cannot be rewritten without invalidating the signature.
Three rules the examples below exist to enforce:
- Use the raw request body, before any JSON parsing. Re-serializing a parsed object changes the bytes and the digest will never match.
- Compare in constant time. A plain
==on the digest leaks how many leading characters were correct. - Check the timestamp against a tolerance window — 300 seconds is our recommendation — so a captured delivery cannot be replayed indefinitely.
Node.js (Express)
Note express.raw: express.json() would hand you a parsed object and you would lose the exact bytes.
Node.js
const { createHmac, timingSafeEqual } = require('crypto');
const express = require('express');
const TOLERANCE_SECONDS = 300;
function verifyMoonSignature(secret, rawBody, header) {
if (!header) return false;
let t;
let v1;
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = value;
if (key === 'v1') v1 = value;
}
if (!t || !v1) return false;
const timestamp = Number(t);
if (!Number.isInteger(timestamp)) return false;
// Reject stale replays AND post-dated timestamps: a receiver whose clock is
// behind ours would otherwise accept a future-dated delivery forever.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
const app = express();
app.post('/moon/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyMoonSignature(process.env.MOON_WEBHOOK_SECRET, rawBody, req.header('Moon-Signature'))) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody);
// Deliveries are retried; deduplicate on webhook_event_id before acting.
handleEvent(event);
// Answer 2xx once the delivery is recorded; see the delivery contract
// on the Webhooks page for timeout and retry behaviour.
res.status(200).send('ok');
});Python (Flask)
request.get_ is the raw body; request.json would already be parsed.
Python
import hashlib
import hmac
import os
import time
from flask import Flask, request
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify_moon_signature(secret: str, raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = {}
for chunk in header.split(","):
key, _, value = chunk.partition("=")
if value:
parts[key.strip()] = value.strip()
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
try:
timestamp = int(t)
except ValueError:
return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/moon/webhooks")
def moon_webhooks():
if not verify_moon_signature(
os.environ["MOON_WEBHOOK_SECRET"],
request.get_data(),
request.headers.get("Moon-Signature"),
):
return "invalid signature", 400
event = request.get_json()
handle_event(event)
return "ok", 200Checking one delivery by hand
Useful when a delivery is being rejected and you want to know whether the problem is your code or the payload. Save the exact body you received to a file — do not reformat it — and recompute:
Shell
SECRET='whsec_...'
TS='1786838985' # the t= value from the header
BODY="$(cat delivery.json)" # exact bytes as received
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
# compare the output with the v1= value from the headerIf this matches but your handler does not, your handler is almost certainly hashing a re-serialized body rather than the bytes it received.
Troubleshooting
| Symptom | Likely cause |
|---|---|
No Moon-Signature header at all | The endpoint has no signing secret yet. Rotate one to start signing. |
| Digest never matches, timestamp looks fine | The body was parsed and re-serialized before hashing, or a proxy reformatted it. |
| Works, then fails after a delay | Clock skew on your side, or verifying after a slow queue hop, against the 300-second window. |
| Worked, then stopped right after a rotate | Rotation takes effect immediately with no grace period. Deploy the new secret before rotating. |
| Signatures appeared without warning | The endpoint was re-registered. Registering replaces the webhook and issues a new secret, returned in that response. |
Rolling out safely
- Deploy a handler that verifies but only logs failures, without rejecting.
- Register or rotate to obtain a secret, and store it where the handler can read it.
- Confirm in your logs that deliveries are verifying.
- Switch to rejecting on failure.
That order means a mistake in step 1 or 2 never drops a real event.
Last updated August 17, 2026