Verify a webhook signature

Confirm an inbound webhook actually came from Brainerce (and isn't a replay), with Node, Python and Go examples.

Brainerce signs every outbound webhook with HMAC-SHA256. Your endpoint MUST verify the signature before doing any work, or anyone who finds your URL can post forged events.

Three rules, none of them optional:

  1. Verify the signature with the subscription's secret (from when you registered the URL).
  2. Reject if the X-Brainerce-Timestamp is older than ~5 minutes. That defeats replay even if a signed payload leaks.
  3. Use constant-time comparison (timingSafeEqual, hmac.compare_digest, …). A regular === leaks the secret one byte at a time under timing attack.

What Brainerce sends

Every delivery carries these headers:

HeaderValue
X-Brainerce-Signaturehex-encoded HMAC-SHA256 of ${timestamp}.${rawBody}
X-Brainerce-TimestampUnix epoch in milliseconds
X-Brainerce-EventEvent type (e.g. order.created)
X-Brainerce-Event-IdStable opaque id: evt_ + 32 hex chars (not a UUID). Use it for idempotency on your side
Content-Typeapplication/json

The body is a JSON envelope:

{
  "id": "evt_a1b2c3d4e5...",
  "type": "order.created",
  "createdAt": "2026-05-19T11:23:45.000Z",
  "data": {
    "orderId": "ord_xyz",
    "order": {
      /* enriched resource */
    }
  }
}

id (the envelope id) is the same value as X-Brainerce-Event-Id: the literal prefix evt_ followed by 32 hex characters. Store it on your side and reject duplicates, because Brainerce retries failed deliveries with the same envelope. Retries all land within about 75 seconds, so a 24-hour dedup key is more than enough.

Node.js (TypeScript)

Using the brainerce SDK? verifyWebhook({ rawBody, signature, timestamp, secret }) and parseWebhookEvent(rawBody) implement exactly what follows (SDK webhooks). The dependency-free version:

import crypto from 'crypto';
import express from 'express';

const SECRET = process.env.BRAINERCE_WEBHOOK_SECRET!;
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

const app = express();

// IMPORTANT: capture the RAW body (not parsed JSON) for HMAC.
// `express.json()` mutates whitespace — the signature won't match.
app.post('/webhooks/brainerce', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-Brainerce-Signature');
  const timestamp = req.header('X-Brainerce-Timestamp');
  if (!signature || !timestamp) {
    return res.status(400).send('Missing signature headers');
  }

  // 1. Replay window — reject anything older than 5 min.
  const ageMs = Date.now() - parseInt(timestamp, 10);
  if (Number.isNaN(ageMs) || ageMs > MAX_AGE_MS || ageMs < -60_000) {
    return res.status(400).send('Timestamp out of window');
  }

  // 2. Recompute signature over `${timestamp}.${rawBody}`.
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');

  // 3. Constant-time compare.
  const sigBuf = Buffer.from(signature, 'hex');
  const expectedBuf = Buffer.from(expected, 'hex');
  if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
    return res.status(400).send('Invalid signature');
  }

  // 4. Idempotency — dedupe on envelope id.
  const event = JSON.parse(req.body.toString('utf8'));
  if (await alreadyHandled(event.id)) {
    return res.status(200).send('OK (duplicate)');
  }
  await markHandled(event.id);

  // 5. Do your work.
  await handleEvent(event);
  res.status(200).send('OK');
});

Python (FastAPI / Flask)

import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException

SECRET = os.environ["BRAINERCE_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000

app = FastAPI()

@app.post("/webhooks/brainerce")
async def webhook(request: Request):
    raw_body = await request.body()  # bytes; NOT request.json()
    signature = request.headers.get("X-Brainerce-Signature")
    timestamp = request.headers.get("X-Brainerce-Timestamp")
    if not signature or not timestamp:
        raise HTTPException(400, "Missing signature headers")

    age_ms = int(time.time() * 1000) - int(timestamp)
    if age_ms > MAX_AGE_MS or age_ms < -60_000:
        raise HTTPException(400, "Timestamp out of window")

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()

    # Constant-time compare.
    if not hmac.compare_digest(expected, signature):
        raise HTTPException(400, "Invalid signature")

    # ... idempotency + handler
    return {"ok": True}

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strconv"
    "time"
)

const maxAgeMs = 5 * 60 * 1000

func verify(w http.ResponseWriter, r *http.Request) {
    sig := r.Header.Get("X-Brainerce-Signature")
    ts := r.Header.Get("X-Brainerce-Timestamp")
    body, _ := io.ReadAll(r.Body)

    tsMs, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        http.Error(w, "bad ts", 400)
        return
    }
    if time.Now().UnixMilli()-tsMs > maxAgeMs {
        http.Error(w, "stale", 400)
        return
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(ts + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(sig), []byte(expected)) {
        http.Error(w, "bad sig", 400)
        return
    }
    // ... process
}

Common gotchas

  • Parsing the body before computing the signature. JSON parsers re-serialize whitespace and key order. Always HMAC the raw bytes you received.
  • Comparing strings with === / ==. Leaks the secret under timing attack. Use the platform's constant-time helper.
  • Forgetting the timestamp. Without a replay window, a signed payload captured once is valid forever.
  • Wrong header name casing. Header names are case-insensitive per HTTP spec, but your framework may normalize differently. Use the framework's getHeader('x-brainerce-signature') accessor rather than indexing a raw map.
  • Treating 200 as the only success. Brainerce considers any 2xx response a successful delivery. 204 No Content is fine.
  • Answering a non-2xx you think is harmless. A 400 or 404 from your handler is retried the full five attempts and counts toward the circuit breaker exactly like a timeout. Only 2xx stops the retries.
  • Returning errors slowly. The dispatcher has a 10-second timeout per delivery. The breaker counts failed attempts and opens at ten, so with five attempts per event that is roughly two consecutive failing events, not ten. Reply within 1 to 2 seconds and queue the actual work async on your side.
  • Assuming an open circuit heals quickly. It half-opens an hour later and probes with the next event or two: a 2xx resumes delivery, anything else re-opens it for another hour, and one failed probe is enough because the failure count is not reset. Everything that happened while it was open is lost either way, because there is no replay. Deactivating and reactivating the subscription recovers faster. Alert on circuitOpen: true.
  • Webhooks: subscribe, retries, circuit breaker, rotate secret
  • Idempotency: for outbound API calls in the other direction
  • Versioning: what counts as a breaking change for webhook payloads