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 payload
  • X-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:

  1. 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.
  2. Reject stale timestamps. We recommend a 5-minute window. Without this check, anyone who captures one delivery can replay it forever.
  3. 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.


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, None

Flask 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}), 202

FastAPI 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);
    $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]
end

Rails 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
end

Go

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"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")
)

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")
	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 := make([]byte, 0, r.ContentLength)
	// io.ReadAll or your framework's raw-body helper
	// ...

	if err := VerifyBrainerceWebhook(
		rawBody,
		r.Header.Get("X-Brainerce-Signature"),
		r.Header.Get("X-Brainerce-Timestamp"),
	); err != nil {
		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.