Webhooks
Security
Every webhook delivery is signed so you can confirm it came from UserCheck and arrived unmodified. The signature is sent in the X-UserCheck-Signature header.
What is signed
The signature is the HMAC SHA-256 of the raw request body, keyed with the webhook's signing secret, encoded as lowercase hexadecimal:
signature = hex(hmac_sha256(secret, raw_request_body))
Each webhook has its own signing secret, shown on the webhooks page of the dashboard, where it can also be regenerated. Keep it private and never expose it in client-side code. Regenerating it invalidates the previous secret immediately, so deploy the new one before rotating.
Important
Verify against the raw body exactly as received, before any JSON parsing. Parsing the body and re-serializing it produces a different byte sequence, and the signature will not match: json.dumps in Python inserts spaces after separators, JSON.stringify in JavaScript does not escape forward slashes, and key order is not guaranteed to survive a round trip.
Verification process
- Read the raw request body, unparsed
- Compute
hash_hmac('sha256', raw_body, secret) - Compare the result with the
X-UserCheck-Signatureheader using a constant-time comparison, so a timing side channel cannot leak the expected value - Reject the request when the signatures differ, and only then parse the body
Implementation examples
PHP example
<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_USERCHECK_SIGNATURE'] ?? '';
$secret = 'your_webhook_secret';
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expectedSignature, $signature)) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
$event = $payload['event'];
$domain = $payload['data']['domain'];
switch ($event) {
case 'domain.flagged.disposable':
// Handle disposable domain flagging
break;
case 'domain.flagged.relay':
// Handle relay domain flagging
break;
case 'domain.flagged.spam':
// Handle spam domain flagging
break;
}
http_response_code(200);
echo json_encode(['status' => 'success']);
Node.js example
express.json() discards the raw body, so capture it with the verify callback before it is parsed.
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = 'your_webhook_secret';
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);
function isValidSignature(rawBody, signature) {
// Requests without a JSON body never reach the verify callback above.
if (!rawBody) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
// timingSafeEqual throws on a length mismatch, so check the length first.
if (signature.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-usercheck-signature'] || '';
if (!isValidSignature(req.rawBody, signature)) {
return res.status(401).json({ status: 'error', message: 'Invalid signature' });
}
const { event, data } = req.body;
switch (event) {
case 'domain.flagged.disposable':
// Handle disposable domain flagging
break;
case 'domain.flagged.relay':
// Handle relay domain flagging
break;
case 'domain.flagged.spam':
// Handle spam domain flagging
break;
}
res.status(200).json({ status: 'success' });
});
app.listen(3000);
Python example
request.get_data() returns the raw body; request.json parses it and cannot be used for verification.
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = 'your_webhook_secret'
@app.route('/webhook', methods=['POST'])
def webhook_handler():
raw_body = request.get_data()
signature = request.headers.get('X-UserCheck-Signature', '')
expected_signature = hmac.new(
SECRET.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(
expected_signature.encode('utf-8'),
signature.encode('utf-8'),
):
return jsonify({'status': 'error', 'message': 'Invalid signature'}), 401
payload = request.get_json()
event = payload['event']
domain = payload['data']['domain']
if event == 'domain.flagged.disposable':
# Handle disposable domain flagging
pass
elif event == 'domain.flagged.relay':
# Handle relay domain flagging
pass
elif event == 'domain.flagged.spam':
# Handle spam domain flagging
pass
return jsonify({'status': 'success'}), 200
if __name__ == '__main__':
app.run()
Respond within 5 seconds and with a 2xx status, or the delivery is treated as failed and retried. See Delivery & Retries.