Webhook Security
Webhook security is essential to ensure requests actually come from moncreneau.
HMAC-SHA256 Signature
Each webhook is signed with HMAC-SHA256. The X-Moncreneau-Signature header contains the signature:
X-Moncreneau-Signature: sha256=5d41402abc4b2a76b9719d911017c592
Signature Verification
Step 1: Get the Secret
When creating the webhook, you receive a secret:
whsec_abc123def456ghi789jkl012mno345
⚠️ Keep this secret secure! Never commit it to your code.
Step 2: Calculate the Signature
Calculate the HMAC of the payload with your secret:
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
// Extract signature (remove "sha256=" prefix)
const receivedSignature = signature.replace('sha256=', '');
// Calculate expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
const expectedSignature = hmac.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
// Usage with Express
app.post('/webhooks/moncreneau', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const secret = process.env.MONCRENEAU_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Now safely parse JSON
const event = JSON.parse(req.body.toString());
handleEvent(event);
res.status(200).send('OK');
});
Java (Spring Boot)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
@Value("${moncreneau.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(HttpStatus.UNAUTHORIZED)
.body("Invalid signature");
}
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
handleEvent(event);
return ResponseEntity.ok("OK");
}
private boolean verifySignature(String payload, String signature) {
try {
String receivedSig = signature.replace("sha256=", "");
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec key = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
hmac.init(key);
byte[] hash = hmac.doFinal(payload.getBytes("UTF-8"));
String expectedSig = bytesToHex(hash);
return MessageDigest.isEqual(
receivedSig.getBytes(),
expectedSig.getBytes()
);
} catch (Exception e) {
return false;
}
}
}
PHP (Laravel)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handleMoncreneau(Request $request)
{
$signature = $request->header('X-Moncreneau-Signature');
$payload = $request->getContent();
$secret = config('moncreneau.webhook_secret');
if (!$this->verifySignature($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}
$event = json_decode($payload, true);
$this->handleEvent($event);
return response('OK', 200);
}
private function verifySignature($payload, $signature, $secret)
{
$receivedSignature = str_replace('sha256=', '', $signature);
$expectedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $receivedSignature);
}
}
Python (Django/Flask)
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify webhook HMAC signature."""
received_signature = signature.replace('sha256=', '')
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, received_signature)
# Flask
@app.route('/webhooks/moncreneau', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Moncreneau-Signature')
payload = request.get_data()
secret = os.getenv('MONCRENEAU_WEBHOOK_SECRET')
if not verify_webhook_signature(payload, signature, secret):
return 'Invalid signature', 401
event = request.get_json()
handle_event(event)
return 'OK', 200
Best Practices
✅ Do
- Always verify signature before processing payload
- Use timing-safe comparison to avoid timing attacks
- Store secret in environment variables
- Log invalid signatures to detect attack attempts
- Use HTTPS in production (mandatory)
❌ Don't
- Never skip signature verification
- Never compare with
==(vulnerable to timing attacks) - Never log the webhook secret
- Never use HTTP in production
Secret Rotation
If your secret is compromised:
- Generate new secret in Dashboard → Webhooks
- Update your code with new secret
- Deploy new version
- Disable old secret
Testing
Generate Test Signature
const crypto = require('crypto');
const payload = JSON.stringify({
id: "evt_test123",
type: "appointment.created",
data: { ... }
});
const secret = "whsec_test_secret";
const signature = "sha256=" + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
console.log(signature);
Test with curl
curl -X POST http://localhost:3000/webhooks/moncreneau \
-H "Content-Type: application/json" \
-H "X-Moncreneau-Signature: sha256=5d41402abc4b2a76b9719d911017c592" \
-d '{"id":"evt_test","type":"appointment.created","data":{}}'