Browse the docs

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/json

The 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:

  1. Use the raw request body, before any JSON parsing. Re-serializing a parsed object changes the bytes and the digest will never match.
  2. Compare in constant time. A plain == on the digest leaks how many leading characters were correct.
  3. 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_data() 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", 200

Checking 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 header

If this matches but your handler does not, your handler is almost certainly hashing a re-serialized body rather than the bytes it received.

Troubleshooting

SymptomLikely cause
No Moon-Signature header at allThe endpoint has no signing secret yet. Rotate one to start signing.
Digest never matches, timestamp looks fineThe body was parsed and re-serialized before hashing, or a proxy reformatted it.
Works, then fails after a delayClock skew on your side, or verifying after a slow queue hop, against the 300-second window.
Worked, then stopped right after a rotateRotation takes effect immediately with no grace period. Deploy the new secret before rotating.
Signatures appeared without warningThe endpoint was re-registered. Registering replaces the webhook and issues a new secret, returned in that response.

Rolling out safely

  1. Deploy a handler that verifies but only logs failures, without rejecting.
  2. Register or rotate to obtain a secret, and store it where the handler can read it.
  3. Confirm in your logs that deliveries are verifying.
  4. 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