Open app
Moonborn — Developers

Webhook signature verification

Verify the `X-Moonborn-Signature` HMAC, reject replays, and survive a secret rotation.

Every webhook delivery carries an X-Moonborn-Signature header with the format:

X-Moonborn-Signature: t=1747497600,v1=2c4f8a...

t is the Unix timestamp the signature was minted; v1 is the HMAC-SHA256 of {t}.{rawBody}, hex-encoded, keyed by the signing secret.

Verify (Node.js)

import { createHmac, timingSafeEqual } from 'node:crypto';
 
const MAX_AGE_SECONDS = 300;
 
export function verify(
  rawBody: string,
  header: string,
  secret: string,
): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=', 2) as [string, string]),
  );
  const t = Number(parts['t']);
  const v1 = parts['v1'];
  if (!Number.isFinite(t) || typeof v1 !== 'string') return false;
  // Replay window guard.
  if (Math.abs(Date.now() / 1000 - t) > MAX_AGE_SECONDS) return false;
  const signed = `${t}.${rawBody}`;
  const expected = createHmac('sha256', secret).update(signed).digest('hex');
  return timingSafeEqual(
    Buffer.from(v1, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

Note: use the raw body bytes, not a re-serialized JSON object — the signature is over the exact bytes Moonborn sent.

Verify (Python)

import hmac, hashlib, time
 
MAX_AGE = 300
 
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    v1 = parts["v1"]
    if abs(time.time() - t) > MAX_AGE:
        return False
    signed = f"{t}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

Secret rotation grace

During rotation, deliveries may carry two v1= entries (old + new). Treat any matching candidate as valid:

const candidates = header.split(',').filter((p) => p.startsWith('v1='));
const ok = candidates.some((c) => {
  const v = c.slice(3);
  return timingSafeEqual(
    Buffer.from(v, 'hex'),
    Buffer.from(expected, 'hex'),
  );
});

Grace period default: api.webhooks.secret_rotation_grace_minutes = 60.

Why the timestamp guard

Without t-age rejection, an attacker who captures one valid delivery can replay it forever. Five minutes is a generous bound; tighten to 60s if your receiver is regional.

Tier

Webhooks are Team+; verification logic is the same on every tier.

Related