Skip to main content
Every webhook delivery carries a signature header. It’s HMAC-SHA256 of <unix-seconds>.<raw-body> keyed by your signing secret, hex-encoded.
X-Inboxbase-Signature: t=1777278929,v1=4f3a91...c7b2
To verify:
  1. Parse the header: t=<unix-seconds> and v1=<hex>.
  2. Reject if |now - t| > 300 (the timestamp window — protects against replay).
  3. Compute hmac_sha256(secret, "<t>.<rawBody>") and hex-encode.
  4. Compare with the v1 value using a constant-time compare.

Node

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

function verifyInboxbase(sigHeader, body, secret) {
  const parts = Object.fromEntries(
    sigHeader.split(",").map((p) => p.split("=")),
  );
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${body}`)
    .digest("hex");
  return timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex"),
  );
}

const app = express();

app.post(
  "/12m",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyInboxbase(
      req.header("X-Inboxbase-Signature"),
      req.body,
      process.env.WHSEC,
    );
    if (!ok) return res.status(401).end();
    const event = JSON.parse(req.body.toString());
    // handle event.type ...
    res.status(200).end();
  },
);

Python

import hmac, hashlib, time
from flask import Flask, request

app = Flask(__name__)

def verify_12m(sig_header: str, raw_body: bytes, secret: str) -> bool:
    parts = dict(p.split("=") for p in sig_header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    msg = f"{parts['t']}.{raw_body.decode('utf-8')}"
    expected = hmac.new(
        secret.encode(), msg.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

@app.post("/12m")
def hook():
    if not verify_12m(
        request.headers["X-Inboxbase-Signature"],
        request.get_data(),
        os.environ["WHSEC"],
    ):
        return "", 401
    event = request.get_json(force=True)
    # handle event["type"] ...
    return "", 200

Three details that bite

  • Read the raw body. If your framework parses JSON before your handler sees it, the bytes are already different (whitespace, reordered keys) and the signature won’t verify. Always grab the raw request body for verification, then parse JSON yourself.
  • Reject old timestamps. Without the 5-minute window check, a leaked old payload is a forever-valid request. The window is the point of the timestamp.
  • Constant-time compare. timingSafeEqual (Node) or hmac.compare_digest (Python). Plain === leaks information about the secret over enough requests.

What’s signed

The signature covers exactly <t>.<rawBody>. Headers are not part of the signed payload. If you need to assert that an event arrived for a specific identity, read data.identity from the body — don’t trust the X-Inboxbase- headers without verification (though they happen to match the body in practice).