> ## Documentation Index
> Fetch the complete documentation index at: https://docs.12m.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify the signature

> HMAC-SHA256 over `<t>.<body>`. Reject anything older than ~5 minutes.

Every webhook delivery carries a signature header. It's HMAC-SHA256 of
`<unix-seconds>.<raw-body>` keyed by your signing secret, hex-encoded.

```http theme={null}
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

```js theme={null}
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

```python theme={null}
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).
