Skip to main content

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)

Resources