CMS Development

reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Hi,

 

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.

 

reneconnhealth_0-1677520483626.png

 

---

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?

0 Upvotes
2 Accepted solutions
tominal
Solution
Guide | Partner
Guide | Partner

Verify Webhook Request with Laravel

SOLVE

Hey @reneconnhealth,

 

I posted here in this thread with a similar signature issue: https://community.hubspot.com/t5/APIs-Integrations/Workflow-webhook-signature-validation-sha256-prod...

 

Let me know if that doesn't work!


Thomas Johnson
Community Champion


Kahu Software LLC
A Texan HubSpot consulting firm
https://kahusoftware.com

View solution in original post

0 Upvotes
reneconnhealth
Solution
Member

Verify Webhook Request with Laravel

SOLVE

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.

 

[
    'httpUri' => config('app.url') . $request->getRequestUri(),
]

 

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 😅

View solution in original post

15 Replies 15
Cpanti1
Participant

Verify Webhook Request with Laravel

SOLVE
    public function handle(Request $request)
    {
        Log::debug('Hubspot WebHook');

        // Get the webhook payload
        $payload = $request->getContent();
        Log::debug($payload);

        //$body = file_get_contents('php://input');
        //Log::debug($body);

        //$headers = $request->header();
        //Log::debug($headers);

        // Verify the webhook call token
        /*
        $IOtoken = $request->header('X-IO-token');
        if ($IOtoken!==env('X_IO_token')) {
            return response()->json(['error' => 'Invalid X-IO-token'], 400);
        }
        */

        //https://developers.hubspot.com/docs/api/webhooks/validating-requests

        $secret = env('HS_CLIENT_SECRET');
        $expectedSignature='';

        $signature_version = strtoupper($request->header('X-HubSpot-Signature-Version'));

        // check timestamp
        $timestamp=$request->header('x-hubspot-request-timestamp');
        if (isset($timestamp)){
            $current_time_ms = round(microtime(true) * 1000);
            $time_diff_ms = abs($current_time_ms - $timestamp);
            // Convert the time difference to minutes
            $time_diff_min = $time_diff_ms / (1000 * 60);
            if ($time_diff_min  > 5) {
                // The request is too old
                Log::debug('Request too old');
                return response()->json(['error' => 'Request too old'], 400);
            }

            // The request is valid
            Log::debug('Timestamp OK');
        }

        $v3present=$request->header('X-HubSpot-Signature-V3');
        if (isset($v3present)){
            $signature_version='V3';
        }
        //Log::debug($signature_version);

        switch ($signature_version) {
            case 'V1':
                Log::debug('signature_version: V1');
                // verify webhook Signature V1
                $signature = $request->header('X-HubSpot-Signature');
                $expectedSignature = bin2hex(hash('sha256', $secret.$payload, true));
                break;
            case 'V2':
                Log::debug('signature_version: V2');
                $signature = $request->header('X-HubSpot-Signature');
                $httpMethod = strtoupper($request->method());

                $httpUri=$request->fullUrl();
                $httpUri=str_replace('http:','https:',$httpUri);

                $stringSource=utf8_encode($secret.$httpMethod.$httpUri.$payload);
                //Log::debug($stringSource);
                $expectedSignature = bin2hex(hash('sha256', $secret.$payload, true));
                //Log::debug($expectedSignature);
                break;
            case 'V3':
                Log::debug('signature_version: V3');
                $signature = $request->header('X-HubSpot-Signature-V3');

                $timestamp=$request->header('x-hubspot-request-timestamp');
                $httpMethod = strtoupper($request->method());

                $httpUri=$request->fullUrl();
                $httpUri=str_replace('http:','https:',$httpUri);

                $httpUri_decoded = str_replace(['%3A', '%2F', '%3F', '%40', '%21', '%24', '%27', '%28', '%29', '%2A', '%2C', '%3B'], [':', '/', '?', '@', '!', '$', "'", '(', ')', '*', ',', ';'], ($httpUri));

                $stringSource=utf8_encode($httpMethod.$httpUri_decoded.$payload.$timestamp);
                //Log::debug($stringSource);

                $expectedSignature = base64_encode(hash_hmac('sha256', $stringSource, $secret, true));
                //Log::debug($expectedSignature);
                break;
        }

        if (!hash_equals($expectedSignature, $signature)){
            Log::debug('Invalid signature');
            return response()->json(['error' => 'Invalid signature'], 400);
        }

        // Process the webhook event
        $payload = $request->all();
        Log::debug($payload);
    }
