HubSpot v3 webhook verifier: HMAC-SHA256 over the concatenated canonical
string requestMethod + requestUri + requestBody + timestamp (no
separators), keyed with the app's client secret, base64-encoded — carried
in the X-HubSpot-Signature-v3 header, with the millisecond timestamp in
a SEPARATE X-HubSpot-Request-Timestamp header (verified against the
vendor docs first-hand 2026-08-21; the acceptance vector is the docs
page's own Java example, quoted in
test/ash_hooks/provider/hub_spot_v3_test.exs). Sign the raw body bytes
exactly as received and reconstruct the signed URI caller-side — Plug's
conn.query_string excludes the ?, and conn.host excludes a
non-default port, so join explicitly:
query = if conn.query_string == "", do: "", else: "?" <> conn.query_string
"https://" <> conn.host <> conn.request_path <> queryThe provider then decodes the vendor's documented percent-encodings via
decode_request_uri/1. Behind a TLS-terminating proxy the host (and any
non-default port) must be the public values HubSpot called.
Replay window: the vendor's validation step 1 — reject a timestamp older
than five minutes — is enforced BY DEFAULT (300 seconds, TWO-SIDED:
|now - ts| <= window, so a far-future timestamp fails closed too,
symmetric with AshHooks.Signing.verify's tolerance; the vendor's
"reject if older" wording is a floor, and stricter is conformant). An
inbound's replay_window_seconds overrides the default in either
direction. Setting it ABOVE 300 weakens replay protection; the vendor
default means a bare declaration is already safe.
parse_event_type/1: HubSpot delivers a top-level ARRAY of event objects
(under 100, eventId explicitly not guaranteed unique, duplicates
possible per event — the ledger's default content-digest identity dedupes
byte-identical redeliveries). A homogeneous batch maps to its
subscriptionType's atom via the 41-entry vendor-documented allowlist; a
mixed batch of known types maps to :mixed (fan out per event
consumer-side); an unknown type string anywhere in the batch fails closed
as {:error, :unknown_event_type} and lands failed_permanent in the
ledger — recorded and auditable. Extend @subscription_types (one module
attribute) when HubSpot publishes new types.
The optional secret callbacks are deliberately NOT implemented: HubSpot
signs with the app-wide client secret — supply it through the DSL
secret source. timestamp_header/0 IS implemented (the replay window
hangs off it).
Summary
Functions
Decodes the vendor's documented percent-encodings in a signed request URI
(%3A %2F %3F %40 %21 %24 %27 %28 %29 %2A %2C %3B, either hex case) —
HubSpot signs the DECODED form, so the verifier must decode before
hashing. The query-separator ? itself is unaffected (only its encoded
%3F form decodes).
Functions
Decodes the vendor's documented percent-encodings in a signed request URI
(%3A %2F %3F %40 %21 %24 %27 %28 %29 %2A %2C %3B, either hex case) —
HubSpot signs the DECODED form, so the verifier must decode before
hashing. The query-separator ? itself is unaffected (only its encoded
%3F form decodes).