Many thanks to @WendyGoh for helping me work towards a solution!
I may have missed this in the documentation, but Hubspot computes the signature BEFORE url-encoding the param values.
The reason that my signature computation was not correct was my handling of url-encoded parameters in the full request url hitting my server. There were a couple of specific details, though, that I was able to suss out.
One param hitting my server contained an email address. When taking the raw request to compute the signature, the ‘@’ was still encoded as ‘%40’. The email address value had to be url-decoded prior to computing the signature.
The other param that was causing trouble was a bit thornier. We have a custom property that contains a url as the value, with query params on that url. For demonstration purposes, the param value is received in the raw request url param as something like:
my_custom_prop=https:%2F%2Fmy.server.com%2Findex%2Fuid%2F123456789%3F___param%3Dvalue
Note that the entire param value is url-encoded. After url-decoding the value becomes:
https://my.server.com/index/uid/123456789?___param=value
Except this also fails the signature computation. The query params on this url property value need to remain url-encoded for the Hubspot signature computation to match. In the end this needed to be:
https://my.server.com/index/uid/123456789?___param%3Dvalue
Once I updated my code to ensure the query params of the Hubspot request were properly url-encoded and url-decoded as described above, the signature computation worked as expected.
This is a generally handy tool that I hadn’t come across before that @WendyGoh turned me on to: https://requestinspector.com/
Hope this helps someone in the future!