Webhook Verification
Detailed guide for implementing HMAC-SHA256 verification of MONCRENEAU webhooks.
Why Verify Signatures?
Without verification, anyone can send fake requests to your webhook endpoint:
# ❌ Attack possible without verification
curl -X POST https://your-app.com/webhooks/moncreneau -d '{"id":"evt_fake","type":"appointment.created","data":{"object":{"id":999}}}'
The HMAC signature guarantees the request actually comes from MONCRENEAU.
How It Works
1. MONCRENEAU Generates the Signature
signature = HMAC-SHA256(webhook_secret, payload_json)
2. You Verify the Signature
calculated_signature = HMAC-SHA256(your_secret, received_payload)
if (calculated_signature === signature_header) ✅
else ❌
Step-by-Step Implementation
Node.js / Express
Step 1: Configuration
// .env
WEBHOOK_SECRET=whsec_abc123xyz789...
// app.js
const express = require('express');
const crypto = require('crypto');
const app = express();
// ⚠️ IMPORTANT: Parse as raw for webhooks
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use(express.json()); // For other routes
Step 2: Verification Function
function verifyWebhookSignature(payload, signature, secret) {
// Check signature exists
if (!signature) {
console.error('Missing signature header');
return false;
}
// Extract hash (format: "sha256=...")
const parts = signature.split('=');
if (parts[0] !== 'sha256') {
console.error('Invalid signature format');
return false;
}
const receivedHash = parts[1];
// Calculate expected signature
const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
);
} catch (error) {
console.error('Signature comparison failed:', error);
return false;
}
}
Step 3: Webhook Route
app.post('/webhooks/moncreneau', (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const payload = req.body; // Buffer thanks to express.raw()
const secret = process.env.WEBHOOK_SECRET;
// Verify signature
if (!verifyWebhookSignature(payload, signature, secret)) {
console.error('Invalid signature');
return res.status(401).send('Invalid signature');
}
// Valid signature ✅
const event = JSON.parse(payload.toString());
console.log('✓ Valid webhook received:', event.type);
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(event);
});
Java / Spring Boot
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
@Value("${webhook.secret}")
private String webhookSecret;
@PostMapping("/moncreneau")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Moncreneau-Signature") String signature
) {
if (!verifySignature(payload, signature)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// Parse and process event
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
processWebhookAsync(event);
return ResponseEntity.ok("OK");
}
private boolean verifySignature(String payload, String signature) {
if (signature == null || !signature.startsWith("sha256=")) {
return false;
}
String receivedHash = signature.substring(7);
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
mac.init(secretKey);
byte[] expectedBytes = mac.doFinal(payload.getBytes("UTF-8"));
String expectedHash = bytesToHex(expectedBytes);
// Timing-safe comparison
return MessageDigest.isEqual(
expectedHash.getBytes(),
receivedHash.getBytes()
);
} catch (Exception e) {
logger.error("Signature verification failed", e);
return false;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
PHP / Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handleMoncreneauWebhook(Request $request)
{
$signature = $request->header('X-Moncreneau-Signature');
$payload = $request->getContent();
$secret = config('services.moncreneau.webhook_secret');
if (!$this->verifySignature($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}
$event = json_decode($payload, true);
// Process asynchronously (queue)
dispatch(new ProcessWebhook($event));
return response('OK', 200);
}
private function verifySignature($payload, $signature, $secret)
{
if (!$signature || !str_starts_with($signature, 'sha256=')) {
return false;
}
$receivedHash = substr($signature, 7);
$expectedHash = hash_hmac('sha256', $payload, $secret);
// Timing-safe comparison
return hash_equals($expectedHash, $receivedHash);
}
}
Python / Django
import hmac
import hashlib
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
@csrf_exempt
def webhook_handler(request):
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
signature = request.headers.get('X-Moncreneau-Signature')
payload = request.body
secret = settings.WEBHOOK_SECRET.encode('utf-8')
if not verify_signature(payload, signature, secret):
return JsonResponse({'error': 'Invalid signature'}, status=401)
import json
event = json.loads(payload)
# Process asynchronously (Celery)
from .tasks import process_webhook
process_webhook.delay(event)
return JsonResponse({'status': 'ok'})
def verify_signature(payload, signature, secret):
if not signature or not signature.startswith('sha256='):
return False
received_hash = signature[7:]
expected_hash = hmac.new(
secret,
payload,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected_hash, received_hash)
Debugging
Check Received Payload
app.post('/webhooks/moncreneau', (req, res) => {
const payload = req.body;
console.log('Payload type:', typeof payload);
console.log('Payload content:', payload);
if (Buffer.isBuffer(payload)) {
console.log('✓ Payload is Buffer (correct)');
} else if (typeof payload === 'string') {
console.log('✓ Payload is string (correct)');
} else {
console.error('✗ Payload is object (WRONG - use express.raw)');
}
// ...
});
Check Secret
console.log('Secret length:', process.env.WEBHOOK_SECRET?.length);
console.log('Secret starts with:', process.env.WEBHOOK_SECRET?.substring(0, 10));
if (!process.env.WEBHOOK_SECRET) {
console.error('❌ WEBHOOK_SECRET not set!');
}
Compare Signatures
function debugSignature(payload, signature, secret) {
console.log('=== SIGNATURE DEBUG ===');
const receivedHash = signature.split('=')[1];
console.log('Received hash:', receivedHash);
const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
console.log('Expected hash:', expectedHash);
console.log('Match:', receivedHash === expectedHash ? '✓' : '✗');
if (receivedHash !== expectedHash) {
console.log('First 10 chars received:', receivedHash.substring(0, 10));
console.log('First 10 chars expected:', expectedHash.substring(0, 10));
}
}
Test with curl
# Generate test signature
SECRET="whsec_your_secret"
PAYLOAD='{"id":"evt_test","type":"appointment.created"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/.* //')
echo "Signature: sha256=$SIGNATURE"
# Send request
curl -X POST http://localhost:3000/webhooks/moncreneau -H "Content-Type: application/json" -H "X-Moncreneau-Signature: sha256=$SIGNATURE" -d "$PAYLOAD"
Common Errors
❌ Payload Parsed as JSON Before Verification
// BAD
app.use(express.json()); // Parses EVERYTHING as JSON
app.post('/webhooks', (req, res) => {
const payload = req.body; // Already an object!
// Cannot calculate signature on object
});
// GOOD
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
const payload = req.body; // Buffer
const signature = calculateSignature(payload); // ✓
});
❌ Wrong Secret
// Hardcoded secret (BAD)
const secret = "my-secret";
// Secret from env (GOOD)
const secret = process.env.WEBHOOK_SECRET;
❌ Non Timing-Safe Comparison
// VULNERABLE to timing attacks
if (receivedHash === expectedHash) { }
// SECURE
if (crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
)) { }
❌ Charset Encoding
// May cause issues
.update(payload, 'utf-8')
// More reliable
.update(payload) // Let Node handle encoding
Tests
Unit Test of Verification
const crypto = require('crypto');
const { verifyWebhookSignature } = require('../lib/webhooks');
describe('verifyWebhookSignature', () => {
const secret = 'test_secret';
const payload = '{"id":"evt_123","type":"appointment.created"}';
it('should verify valid signature', () => {
const signature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
});
it('should reject invalid signature', () => {
expect(verifyWebhookSignature(payload, 'sha256=invalid', secret)).toBe(false);
});
it('should reject missing signature', () => {
expect(verifyWebhookSignature(payload, null, secret)).toBe(false);
});
it('should reject wrong format', () => {
expect(verifyWebhookSignature(payload, 'md5=abc123', secret)).toBe(false);
});
});
Integration Test with ngrok
# 1. Start local server
npm start
# 2. Expose with ngrok
ngrok http 3000
# 3. Configure webhook URL in MONCRENEAU
# https://abc123.ngrok.io/webhooks/moncreneau
# 4. Create test appointment
# Webhooks arrive in your terminal!
Checklist
- HMAC-SHA256 signature verified
- Payload as Buffer/String (not parsed as JSON)
- Secret from environment variable
- Timing-safe comparison
- Debug logs in development
- Unit tests for verification
- Integration tests with ngrok
- Error handling (401 if invalid signature)