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:
- Verify the signature with the subscription's
secret(from when you registered the URL).- Reject if the
X-Brainerce-Timestampis older than ~5 minutes. That defeats replay even if a signed payload leaks.- 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:
| Header | Value |
|---|---|
X-Brainerce-Signature | hex-encoded HMAC-SHA256 of ${timestamp}.${rawBody} |
X-Brainerce-Timestamp | Unix epoch in milliseconds |
X-Brainerce-Event | Event type (e.g. order.created) |
X-Brainerce-Event-Id | Stable opaque id: evt_ + 32 hex chars (not a UUID). Use it for idempotency on your side |
Content-Type | application/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
200as the only success. Brainerce considers any2xxresponse a successful delivery.204 No Contentis fine. - Answering a non-2xx you think is harmless. A
400or404from your handler is retried the full five attempts and counts toward the circuit breaker exactly like a timeout. Only2xxstops 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
2xxresumes 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 oncircuitOpen: true.
Related
- 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