Skip to main content

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

  1. Always verify signature before processing payload
  2. Use timing-safe comparison to avoid timing attacks
  3. Store secret in environment variables
  4. Log invalid signatures to detect attack attempts
  5. Use HTTPS in production (mandatory)

❌ Don't

  1. Never skip signature verification
  2. Never compare with == (vulnerable to timing attacks)
  3. Never log the webhook secret
  4. Never use HTTP in production

Secret Rotation

If your secret is compromised:

  1. Generate new secret in Dashboard → Webhooks
  2. Update your code with new secret
  3. Deploy new version
  4. 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":{}}'

Next Steps