Lead Capture

HubSpot & lead capture

White Tree Digital captures leads two ways, and they share zero code: the /contact form is a HubSpot embed pasted into Sanity and rendered as-is, while /free-website-audit posts JSON straight to HubSpot's Forms v3 API from the browser.

Both feed the same HubSpot portal (246233118, in HubSpot's NA2 region), and both depend on contact properties + forms that live entirely in the HubSpot dashboard — the repo's code is fully wired but inert until that dashboard setup is done. The end-to-end dashboard runbook is _HUBSPOT-SETUP.md; this page documents the code side and the contract between them.


Two lead paths

There are exactly two HubSpot forms, used by two completely different mechanisms:

MechanismWhere it lives
Form B — "Contact — Book a call"Visible HubSpot embed snippet, pasted into a Sanity ctaSection and rendered via set:html/contact page (a Sanity page doc)
Form A — "Website Audit Tool"Invisible API target — the audit widget POSTs to the Forms v3 API/free-website-audit

Form A's GUID goes in .env as PUBLIC_HUBSPOT_AUDIT_FORM_ID; it is never embedded anywhere. Form B's embed snippet is pasted into Studio. The rules that apply to either form: ≤5 fields on lead-capture forms (≤3 in hero forms), a real <label> on every input, and action-led submit copy ("Send message", "Request proposal", "Book a call") — never "Submit". HubSpot's own tracking and banner scripts load via GTM only; see Deployment and the analytics notes in Security.


Path 1 — the /contact embed

/contact is an ordinary Sanity page document. One of its sections is a ctaSection running in embed mode: the editor pastes the HubSpot form's <script …> snippet into the section's embedCode field in Studio, and the website renders it verbatim. There is no code involvement to change the form — swapping forms is a Studio edit. See Section types for the ctaSection two-column toggle, and Visual editing for the draft-mode mechanics referenced below.

The render path lives in CtaSection.astro. The embed string is stegaCleaned before it reaches set:html:

// stegaClean: in draft preview the embed string carries invisible stega chars
// that can land inside the pasted <script> source and break the form embed
// exactly where the client QAs it (Presentation Tool).
const cleanEmbedCode = embedCode ? stegaClean(embedCode) : embedCode;
const useEmbed = Boolean(twoColumnLayout) && Boolean(embedMode) && Boolean(cleanEmbedCode);

Stega breaks pasted embeds

In draft/preview mode the sanityPreview client encodes every string with invisible Unicode tag characters for click-to-edit. Those characters can land inside the pasted <script> source and break the embed in exactly the place an editor QAs it. Always stegaClean() the embed before set:html. This is the same footgun documented in Visual editing — invisible in production, only surfaces in Presentation Tool.

Styling for pasted embeds is light by design — default .cta-embed rules live in CtaSection.astro so a raw HubSpot (or Tally) form inherits sane spacing without per-form CSS.


Path 2 — the audit Forms v3 submission

The /free-website-audit tool (audit overview) ends with an email gate, then posts the lead directly to HubSpot's unauthenticated Forms v3 endpoint. All of this lives in website/src/components/audit/hubspot.ts.

Portal & form IDs

The submit reads two public build-time env vars (import.meta.env):

  • PUBLIC_HUBSPOT_PORTAL_ID — the portal (246233118)
  • PUBLIC_HUBSPOT_AUDIT_FORM_ID — Form A's GUID

Both are PUBLIC_* because the HubSpot submit is client-side; they're public IDs, not secrets. See Environment variables for the full env layout.

Never put real IDs or secrets in the docs

Document the env-var names and their purpose only. The portal ID is a non-secret public identifier; the form GUID is generated per-form in HubSpot. Never copy a real deploy-hook URL, read token, or PSI key into source or docs.

The six audit contact properties

The body posts the visitor's email plus six custom contact properties. These names are sent verbatim and must exist as fields on Form A in HubSpot or the submission is rejected:

const body = {
  fields: [
    {name: 'email', value: email},
    {name: 'audited_url', value: siteUrl},
    {name: 'audit_score', value: teaser.score.toFixed(1)},
    {name: 'audit_total_issues', value: String(teaser.totalIssues)},
    {name: 'audit_finding_1', value: f[0]?.headline ?? ''},
    {name: 'audit_finding_2', value: f[1]?.headline ?? ''},
    {name: 'audit_finding_3', value: f[2]?.headline ?? ''},
  ],
  context: {
    hutk: getHubspotCookie(),
    pageUri: window.location.href,
    pageName: document.title,
  },
};

The six custom properties — exact internal names — are audited_url, audit_score, audit_total_issues, audit_finding_1, audit_finding_2, audit_finding_3. The context.hutk is the HubSpot hubspotutk tracking cookie (read by getHubspotCookie()), which ties the submission to the visitor's session for source attribution; it's set by the HubSpot tracking pixel via GTM and degrades gracefully when absent.

Only teaser data is submitted

By design, only teaser-level data crosses the wire — the score, the issue count, and the three finding headlines. The gated per-check report (statuses, values, fixes, business-cost copy) never reaches the client, so it can't be submitted here either. The richer report is Phase 2. This mirrors the audit API's own contract that "only the teaser shape leaves the Worker" (src/pages/api/audit.ts). See Audit checks & scoring for the teaser shape.

Fail-fast on missing IDs

Before posting, the submit hard-fails if either ID is unset — otherwise a misconfigured build silently posts to /undefined/undefined and drops every lead:

export async function submitToHubspot(args: SubmitArgs): Promise<void> {
  // Fail fast and loud on missing config — without this guard a misconfigured
  // build posts to /undefined/undefined and quietly drops every lead.
  if (!import.meta.env.PUBLIC_HUBSPOT_PORTAL_ID || !import.meta.env.PUBLIC_HUBSPOT_AUDIT_FORM_ID) {
    console.error(
      '[hubspot] PUBLIC_HUBSPOT_PORTAL_ID / PUBLIC_HUBSPOT_AUDIT_FORM_ID not set — lead not submitted',
    );
    throw new Error('hubspot-env-missing');
  }
  // …
}

The NA2 region retry

The portal is in HubSpot's NA2 region, and the docs are ambiguous about whether the unauthenticated Forms v3 submit needs the regional host. So the endpoint self-heals: it tries api.hsforms.com first, and a 404 (the wrong-region signature) retries once against api-na2.hsforms.com.

const FORMS_BASES = ['https://api.hsforms.com', 'https://api-na2.hsforms.com'];

try {
  await postOnce(args, FORMS_BASES[0]);
} catch (err) {
  if (err instanceof HubspotHttpError) {
    if (err.status === 404) return postOnce(args, FORMS_BASES[1]); // wrong region
    throw err;
  }
  return postOnce(args, FORMS_BASES[0]); // network error — one retry
}

The retry policy is deliberately narrow:

  • 404 → retry against the NA2 host (wrong-region signal).
  • Network error (the fetch itself threw) → retry once against the same host.
  • Any other 4xx → throw, never retry. A non-404 4xx means HubSpot received and rejected the payload (e.g. a 400 field-name mismatch on Form A), so retrying would only double-submit.

A 400 is a Form A field mismatch — don't retry it

If the audit submit errors, open DevTools → Network → the hsforms.com request. A 404 is the region endpoint (the code already retries it). A 400 means a field name on Form A doesn't match one of the six audit_* properties — fix it on the form, don't retry in code. Retrying a 400 would double-submit the lead.


The explorer → contact mapping contract

The two explorer sections (heroExplorerSection, servicesExplorerSection) let a visitor check service interests, then carry those selections to /contact via the CTA URL. This is a pure query-string contract — HubSpot auto-populates the contact form natively from the URL, with no website code on the receiving end.

How the URL is built

When services are checked, the explorer appends them to the CTA href as ?<queryParam>=<value;value> — semicolon-separated, each value individually URL-encoded so the separator stays literal:

// Semicolon-separated per HubSpot's multi-checkbox query-string format;
// values encoded individually so the separator stays literal.
const list = checked
  .map((c) => c.dataset.value ?? '')
  .filter(Boolean)
  .map(encodeURIComponent)
  .join(';');
const sep = hrefBase.includes('?') ? '&' : '?';
ctaEl.setAttribute('href', `${hrefBase}${sep}${queryParam}=${list}`);

A two-service selection produces, for example, /contact?interested_services=web-design;seo.

The four parts of the contract

HubSpot's native query-string pre-fill works iff all four hold:

  1. The Sanity queryParam equals the HubSpot property's internal name — it defaults to interested_services.
  2. Each value exactly matches an option value on that HubSpot multi-checkbox property.
  3. The values come from one source: the value slugs on the shared explorerService docs ("Service Explorer" in Studio). Every explorer instance dereferences the same list, so the contract can't drift per-section.
  4. The format is HubSpot's semicolon-separated multi-checkbox query string.

The canonical slugs (consolidated June 2026) are web-design, seo, cro, branding. The explorerService.value field's own description states the contract:

Sent to the contact form as ?interested_services=<value>. Slug-cased, e.g.
"web-design". Must exactly match an option value on the HubSpot
"interested_services" property — when adding an item here, add the matching
option in HubSpot or the checkbox won't pre-fill.

A two-system contract — unmatched slugs silently no-prefill

Adding a Service Explorer item in Studio means adding the matching option value on the HubSpot interested_services property. If a slug has no matching HubSpot option, that checkbox simply doesn't pre-check — no error, no warning. HubSpot does record the submission page URL (with its raw query string), so the raw selection survives even when an option is missing, but the visible pre-fill is lost. Setup steps: _HUBSPOT-SETUP.md §5.

See Section types for the explorer sections and Content model for the explorerService document.


The disposable-email blocklist

The audit's email gate blocks throwaway / disposable providers only — never free providers like gmail.com, yahoo.com, or icloud.com (those are legitimate leads). This intentionally overrides the original spec's "no email checking" non-goal at the user's explicit request. The logic lives in website/src/components/audit/disposable.ts.

The blocklist is a 2.4 MB JSON (the disposable-email-domains package). Importing it statically would inline it into the client:load island and ship it to every visitor on first paint, so it loads as a lazy async chunk instead, warmed when the email gate appears:

// Warm the disposable-domain chunk while the visitor reads their teaser so
// the submit-time check is instant.
useEffect(() => {
  if (teaser) preloadDisposableList();
}, [teaser]);

The check fails open — a chunk-load failure must never block a real lead:

export async function isDisposableEmail(email: string): Promise<boolean> {
  const at = email.lastIndexOf('@');
  if (at < 0) return false;
  const host = email.slice(at + 1).trim().toLowerCase();
  // Fail open: a chunk-load failure must never block a real lead.
  const set = await loadDomains().catch(() => null);
  return set ? set.has(host) : false;
}

Never import the blocklist statically

A static import 'disposable-email-domains' ships the full 2.4 MB list to every visitor on first paint and blows the page-weight budget. Keep it a dynamic import() behind loadDomains(), preloaded only when the email gate appears. See Performance budgets.

On the HubSpot side, this client-side check is why Form A leaves "Block free email providers" OFF — that HubSpot setting would reject Gmail/Yahoo (real leads); disposable blocking is handled here in the tool.


Hydration & CAPTCHA notes

  • Both audit <form>s carry suppressHydrationWarning. HubSpot's collected-forms script stamps data-hs-cf-bound on form elements before React hydrates, which would otherwise trip a hydration mismatch warning.
  • Form A (audit API target) must have CAPTCHA OFF. The widget submits via the Forms API, and a CAPTCHA-enabled form silently rejects API submissions. Bot pressure is handled by the disposable-email check plus the per-IP rate limit on /api/audit (see Audit overview).
  • Form B (the /contact embed) can keep CAPTCHA ON. It's a normal browser embed, not an API target, and query-string pre-fill is unaffected by CAPTCHA.

Where this lives

ConcernFile
Forms v3 submit, IDs, fail-fast, NA2 retrywebsite/src/components/audit/hubspot.ts
Disposable-email lazy chunk (fails open)website/src/components/audit/disposable.ts
Teaser shape (the only data submitted)website/src/components/audit/teaser.ts
Audit widget (hydration, chunk preload)website/src/components/audit/WebsiteAudit.tsx
Audit API route (teaser-only response)website/src/pages/api/audit.ts
/contact embed render (stegaClean → set:html)website/src/components/sections/CtaSection.astro
Explorer CTA URL builder (semicolon format)website/src/components/sections/ServicesExplorerSection.astro, HeroExplorerSection.astro
Canonical service-interest list (slug = HubSpot contract)studio/schemaTypes/explorerService.ts
Dashboard runbook (properties, forms, workflow, meetings)_HUBSPOT-SETUP.md
Lead-capture status & open itemsTODO.md (§ Lead capture, § Free Website Audit Tool)
Forms rules, two paths, mapping contractwebsite/CLAUDE.md (§ Forms (HubSpot), § Free Website Audit funnel)

Related pages: Audit overview · Audit checks & scoring · Section types · Visual editing · Environment variables.

Previous
Checks, scoring & teaser