AshHooks.Provider.HubSpotV3 (AshHooks v1.0.2)

Copy Markdown View Source

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 <> query

The 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

decode_request_uri(uri)

@spec decode_request_uri(String.t()) :: String.t()

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).