I'd like to verify the Webhook Request with PHP/Laravel, but neither v2 nor v3 gives me the correct hashes in my tests. I'd like to use this for the CRM Cards.
This is the code for v3. For simplicity I'm assuming it's just GET
$xHubSpotSignaturev3 = $request->header('X-HubSpot-Signature-v3');
$xHubSpotRequestTimestamp = strval($this->header('X-HubSpot-Request-Timestamp'));
$httpMethod = strtoupper($request->method());
$httpUri = $request->fullUrl(); // this will include the parameters with ?foo=bar&te=st etc
$requestBody = ''; // For GET the body is empty
$sourceString = mb_convert_encoding($httpMethod . $httpUri . $requestBody . $xHubSpotRequestTimestamp, 'utf8');
$hmac = hash_hmac(algo: 'sha256', data: $sourceString, key: 'CLIENT_SECRET_FROM_HUBSPOT', binary: true);
$result = hash_equals(base64_encode($hmac), $xHubSpotSignaturev3);
I've also tried str_replacing()'ing this map from the documentation in the URL (minus the & symbol, as it says) but it I still get the wrong hash. I've also tried url_encode with no luck.
---
This is the code for v2 but it also doesn't work.
$xHubSpotSignature = $request->header('X-HubSpot-Signature');
$clientSecret = 'HUBSPOT_SECRET';
$httpMethod = strtoupper($request->method());
$httpUri = $request->fullUrl(); // this will include the parameters with ?foo=bar&te=st etc
$requestBody = ''; // For GET the body is empty
$sourceString = mb_convert_encoding($clientSecret . $httpMethod . $httpUri . $requestBody, 'utf8');
$result = hash_equals(hash(algo: 'sha256', data: $sourceString), $xHubSpotSignature);
I'm stuck and have found many posts of other people getting stuck here as well. Is this feature even working or are we all doing something wrong? Maybe something is missing in the documentation?
I've found the official library, which isn't hubspot/hubspot-php (this is a third party package) but hubspot/api-client - quite confusing as they are using the same namespace.
This library does indeed contain a verification method (Signature::isValid()), so I'd suggest for anybody that reads this, to just use the official library.
After days of painful debugging, the solution was: $request->fullUrl() reorders the GET parameters. But of course if you're hashing the URL, they need to be in exactly the same order. So (for Laravel) best to build the URL yourself.
I thought I had done this in tests, but I must've made a mistake. I revisited the code @tominal posted at the beginning and found this was the only difference. Thanks @tominal, I thought I was going insane 😅
Hi @reneconnhealth. I didn't know that $request->fullUrl() reorders the GET parameters. It is good to know. ... and thanks for the official library (hubspot-api-php). I will look into it.
we have the correct client secret (UUID), so that's not the same problem.
POST requests should work, but we can't get GET requests to work. The body of a GET request is empty (the parameters are in the URL), so the concatenated string is just method + uri + timestamp?
In our uri we have an email address with %40 instead of an @ symbol. Do we need to replace the left side with the right side in this table? However, the ? symbol isn't even encoded, so the last sentence ("You do not need to decode the question mark that denotes the beginning of the query string.") is a little confusing.
I have the code I linked to running in several applications. That specific post I linked to shows how to validate the request. The original post I am replying to is probably not relevant to your issue.
I would remove the strval on your timestamp and the mb_convert_encoding function is not necessary since it doesn't convert those %40's into @'s. You can use the urldecode decode function to take care of that.
In regards to the "?" notice at the bottom, they are probably saying that because of past issue with developers encoding/decoding it.
we've done various other tests and were able to verify POST requests.
However, the problem lies with GET requests. I'm using virtually the same code as you but it still doesn't work.
// This is the same
$url = 'https://yourdomain.com' . $request->getRequestUri();
$url = $request->fullUrl();
// This is unnecessary, as $request->getContent() will just return an empty string, same as the else: ''
$content = (strlen($request->getContent()) > 0 ? $request->getContent() : '')
I didn't use mb_convert_encoding for encoding based on that table (%40 etc), but because in the documentation it says "Create a utf-8 encoded string that concatenates together the following". We might have some non utf-8 encoded values in production, but in my tests, it made no difference whether or not I used it.
strval makes no difference, it's just that the timestamp comes as a number and we're concatenating strings. If I didn't do this, PHP would automatically do it.
1. Can you confirm that what HubSpot wants as requestBody for GET requests is an empty string? So, it can be ignored and the string would actually be requestMethod + requestUri+ timestamp.
2. Can you confirm, that the URL is allowed to contain our own GET parameters, already so our webhook url looks like https://app.domain.com/?foo=bar.
Then HubSpot adds the parameters and it looks like this: https://app.domain.com/?foo=bar&email=...&hs_object_id=123 etc.
That's the requestUri we're using, including our own parameters.
3. Can you confirm that we are supposed to replaced %40 with the @, %2F with /, %3A with : etc?
Ah, good point on that strval. I forgot about the timestamp variable's destination.
I must not be passing variables through the webhook in a GET request in my app, so I have not encountered this specific type of issue.
Have you tried using the urldecode function on the request URI? You will need to do that before creating the HMAC hash.
If all else fails here, try logging the $request->fullUrl() in your log file to see what HubSpot is poking you with. I do not know if they will append parameters to a URL already containing parameters.
Let me know how everything goes! I am happy to help.
I've tried urldecode and I've also tried to str_replace the table that's in the documentation manually. I've also removed the email from the sent parameters so there was actually nothing to decode, that doesn't solve the problem either.
$request->fullUrl() does include my custom parameter, so does $request->all(), as that's the way the Request class works in Laravel. Like I've mentioned, for POST requests, that's not a problem. I've meanwhile removed my custom GET parameter and tested it and it made no difference.
I've also tried other stuff just in case the documentation is wrong, like inverting that table (encoding instead of decoding), not using the full url but cutting out the GET parameters, JSON encoding the GET parameters and adding them as body (last before the timestamp) and all sorts of experiments and combinations of them - nothing worked.
This leads me to believe that either the documentation for GET requests is wrong or there is a bug in the way HubSpot signs GET requests. Hard to imagine but they're also just humans;) Would it be possible to speak to somebody responsible at HubSpot, or get the attention of a dev / lead in this thread @Jaycee_Lewis? I've literally wasted hours on this now.
@Jaycee_Lewis: The PHP SDK also needs an update. It offers a verification function, but only for version 1 (and doesn't say that, version 1 is also not sent anymore, only v2 and v3). There's a GitHub issue: https://github.com/HubSpot/hubspot-php/issues/403
All other services we work with (that have a PHP SDK) offer this in their libraries, e.g. Sendgrid and Twilio, it would be nice if HubSpot could do the same.
I've found the official library, which isn't hubspot/hubspot-php (this is a third party package) but hubspot/api-client - quite confusing as they are using the same namespace.
This library does indeed contain a verification method (Signature::isValid()), so I'd suggest for anybody that reads this, to just use the official library.
After days of painful debugging, the solution was: $request->fullUrl() reorders the GET parameters. But of course if you're hashing the URL, they need to be in exactly the same order. So (for Laravel) best to build the URL yourself.
I thought I had done this in tests, but I must've made a mistake. I revisited the code @tominal posted at the beginning and found this was the only difference. Thanks @tominal, I thought I was going insane 😅