0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Thx @Cpanti1 but your code doesn't work either.

 

I copied / pasted your code, just to make sure I'm not insane and it also fails for GET requests.

 

reneconnhealth_0-1678110179411.png

 

0 Upvotes
Cpanti1
Participant

Verify Webhook Request with Laravel

SOLVE

Sorry @reneconnhealth . You are right. It doesn't work for GET. (CRM CARDS)
As far as I see the reguest header contains:

( x-hubspot-signature and 
x-hubspot-signature-version = V2)
and
x-hubspot-signature-v3.
 
However, none of them (v2 or V3) cannot be verified. I'm gonna look into it, because I also what to use the CRM cards.
 

 

0 Upvotes
Cpanti1
Participant

Verify Webhook Request with Laravel

SOLVE

Hi @reneconnhealth. I just solved it. You need to get the entire URL, including query string and anchor. Please replace:

$httpUri=$request->fullUrl();

with 

$httpUri= (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";

In my case I need to use 

$httpUri=str_replace('http:','https:',$httpUri);

because I'm using proxy redirect.

It works on both V2&V3 signatures.



0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Hi @Cpanti1 

 

https://laravel.com/docs/10.x/requests#retrieving-the-request-url

 

that's exactly what fullUrl() does and it changes nothing, still doesn't work

0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

@Cpanti1 $request->fullUrl() reorders the GET parameters, the way you've done it doesn't -> that was the solution, thank you 🙂

Cpanti1
Participant

Verify Webhook Request with Laravel

SOLVE

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.
 

0 Upvotes
tominal
Solution
Guide | Partner
Guide | Partner

Verify Webhook Request with Laravel

SOLVE

Hey @reneconnhealth,

 

I posted here in this thread with a similar signature issue: https://community.hubspot.com/t5/APIs-Integrations/Workflow-webhook-signature-validation-sha256-prod...

 

Let me know if that doesn't work!


Thomas Johnson
Community Champion


Kahu Software LLC
A Texan HubSpot consulting firm
https://kahusoftware.com
0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Hi @tominal,

 

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.

 

reneconnhealth_0-1677677194486.png

 

 

Can you tell if our PHP code is correct?

0 Upvotes
tominal
Guide | Partner
Guide | Partner

Verify Webhook Request with Laravel

SOLVE

Hey @reneconnhealth,

 

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.

 

Hope that helps!


Thomas Johnson
Community Champion


Kahu Software LLC
A Texan HubSpot consulting firm
https://kahusoftware.com
0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Hi  @tominal

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.

 

 

strval('1677745533245') == 1677745533245 // true 

 

---

 

The concatenated string should be:

requestMethod + requestUri + requestBody + timestamp

 

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?

 

Thanks for your help!

 

 

 

 

0 Upvotes
tominal
Guide | Partner
Guide | Partner

Verify Webhook Request with Laravel

SOLVE

Hey @reneconnhealth,

 

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.


Thomas Johnson
Community Champion


Kahu Software LLC
A Texan HubSpot consulting firm
https://kahusoftware.com
0 Upvotes
reneconnhealth
Member

Verify Webhook Request with Laravel

SOLVE

Thanks for the suggestions @tominal 

 

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.

0 Upvotes
reneconnhealth
Solution
Member

Verify Webhook Request with Laravel

SOLVE

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.

 

[
    'httpUri' => config('app.url') . $request->getRequestUri(),
]

 

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 😅

Jaycee_Lewis
Community Manager
Community Manager

Verify Webhook Request with Laravel

SOLVE

Hi, @reneconnhealth 👋 Thank you for including your details. Hey, @tominal @Teun @stefen, do you have any PHP-related insight you can share?

 

Thank you very much for looking! — Jaycee

linkedin

Jaycee Lewis

Developer Community Manager

Community | HubSpot

0 Upvotes