Webhook Security
Every webhook request from ConsentForge is signed with HMAC-SHA256. Always verify the signature before processing the payload — this confirms the request came from ConsentForge and the body was not tampered with.
Request headers
| Header | Description |
|---|---|
X-ConsentForge-Signature | HMAC-SHA256 hex digest of {timestamp}.{raw_body} |
X-ConsentForge-Timestamp | Unix timestamp (seconds) when the event was sent |
X-ConsentForge-Delivery-ID | Unique delivery ID — use for idempotency |
Verification algorithm
- Read
X-ConsentForge-Timestampfrom the request headers - Read
X-ConsentForge-Signaturefrom the request headers - Build the signing string:
{timestamp}.{raw_request_body}(concatenate with a dot) - Compute HMAC-SHA256 of the signing string using your webhook secret
- Compare (using timing-safe comparison) with the received signature
- Reject if the timestamp is more than 5 minutes old (replay protection)
warning
Always use timing-safe comparison (e.g. hash_equals, crypto.timingSafeEqual, hmac.compare_digest). Standard string comparison is vulnerable to timing attacks.
Code examples
- Node.js
- PHP
- Python
- Ruby
- Go
const crypto = require('crypto');
function verifyConsentForgeWebhook(rawBody, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return false; // Replay protection: reject if older than 5 minutes
}
const signingString = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}
// Express endpoint
app.post('/webhooks/consentforge', express.raw({ type: '*/*' }), (req, res) => {
const valid = verifyConsentForgeWebhook(
req.body.toString(),
req.headers['x-consentforge-signature'],
req.headers['x-consentforge-timestamp'],
process.env.CONSENTFORGE_WEBHOOK_SECRET
);
if (!valid) return res.status(401).json({ error: 'Invalid signature' });
const payload = JSON.parse(req.body);
console.log('Received event:', payload.event);
res.json({ received: true });
});
function verifyConsentForgeWebhook(
string $rawBody,
string $signature,
string $timestamp,
string $secret
): bool {
if (abs(time() - (int)$timestamp) > 300) {
return false; // Replay protection
}
$signingString = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signingString, $secret);
return hash_equals($expected, $signature);
}
// Laravel controller
public function handle(Request $request): Response
{
$rawBody = $request->getContent();
$signature = $request->header('X-ConsentForge-Signature');
$timestamp = $request->header('X-ConsentForge-Timestamp');
$secret = config('services.consentforge.webhook_secret');
if (!verifyConsentForgeWebhook($rawBody, $signature, $timestamp, $secret)) {
return response('Unauthorized', 401);
}
$payload = $request->json()->all();
// handle $payload['event'] ...
return response()->json(['received' => true]);
}
import hmac
import hashlib
import time
import os
from flask import request, abort, jsonify
def verify_consentforge_webhook(raw_body, signature, timestamp, secret):
if abs(time.time() - int(timestamp)) > 300:
return False # Replay protection
signing_string = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode('utf-8'),
signing_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks/consentforge', methods=['POST'])
def webhook():
raw_body = request.get_data(as_text=True)
valid = verify_consentforge_webhook(
raw_body,
request.headers.get('X-ConsentForge-Signature'),
request.headers.get('X-ConsentForge-Timestamp'),
os.environ['CONSENTFORGE_WEBHOOK_SECRET']
)
if not valid:
abort(401)
payload = request.get_json()
print(f"Received event: {payload['event']}")
return jsonify(received=True)
require 'openssl'
require 'rack/utils'
def verify_consentforge_webhook(raw_body, signature, timestamp, secret)
return false if (Time.now.to_i - timestamp.to_i).abs > 300
signing_string = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signing_string)
Rack::Utils.secure_compare(expected, signature)
end
# Rails controller
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def consentforge
raw_body = request.body.read
valid = verify_consentforge_webhook(
raw_body,
request.headers['X-ConsentForge-Signature'],
request.headers['X-ConsentForge-Timestamp'],
ENV['CONSENTFORGE_WEBHOOK_SECRET']
)
return head :unauthorized unless valid
payload = JSON.parse(raw_body)
Rails.logger.info "Webhook event: #{payload['event']}"
head :ok
end
end
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"net/http"
"strconv"
"time"
)
func verifyConsentForgeWebhook(rawBody []byte, signature, timestamp, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
signingString := timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
valid := verifyConsentForgeWebhook(
body,
r.Header.Get("X-ConsentForge-Signature"),
r.Header.Get("X-ConsentForge-Timestamp"),
os.Getenv("CONSENTFORGE_WEBHOOK_SECRET"),
)
if !valid {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// process body...
w.WriteHeader(http.StatusOK)
}
Secret rotation
To rotate your webhook secret without downtime:
- Go to Dashboard → Property → Webhooks → [Webhook] → Rotate Secret
- A new secret is issued — your old secret remains valid for 24 hours
- Update your server to accept the new secret
- After 24 hours the old secret is invalidated automatically
Finding your webhook secret
Your webhook secret is shown once at creation time. If you've lost it, rotate it:
Dashboard → Property → Webhooks → [Webhook] → Rotate Secret
Testing signature verification
Use the Send Test Event button in the Dashboard to send a real signed payload to your endpoint. Check your server logs to confirm successful verification.