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 |
X-Brainerce-Redelivery | true only on an event that was resent by hand or from the Not sent list. Absent on a first delivery. A resend keeps the original X-Brainerce-Event-Id and is signed afresh |
Content-Type | application/json |
If the endpoint has custom headers configured, they arrive in addition to these.
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. Automatic retries all land within about 75 seconds, but an event can also be resent by hand from the event log later, with the same id and the header X-Brainerce-Redelivery: true. Keep the event id in your inbox table (a unique key) rather than in a 24-hour cache, and a resend of an event you already processed is ignored.
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();
// YOUR durable inbox (a table with a unique key on the event id). Implement it
// before going live: this stub throws, so an unimplemented copy answers 500 and
// Brainerce retries, rather than acknowledging events it never kept.
async function storeEvent(eventId: string, rawBody: Buffer): Promise<void> {
// e.g. INSERT INTO webhook_inbox (event_id, body) VALUES ($1, $2)
// ON CONFLICT (event_id) DO NOTHING
throw new Error('storeEvent not implemented');
}
// 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' }), async (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. Store the event durably, keyed by envelope id (a duplicate insert is a
// no-op), then acknowledge. If the insert fails, answer 5xx so we retry.
try {
const event = JSON.parse(req.body.toString('utf8'));
await storeEvent(event.id, req.body);
} catch {
return res.status(500).send('Not stored');
}
res.status(202).send('Accepted');
// 5. A separate worker processes pending inbox rows and marks each one done
// only after the work succeeds. Never mark an event handled before the
// work: a failure after that point makes our retry look like a duplicate.
});The inbox, worker and business-key dedup (one downstream order per orderId, even though order.created and order.paid both arrive for it) are spelled out in Idempotency.
Python (FastAPI / Flask)
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Request, HTTPException
SECRET = os.environ["BRAINERCE_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000
app = FastAPI()
def store_event(event_id: str, raw_body: bytes) -> None:
"""YOUR durable inbox: insert, no-op if event_id already exists, raise on failure.
e.g. INSERT INTO webhook_inbox (event_id, body) VALUES (%s, %s)
ON CONFLICT (event_id) DO NOTHING
This stub raises, so an unimplemented copy answers 500 and Brainerce retries.
"""
raise NotImplementedError("store_event not implemented")
@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")
try:
age_ms = int(time.time() * 1000) - int(timestamp)
except ValueError:
raise HTTPException(400, "Malformed 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")
# Store durably BEFORE acknowledging; a worker processes it later and marks
# it done only after the work succeeds.
try:
store_event(json.loads(raw_body)["id"], raw_body)
except Exception:
raise HTTPException(500, "Not stored")
return {"ok": True}Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"time"
)
const maxAgeMs = 5 * 60 * 1000
var secret = os.Getenv("BRAINERCE_WEBHOOK_SECRET")
// storeEvent is YOUR durable inbox: insert, no-op if eventID already exists,
// return an error on failure. e.g.
// INSERT INTO webhook_inbox (event_id, body) VALUES ($1, $2)
// ON CONFLICT (event_id) DO NOTHING
// This stub fails, so an unimplemented copy answers 500 and Brainerce retries.
func storeEvent(eventID string, body []byte) error {
return errors.New("storeEvent not implemented")
}
func Handle(w http.ResponseWriter, r *http.Request) {
if secret == "" {
// Fail closed: an HMAC with an empty key would accept anyone's forged signature.
http.Error(w, "server misconfigured", http.StatusInternalServerError)
return
}
sig := r.Header.Get("X-Brainerce-Signature")
ts := r.Header.Get("X-Brainerce-Timestamp")
body, err := io.ReadAll(r.Body)
if err != nil || sig == "" || ts == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
tsMs, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
http.Error(w, "bad ts", http.StatusBadRequest)
return
}
age := time.Now().UnixMilli() - tsMs
if age > maxAgeMs || age < -60_000 {
http.Error(w, "stale", http.StatusBadRequest)
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", http.StatusBadRequest)
return
}
// Store durably BEFORE acknowledging; a worker processes it later and
// marks it done only after the work succeeds.
var env struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &env); err != nil || env.ID == "" {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if err := storeEvent(env.ID, body); err != nil {
http.Error(w, "not stored", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}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. Events that happen while it is open are not lost: each is recorded as Not sent in the endpoint's event log (up to 500 per endpoint; only the last 7 days count towardmissedCountandreplay-missed). But nothing replays them automatically: once your endpoint works, resend them from the event log or withPOST /api/stores/{storeId}/webhook-subscriptions/{id}/replay-missed. Deactivating and reactivating the subscription closes the circuit faster. Alert oncircuitOpen: trueand onmissedCount. - Treating a resend as a new event. A resent event keeps its original
idandX-Brainerce-Event-Id, carriesX-Brainerce-Redelivery: true, and is tried once (no retries). Dedupe on the id as you already do. See Resending events.
Related
- Webhooks: subscribe, retries, circuit breaker, resending missed events, custom headers, body template, rotate secret
- Idempotency: for outbound API calls in the other direction
- Versioning: what counts as a breaking change for webhook payloads