Hey awesome HubSpot team & fellow devs.
After spending 4 intense days debugging signature validation for Webhooks V3 in PHP, I finally cracked it.
The current docs are helpful — but they miss a few critical implementation details for PHP developers. So I’m sharing a working PHP example + notes that could help others avoid my headache.
V3 Signature Validation in PHP — Fully Working:
//By Ramy Elkherbawy
//ramy.pro
$signature = $_SERVER['HTTP_X_HUBSPOT_SIGNATURE_V3'] ?? null;
$timestamp = $_SERVER['HTTP_X_HUBSPOT_REQUEST_TIMESTAMP'] ?? null;
if (!$signature || !$timestamp) {
http_response_code(403);
exit('Missing signature or timestamp.');
}
// Validate timestamp (within 5 minutes)
$maxSkew = 300;
if (abs(time() - ((int)($timestamp / 1000))) > $maxSkew) {
http_response_code(403);
exit('Expired timestamp.');
}
$method = $_SERVER['REQUEST_METHOD'];
// Very important! HubSpot signs the full Target URL including https://domain.com/path
// NOT just the path — use the exact URL you entered in your app's "Target URL" field.
$domain = 'https://' . $_SERVER['HTTP_HOST'];
$uri = $_SERVER['REQUEST_URI'];
// Decode URL-encoded characters (as per HubSpot docs)
$decodeMap = [
'%3A' => ':', '%2F' => '/', '%40' => '@',
'%21' => '!', '%24' => '$', '%27' => "'",
'%28' => '(', '%29' => ')', '%2A' => '*',
'%2C' => ',', '%3B' => ';',
];
$uri = strtr(rawurldecode($uri), $decodeMap);
// Final URI string used in the signature
$fullUri = $domain . $uri;
$body = file_get_contents('php://input'); // Raw JSON string
$clientSecret = 'YOUR_CLIENT_SECRET'; // Replace with yours
$rawString = $method . $fullUri . $body . $timestamp;
$expectedSignature = base64_encode(
hash_hmac('sha256', $rawString, $clientSecret, true)
);
// Validate safely
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(403);
exit('Invalid signature.');
}
http_response_code(200);
echo 'Valid webhook';
Key Notes:
- HubSpot signs the exact Target URL you entered in the webhook settings, including the protocol (e.g., https://yourdomain.com/path). So make sure you’re reproducing it exactly.
- Apply rawurldecode(), then replace special encodings manually.
- Always use php://input for raw body (don’t use $_POST).
- Use hash_equals() to prevent timing attacks.
This was a wild ride ![]()
HubSpot team — would love to see this kind of PHP example added to the docs. It would save a lot of us hours (and maybe some therapy bills
).