Signup Protection Pricing Documentation Sign up Log in

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

  1. Read the raw request body, unparsed
  2. Compute hash_hmac('sha256', raw_body, secret)
  3. Compare the result with the X-UserCheck-Signature header using a constant-time comparison, so a timing side channel cannot leak the expected value
  4. 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.

Previous
Events & Payloads