Verifying Signatures
Copy-paste HMAC-SHA256 verification snippets in TypeScript, Python, PHP, Ruby, and Go.
Every webhook delivery carries:
X-Brainerce-Signature: hex HMAC-SHA256 of the signed payloadX-Brainerce-Timestamp: Unix milliseconds the payload was signed at- a JSON body
The signed payload is built by concatenating the timestamp, a literal dot, and the raw body bytes:
signed = `${timestamp}.${raw_body}`
signature = HMAC_SHA256(your_subscription_secret, signed)Three rules to follow on the receiving side:
- Sign the raw bytes, not a re-serialised JSON. Different libraries reorder keys differently, so the bytes you sign must be exactly the bytes you received.
- Reject stale timestamps. We recommend a 5-minute window. Without this check, anyone who captures one delivery can replay it forever. Retries do not trip this window: every attempt is signed afresh with a new
X-Brainerce-Timestampand a new signature, so always verify a request against its own headers and its own body. - Constant-time compare. Use
crypto.timingSafeEqual/hmac.compare_digest/hash_equals/ your language's equivalent. Plain==leaks timing.
The snippets below follow these three rules.
Two cases that look different and verify the same way. An event that was resent from the event log is signed afresh with a new timestamp, so it passes the 5-minute window; it keeps its original X-Brainerce-Event-Id and adds X-Brainerce-Redelivery: true. An endpoint with a body template still sends a signature over the actual body that was sent (the rendered template, not the event envelope), so keep hashing the raw bytes you received. Any custom headers on the endpoint are extra and are not part of the signature.
TypeScript / Node.js
Works with Express, Fastify, Next.js Route Handlers, and any framework that exposes the raw request body.
If you already depend on the brainerce SDK, verifyWebhook({ rawBody, signature, timestamp, secret }) is this exact check (timestamp-prefixed HMAC, constant-time compare, 5-minute window) and parseWebhookEvent(rawBody) types the envelope; see SDK webhooks. The hand-rolled version below is equivalent and has no dependency.
import * as crypto from 'crypto';
const WEBHOOK_SECRET = process.env.BRAINERCE_WEBHOOK_SECRET!; // whsec_...
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
export function verifyBrainerceWebhook(
rawBody: string | Buffer,
signatureHeader: string | undefined,
timestampHeader: string | undefined
): { valid: boolean; reason?: string } {
if (!signatureHeader || !timestampHeader) {
return { valid: false, reason: 'missing signature/timestamp headers' };
}
const timestamp = Number.parseInt(timestampHeader, 10);
if (Number.isNaN(timestamp)) {
return { valid: false, reason: 'malformed timestamp' };
}
const age = Date.now() - timestamp;
if (age > MAX_AGE_MS) return { valid: false, reason: 'timestamp too old' };
if (age < -MAX_AGE_MS) return { valid: false, reason: 'timestamp in the future' };
const bodyString = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${bodyString}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const givenBuf = Buffer.from(signatureHeader, 'hex');
if (expectedBuf.length !== givenBuf.length) {
return { valid: false, reason: 'signature length mismatch' };
}
const valid = crypto.timingSafeEqual(expectedBuf, givenBuf);
return valid ? { valid: true } : { valid: false, reason: 'signature mismatch' };
}Express example
import express from 'express';
const app = express();
// Use raw body — express.json() will re-serialise and break signing
app.post('/brainerce-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const result = verifyBrainerceWebhook(
req.body, // Buffer thanks to express.raw
req.header('X-Brainerce-Signature'),
req.header('X-Brainerce-Timestamp')
);
if (!result.valid) {
return res.status(401).json({ error: result.reason });
}
const event = JSON.parse(req.body.toString('utf8'));
// process event ...
res.status(202).json({ ok: true });
});Next.js App Router example
// app/api/brainerce-webhook/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const rawBody = await req.text(); // raw string, not parsed JSON
const result = verifyBrainerceWebhook(
rawBody,
req.headers.get('X-Brainerce-Signature') ?? undefined,
req.headers.get('X-Brainerce-Timestamp') ?? undefined
);
if (!result.valid) {
return NextResponse.json({ error: result.reason }, { status: 401 });
}
const event = JSON.parse(rawBody);
// process event ...
return NextResponse.json({ ok: true }, { status: 202 });
}Python
Works with Flask, FastAPI, Django, and any WSGI/ASGI framework that exposes the raw body.
import hmac
import hashlib
import os
import time
WEBHOOK_SECRET = os.environ['BRAINERCE_WEBHOOK_SECRET'].encode() # whsec_...
MAX_AGE_MS = 5 * 60 * 1000
def verify_brainerce_webhook(
raw_body: bytes,
signature_header: str | None,
timestamp_header: str | None,
) -> tuple[bool, str | None]:
if not signature_header or not timestamp_header:
return False, 'missing signature/timestamp headers'
try:
timestamp = int(timestamp_header)
except ValueError:
return False, 'malformed timestamp'
now_ms = int(time.time() * 1000)
age = now_ms - timestamp
if age > MAX_AGE_MS:
return False, 'timestamp too old'
if age < -MAX_AGE_MS:
return False, 'timestamp in the future'
signed_payload = f'{timestamp}.'.encode() + raw_body
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature_header):
return False, 'signature mismatch'
return True, NoneFlask example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post('/brainerce-webhook')
def webhook():
raw_body = request.get_data() # raw bytes, not request.json
valid, reason = verify_brainerce_webhook(
raw_body,
request.headers.get('X-Brainerce-Signature'),
request.headers.get('X-Brainerce-Timestamp'),
)
if not valid:
return jsonify({'error': reason}), 401
event = request.get_json()
# process event ...
return jsonify({'ok': True}), 202FastAPI example
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post('/brainerce-webhook')
async def webhook(request: Request):
raw_body = await request.body() # raw bytes
valid, reason = verify_brainerce_webhook(
raw_body,
request.headers.get('X-Brainerce-Signature'),
request.headers.get('X-Brainerce-Timestamp'),
)
if not valid:
raise HTTPException(401, reason)
import json
event = json.loads(raw_body)
# process event ...
return {'ok': True}PHP
Works with Laravel, Symfony, and plain PHP.
<?php
const BRAINERCE_WEBHOOK_SECRET_ENV = 'BRAINERCE_WEBHOOK_SECRET';
const BRAINERCE_MAX_AGE_MS = 5 * 60 * 1000;
function verifyBrainerceWebhook(
string $rawBody,
?string $signatureHeader,
?string $timestampHeader
): array {
if (!$signatureHeader || !$timestampHeader) {
return ['valid' => false, 'reason' => 'missing signature/timestamp headers'];
}
$timestamp = filter_var($timestampHeader, FILTER_VALIDATE_INT);
if ($timestamp === false) {
return ['valid' => false, 'reason' => 'malformed timestamp'];
}
$nowMs = (int) (microtime(true) * 1000);
$age = $nowMs - $timestamp;
if ($age > BRAINERCE_MAX_AGE_MS) return ['valid' => false, 'reason' => 'timestamp too old'];
if ($age < -BRAINERCE_MAX_AGE_MS) return ['valid' => false, 'reason' => 'timestamp in the future'];
$secret = getenv(BRAINERCE_WEBHOOK_SECRET_ENV);
if ($secret === false || $secret === '') {
// Fail closed: an HMAC with an empty key would accept anyone's forged signature.
throw new RuntimeException(BRAINERCE_WEBHOOK_SECRET_ENV . ' is not set');
}
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $signatureHeader)) {
return ['valid' => false, 'reason' => 'signature mismatch'];
}
return ['valid' => true];
}
// Usage (plain PHP)
$rawBody = file_get_contents('php://input');
$result = verifyBrainerceWebhook(
$rawBody,
$_SERVER['HTTP_X_BRAINERCE_SIGNATURE'] ?? null,
$_SERVER['HTTP_X_BRAINERCE_TIMESTAMP'] ?? null
);
if (!$result['valid']) {
http_response_code(401);
echo json_encode(['error' => $result['reason']]);
exit;
}
$event = json_decode($rawBody, true);
// process event ...
http_response_code(202);
echo json_encode(['ok' => true]);Laravel example
// routes/web.php
Route::post('/brainerce-webhook', function (Request $request) {
$result = verifyBrainerceWebhook(
$request->getContent(), // raw body, not $request->all()
$request->header('X-Brainerce-Signature'),
$request->header('X-Brainerce-Timestamp')
);
if (!$result['valid']) {
return response()->json(['error' => $result['reason']], 401);
}
$event = $request->json()->all();
// process event ...
return response()->json(['ok' => true], 202);
});Ruby
Works with Rails, Sinatra, and Rack.
require 'openssl'
require 'rack/utils'
WEBHOOK_SECRET = ENV.fetch('BRAINERCE_WEBHOOK_SECRET') # whsec_...
MAX_AGE_MS = 5 * 60 * 1000
def verify_brainerce_webhook(raw_body, signature_header, timestamp_header)
return [false, 'missing signature/timestamp headers'] if signature_header.nil? || timestamp_header.nil?
timestamp = Integer(timestamp_header) rescue nil
return [false, 'malformed timestamp'] if timestamp.nil?
now_ms = (Time.now.to_f * 1000).to_i
age = now_ms - timestamp
return [false, 'timestamp too old'] if age > MAX_AGE_MS
return [false, 'timestamp in the future'] if age < -MAX_AGE_MS
signed_payload = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest('SHA256', WEBHOOK_SECRET, signed_payload)
return [false, 'signature mismatch'] unless Rack::Utils.secure_compare(expected, signature_header)
[true, nil]
endRails example
# config/routes.rb
post '/brainerce-webhook', to: 'webhooks#brainerce'
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def brainerce
raw_body = request.raw_post # raw string, not params
valid, reason = verify_brainerce_webhook(
raw_body,
request.headers['X-Brainerce-Signature'],
request.headers['X-Brainerce-Timestamp']
)
return render(json: { error: reason }, status: 401) unless valid
event = JSON.parse(raw_body)
# process event ...
render json: { ok: true }, status: 202
end
endGo
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const maxAgeMs = 5 * 60 * 1000
var (
ErrMissingHeaders = errors.New("missing signature/timestamp headers")
ErrMalformedTime = errors.New("malformed timestamp")
ErrTimestampStale = errors.New("timestamp too old")
ErrTimestampFuture = errors.New("timestamp in the future")
ErrSignatureMismatch = errors.New("signature mismatch")
ErrMissingSecret = errors.New("BRAINERCE_WEBHOOK_SECRET is not set")
)
func VerifyBrainerceWebhook(
rawBody []byte,
signatureHeader, timestampHeader string,
) error {
if signatureHeader == "" || timestampHeader == "" {
return ErrMissingHeaders
}
timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return ErrMalformedTime
}
nowMs := time.Now().UnixMilli()
age := nowMs - timestamp
if age > maxAgeMs {
return ErrTimestampStale
}
if age < -maxAgeMs {
return ErrTimestampFuture
}
secret := os.Getenv("BRAINERCE_WEBHOOK_SECRET")
if secret == "" {
// Fail closed: an HMAC with an empty key would accept anyone's forged signature.
return ErrMissingSecret
}
signed := fmt.Sprintf("%d.%s", timestamp, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signatureHeader)) {
return ErrSignatureMismatch
}
return nil
}
// net/http handler
func BrainerceWebhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body) // the raw bytes, never a re-encoded body
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if err := VerifyBrainerceWebhook(
rawBody,
r.Header.Get("X-Brainerce-Signature"),
r.Header.Get("X-Brainerce-Timestamp"),
); err != nil {
if errors.Is(err, ErrMissingSecret) {
http.Error(w, "server misconfigured", http.StatusInternalServerError)
return
}
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// process event ...
w.WriteHeader(http.StatusAccepted)
}Testing your verifier
Once you have the snippet pasted in, test it with a known-good payload:
# Run from a terminal in your dev environment
SECRET='whsec_REPLACE_ME'
BODY='{"id":"evt_test","type":"order.created","createdAt":"2026-05-18T14:00:00.000Z","data":{"orderId":"ord_test"}}'
TS=$(date +%s)000
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
curl -X POST http://localhost:3000/brainerce-webhook \
-H "Content-Type: application/json" \
-H "X-Brainerce-Signature: $SIG" \
-H "X-Brainerce-Timestamp: $TS" \
--data-raw "$BODY"You should get 202 Accepted back. If you get 401, the snippet returned a reason; check it against the rules at the top of this page.