Skip to main content
Internal Documentation

HubSpot Forms Integration

← Back to Test Page

HubSpot Forms API Integration Guide

Version: 1.2 Last Updated: February 20, 2026 Status: Implemented

Table of Contents

  1. Form Inventory
  2. Overview
  3. Current vs. New Approach Comparison
  4. Architecture
  5. Prerequisites
  6. Form Gate Architecture — Where Forms Live
  7. Component Design
  8. Implementation Guide
  9. HubSpot Configuration
  10. Field Reference
  11. Security & Spam Protection
  12. Testing Guide
  13. Troubleshooting

Form Inventory

Account Details

Setting Value
Portal ID 2962673
Region na2
Tracking Script //js.hs-scripts.com/2962673.js (loaded in BaseLayout.astro)
Turnstile Site Key 0x4AAAAAACfNh7MSRArWaAC8
Central Config src/lib/hubspot.ts

Active Forms (6)

# Constant Form GUID Purpose Component Page(s)
1 TOOL_GATE ef0ab13c-e7ae-4d3c-ac33-598e056c8e49 Tool result gate (all 15 tool pages) HubSpotForm.astro / vanilla JS tools/am-i-a-target.astro + 14 more
2 RESOURCE_GATE 10e58860-c835-4b22-91b5-a6854b71648b PDF download gate for Learning Center ResourceGate.astro learn/resources.astro, learn/resources/[slug].astro
3 GENERAL_CYBERSECURITY e9594ea7-e618-44b6-8d57-95a1c7248e8d Assessment result collection HubSpotForm.astro assessment/business-technology.astro, assessment/ai-readiness.astro, assessment/consultation.astro
4 ASSESSMENT_NONPROFIT d338559e-f9bd-4985-9b50-b6260d06bd79 Nonprofit cybersecurity assessment HubSpotForm.astro assessment/cybersecurity.astro
5 CONTACT_SALES 2e205231-7952-46e3-936d-8c43042156ff Contact/Sales inquiry Legacy iframe embed contact.astro
6 378ef3e4-f655-49ac-ad1d-2c9dd3208d3f Newsletter footer signup Hardcoded fetch() Footer.astro

Defined but Not Active on Pages (6)

These form GUIDs are registered in hubspot.ts but not yet wired to any page:

# Constant Form GUID Purpose
7 ASSESSMENT_GENERAL 76f79cc6-8b28-4b6b-a13d-edead7720171 30-Min Assessment (General)
8 ASSESSMENT_LIFE_SCIENCE 9010ded8-9c89-47fa-a33d-cb741db6e768 30-Min Assessment (Life Science)
9 ASSESSMENT_HEALTHCARE 12957a4e-78ee-4e4d-a4fe-1aa1401d4a62 Healthcare IT Risk Assessment
10 ASSESSMENT_LIFE_SCIENCE_RISK 33215c2b-724f-4f51-8e19-a352ac595d66 Life Science IT Risk Assessment
11 MQL_LIFE_SCIENCE bf1b773c-65ac-42e4-ba2f-82581d7462a9 Life Science MQL
12 MQL_NONPROFIT ab82f70a-bf88-4d4f-a9ee-bb588974e58e Nonprofit MQL

Internal/Test Only (2)

# Form GUID Purpose File
13 1eea3e61-7467-46c1-9c05-4d11001b8ff6 Rewst integration test pages/internal/rewst-integration-test.astro
14 865595b6-067d-451a-be40-3dcf2466ad6d Rewst test (email only) pages/internal/rewst-integration-test.astro

Key Files

File Role
src/lib/hubspot.ts Central config — portal ID, form GUIDs, Turnstile key, utility functions
src/components/forms/HubSpotForm.astro Reusable form wrapper (Forms API + Turnstile + consent)
src/components/forms/FormField.astro Reusable field component
src/components/forms/ResourceGate.astro PDF download gate (modal + form + auto-download)
src/components/forms/TurnstileWidget.astro Cloudflare Turnstile CAPTCHA widget
src/data/resources.ts Metadata for all 26 resource PDFs (titles, types, segments, funnel stages)

Architecture Exceptions

Page Why Different
contact.astro Legacy HubSpot iframe embed — uses CONTACT_SALES form via js-na2.hsforms.net
Footer.astro Newsletter signup — hardcoded fetch() to Forms API, not using HubSpotForm.astro
tools/am-i-a-target.astro Vanilla JS form + API call — predates HubSpotForm.astro component

Resource PDFs

26 PDFs stored locally in the repo (NOT Cloudflare R2):

  • public/resources/ — healthcare, life sciences, general business PDFs
  • public/docs/ — financial services, nonprofit, life sciences ops PDFs

Full metadata in src/data/resources.ts — includes titles, types (white-paper, checklist, guide), segments (7 industries), funnel stages (upper, mid, lower), and PDF URLs.

Known Issues

Issue Priority Status
6 legacy WordPress PDF links broken in blog posts P2 NOT STARTED
1 HubSpot hubfs PDF should be migrated local P3 NOT STARTED
PDFs split across public/resources/ and public/docs/ P3 NOT STARTED — consolidate to single directory
Contact page uses legacy iframe embed P2 NOT STARTED — migrate to Forms API

Overview

Approach

Use HubSpot's Forms API (client-side) to submit form data directly from custom-styled Astro/Tailwind forms. This provides:

  • Full design control — Native Astro components with Tailwind styling
  • Native HubSpot features — Analytics, lifecycle stages, notifications, workflows
  • GDPR compliance — Consent recorded via legalConsentOptions payload
  • Spam protection — Cloudflare Turnstile integration
  • No backend required — Forms API is a public endpoint

What This Replaces

  • Current contact form (frontend-only, no submission handling)
  • Tool gate HubSpot embeds (150KB+ per form)

Performance Impact

Current (Embedded) New (Forms API)
~150KB per form ~50KB total site-wide
HubSpot styling conflicts Full Tailwind control
Multiple script loads Single tracking script

Current vs. New Approach Comparison

Current Tool Page Workflow

Step Current Implementation Status
1 Page loads — Astro serves static .astro page with BaseLayout ✅ Unchanged
2 External script loads — js.hsforms.net/forms/embed/v2.js (~150KB) 🔄 Removed
3 User completes the tool — fills in fields and clicks submit ✅ Unchanged
4 Tool section hides, form gate appears — JavaScript toggles divs ✅ Unchanged
5 HubSpot form renders — hbspt.forms.create() injects form 🔄 Changed
6 Hidden field injected — tool_name passed to HubSpot ✅ Aligned
7 User fills out the gate form — first name, email, etc. ✅ Unchanged
8 User submits — HubSpot handles submission, creates contact 🔄 Changed
9 Iframe height watcher detects submission — polling hack 🔄 Eliminated
10 Results display — JavaScript shows result section ✅ Unchanged
11 "Skip to results" option — bypass link ✅ Unchanged

Detailed Comparison

Step 2: Script Loading

Current New
js.hsforms.net/forms/embed/v2.js (~150KB) HubSpot tracking (~45KB) + Turnstile (~5KB)
Loads on every tool page Single tracking script site-wide
Blocking form render Non-blocking, async

Change: Remove HubSpot form embed script. Add HubSpot tracking script once in BaseLayout.astro. Add Turnstile only on pages with forms.


Step 5: Form Rendering

Current New
hbspt.forms.create({ portalId, formId, target }) Native <form> with Tailwind classes
HubSpot controls HTML structure Full control over markup
HubSpot CSS (often conflicts) Site design system
May use internal iframes No iframes

Change: Replace #hubspotForm div with <HubSpotForm> Astro component containing native form fields.


Step 6: Hidden Field (tool_name)

Current New
Passed via hbspt.forms.create({ ... }) options Passed in fields array to Forms API
HubSpot injects as hidden input Native <input type="hidden">

Alignment: Same outcome — tool_name property populated on contact. Implementation changes but behavior identical.

// Current
hbspt.forms.create({
  formId: 'xxx',
  hiddenFields: { tool_name: 'Am I a Target' },
});

// New
fields: [
  { name: 'email', value: formData.get('email') },
  { name: 'tool_name', value: 'am-i-a-target' }, // Same result
];

Step 8: Form Submission

Current New
HubSpot handles internally (opaque) fetch() to Forms API (transparent)
No direct success/error callback Promise resolves with status
Contact created via embed Contact created via API
Lifecycle stage via form config Lifecycle stage via form config ✅
Email notification via form config Email notification via form config ✅

Change: Submission via explicit fetch() call. Success/error handling in our control.


Step 9: Submission Detection — THE BIG FIX

Current New
setInterval every 300ms Direct API response
Check iframe offsetHeight response.ok boolean
Guess submission from height drop Explicit success/failure
Fragile, breaks if HubSpot changes Stable, API contract

Change: Eliminate height-watching hack entirely. API response tells us exactly when submission succeeded.

// Current (fragile)
setInterval(() => {
  if (iframe.offsetHeight < 250) {
    // probably submitted?
    showResults();
  }
}, 300);

// New (reliable)
const response = await fetch(hubspotApiUrl, { ... });
if (response.ok) {
  showResults();  // Definitive success
}

Fragile Parts — Resolution Status

Issue Current State New Approach Status
Height-watching hack Polling iframe height every 300ms to guess submission Direct API response with success/error Fixed
150KB external JS Loads on every tool page ~50KB total (tracking + Turnstile) Fixed
HubSpot controls HTML/CSS Form styling conflicts with site design Full Tailwind control Fixed

What Stays the Same

Feature Notes
Tool interaction flow User completes quiz → gate appears → results shown
"Skip to results" link Still available, bypasses form gate
Contact creation in HubSpot Same CRM outcome
Lifecycle stage assignment Configure in HubSpot form settings
Email notifications Configure in HubSpot form settings
UTM/campaign attribution Preserved via hutk cookie
Hidden field mapping tool_name still populates custom property

What Changes

Area Before After
Form embed method hbspt.forms.create() Native form + fetch() to API
Submission detection Height polling hack API response
Form styling HubSpot CSS Tailwind + site design system
Script payload ~150KB per page ~50KB site-wide
Spam protection HubSpot CAPTCHA Cloudflare Turnstile
GDPR consent HubSpot auto-inject Manual checkbox + API payload
Error handling Opaque (HubSpot manages) Explicit try/catch

Gap Analysis: Is Anything Not Addressed?

Current Behavior Addressed? Notes
Contact created in CRM ✅ Yes Forms API creates/updates contacts
Lifecycle stage set to Subscriber ✅ Yes Configure in HubSpot form editor
Email notification sent ✅ Yes Configure in HubSpot form editor
Hidden field tool_name captured ✅ Yes Passed in fields array
Thank-you message shown ✅ Yes We control success message (better)
"Skip to results" bypass ✅ Yes No change to this feature
Works on 2 of 15 tools ✅ Yes New approach applies to all 15
Height-based submission detection ✅ Yes Eliminated entirely
Form matches site design ✅ Yes Full Tailwind control

All current behaviors are addressed. No gaps identified.


Migration Path for Existing Tools

Tools Currently Using Embed (2 pages)

  1. Replace hbspt.forms.create() with <HubSpotForm> component
  2. Remove js.hsforms.net script tag
  3. Remove height-watching setInterval code
  4. Update showResults() to trigger on API success

Tools Not Yet Gated (13 pages)

  1. Add <HubSpotForm> component with toolName prop
  2. Wire up gate toggle logic (hide tool → show form → show results)

Architecture

┌─────────────────────────────────────────────────────────────────────┐
│  Browser                                                             │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │  HubSpotForm.astro Component                                    ││
│  │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐ ││
│  │  │ Tailwind Fields │  │ Turnstile CAPTCHA│  │ Consent Checkbox│ ││
│  │  └─────────────────┘  └─────────────────┘  └─────────────────┘ ││
│  │                              ↓                                  ││
│  │  ┌─────────────────────────────────────────────────────────────┐││
│  │  │ onSubmit → fetch() to HubSpot Forms API                     │││
│  │  └─────────────────────────────────────────────────────────────┘││
│  └─────────────────────────────────────────────────────────────────┘│
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │  HubSpot Tracking Script (hs-script-loader)                     ││
│  │  • Sets hutk cookie for visitor tracking                        ││
│  │  • Enables UTM/campaign attribution                             ││
│  └─────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────┐
│  HubSpot Forms API                                                   │
│  POST https://api.hsforms.com/submissions/v3/integration/submit/    │
│       {portalId}/{formGuid}                                          │
│                                                                      │
│  ✓ Creates/updates contact                                          │
│  ✓ Records GDPR consent                                             │
│  ✓ Triggers notifications                                           │
│  ✓ Sets lifecycle stage                                             │
│  ✓ Full analytics attribution                                       │
│  ✓ Fires workflows                                                  │
└─────────────────────────────────────────────────────────────────────┘

Prerequisites

1. HubSpot Account Setup

Item Required Where to Find
Portal ID Yes Settings → Account Setup → Account Defaults
Form GUID Per form Marketing → Forms → Form Details
Tracking Code Yes Settings → Tracking & Analytics → Tracking Code
Custom Properties If using hidden fields Settings → Properties

2. Cloudflare Turnstile Setup

Already configured — Site key is stored in src/lib/hubspot.ts:

export const TURNSTILE_SITE_KEY = '0x4AAAAAACfNh7MSRArWaAC8';

Configured domains:

  • centrexit.com (production)
  • www.centrexit.com
  • staging.centrexit.com
  • zealous-coast-0ed4f7610.2.azurestaticapps.net (Azure SWA prod)
  • zealous-coast-0ed4f7610-staging.centralus.2.azurestaticapps.net (Azure SWA staging)
  • localhost (local dev)

To manage: Cloudflare Dashboard → Turnstile → "centrexIT Website Forms"

Secret Key: Stored in Cloudflare (only needed for server-side verification)

3. Create Custom HubSpot Properties

For tool gate forms, create these custom contact properties:

Property Name Internal Name Type Group
Tool Name tool_name Single-line text Contact Information
Tool Completed Date tool_completed_date Date picker Contact Information

Form Gate Architecture — Where Forms Live

The standalone HTML tool source files (tool01–tool15) should NOT have HubSpot forms embedded. The form gate is implemented exclusively at the Astro page level via the HubSpotForm.astro component.

End-to-End Flow

User completes tool
  → Results calculated but hidden
  → HubSpotForm.astro renders gate form
      Fields: First Name, Last Name, Email, Company
      Hidden fields (auto-set): tool_name, tool_completed_date
  → fetch() POST to Forms API endpoint
      https://api.hsforms.com/submissions/v3/integration/submit/2962673/ef0ab13c-e7ae-4d3c-ac33-598e056c8e49
  → 200 OK
  → Results revealed

Validation

Validated end-to-end on Feb 17, 2026 with tool_name = "the-3am-test".


Component Design

File Structure

src/
├── components/
│   ├── forms/
│   │   ├── HubSpotForm.astro       # Reusable form wrapper
│   │   ├── FormField.astro         # Individual field component
│   │   ├── ResourceGate.astro      # PDF download gate (modal + form + download)
│   │   ├── ConsentCheckbox.astro   # GDPR consent component
│   │   └── TurnstileWidget.astro   # Spam protection component
│   └── ...existing components
├── layouts/
│   └── BaseLayout.astro            # Add HubSpot tracking script
└── lib/
    └── hubspot.ts                  # Form submission utilities

Component Props

HubSpotForm.astro

interface Props {
  formId: string; // HubSpot form GUID (required)
  portalId?: string; // HubSpot portal ID (defaults to centrexIT)
  redirectUrl?: string; // Optional redirect after success
  toolName?: string; // Hidden field for tool gates
  showConsent?: boolean; // Show GDPR consent (default: true)
  consentText?: string; // Custom consent message
  submitText?: string; // Submit button text (default: "Submit")
  successMessage?: string; // Custom success message
  successSubtitle?: string; // Success message subtitle
  onSuccess?: string; // JavaScript function to call on success
  enableTurnstile?: boolean; // Cloudflare Turnstile spam protection (default: true)
  formInstanceId?: string; // Unique ID for multiple forms on same page
  class?: string; // Additional CSS classes
}

Note: Turnstile is enabled by default. The site key is imported from hubspot.ts — no configuration needed per form.

ResourceGate.astro

interface Props {
  resourceName: string; // Display name (sent as resource_name hidden field)
  pdfUrl: string; // URL to the PDF file
  buttonText?: string; // Trigger/submit button text (default: "Download")
  class?: string; // Additional CSS classes for trigger button
}

Behavior:

  1. Renders a styled download button (trigger)
  2. On click, opens a modal overlay with the HubSpot form
  3. Uses RESOURCE_GATE form ID from hubspot.ts
  4. Passes resourceName as hidden resource_name field
  5. On successful submission, auto-triggers PDF download via pdfUrl
  6. Shows "Your download is ready." confirmation with manual download fallback link
  7. Includes Turnstile CAPTCHA, GDPR consent, and UTM tracking

Usage:

<ResourceGate
  resourceName="Beyond Compliance"
  pdfUrl="/downloads/centrexIT-Healthcare-Cyber-Threats-Beyond-Compliance.pdf"
  buttonText="Download Full PDF"
  class="bg-green-600 text-white px-6 py-3 rounded-lg font-bold"
>
  <Icon name="download" size={18} />
  Download Full PDF
</ResourceGate>

Implementation Guide

Step 1: Add HubSpot Tracking Script

File: src/layouts/BaseLayout.astro

Add before </body>:

<!-- HubSpot Tracking Code -->
<script
  type="text/javascript"
  id="hs-script-loader"
  async
  defer
  src="//js.hs-scripts.com/{YOUR_PORTAL_ID}.js"
></script>

Step 2: Create Form Utilities

File: src/lib/hubspot.ts

export interface HubSpotField {
  name: string;
  value: string;
}

export interface HubSpotSubmission {
  portalId: string;
  formId: string;
  fields: HubSpotField[];
  context: {
    hutk?: string;
    pageUri: string;
    pageName: string;
  };
  legalConsentOptions?: {
    consent: {
      consentToProcess: boolean;
      text: string;
      communications?: Array<{
        value: boolean;
        subscriptionTypeId: number;
        text: string;
      }>;
    };
  };
}

export function getHubSpotCookie(): string | undefined {
  if (typeof document === 'undefined') return undefined;
  const match = document.cookie.match(/hubspotutk=([^;]*)/);
  return match?.[1];
}

export async function submitToHubSpot(
  submission: HubSpotSubmission
): Promise<{ success: boolean; error?: string }> {
  const url = `https://api.hsforms.com/submissions/v3/integration/submit/${submission.portalId}/${submission.formId}`;

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fields: submission.fields,
        context: submission.context,
        legalConsentOptions: submission.legalConsentOptions,
      }),
    });

    if (!response.ok) {
      const errorData = await response.json();
      return {
        success: false,
        error: errorData.message || 'Submission failed',
      };
    }

    return { success: true };
  } catch (error) {
    return {
      success: false,
      error: 'Network error. Please try again.',
    };
  }
}

Step 3: Create HubSpotForm Component

File: src/components/forms/HubSpotForm.astro

Note: This component is already implemented. See the actual file for current code.

Key features:

  • Accepts formId (required) and optional props for customization
  • Turnstile spam protection enabled by default (enableTurnstile: true)
  • Site key imported from hubspot.ts — no per-form configuration needed
  • GDPR consent checkbox included by default
  • Supports onSuccess callback for tool gates

Basic usage:

---
import HubSpotForm from '../components/forms/HubSpotForm.astro';
import FormField from '../components/forms/FormField.astro';
import { HUBSPOT_FORMS } from '../lib/hubspot';
---

<HubSpotForm formId={HUBSPOT_FORMS.GENERAL_CYBERSECURITY} submitText="Send Message">
  <FormField name="email" label="Email" type="email" required />
  <FormField name="firstname" label="First Name" />
  <FormField name="message" label="Message" type="textarea" />
</HubSpotForm>

Tool gate usage:

<HubSpotForm
  formId={HUBSPOT_FORMS.TOOL_GATE}
  toolName="am-i-a-target"
  submitText="Get My Results"
  onSuccess="showResults"
>
  <FormField name="email" label="Email" type="email" required />
  <FormField name="firstname" label="First Name" />
</HubSpotForm>

Step 4: Usage Examples

Contact Page

---
import HubSpotForm from '../components/forms/HubSpotForm.astro';
import FormField from '../components/forms/FormField.astro';
import { HUBSPOT_FORMS } from '../lib/hubspot';
---

<HubSpotForm
  formId={HUBSPOT_FORMS.GENERAL_CYBERSECURITY}
  submitText="Send Message"
  successMessage="Thank you!"
  successSubtitle="We'll get back to you within one business day."
>
  <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
    <FormField name="firstname" label="First Name" required />
    <FormField name="lastname" label="Last Name" required />
  </div>
  <FormField name="email" label="Email" type="email" required />
  <FormField name="phone" label="Phone" type="tel" />
  <FormField name="company" label="Company" />
  <FormField name="message" label="Message" type="textarea" rows={5} required />
</HubSpotForm>

Note: Turnstile is included automatically. No turnstileSiteKey prop needed.

Tool Gate Form

---
import HubSpotForm from '../components/forms/HubSpotForm.astro';
import FormField from '../components/forms/FormField.astro';
import { HUBSPOT_FORMS } from '../lib/hubspot';
---

<HubSpotForm
  formId={HUBSPOT_FORMS.TOOL_GATE}
  toolName="am-i-a-target"
  submitText="Get My Results"
  successMessage="Check your email!"
  successSubtitle="Your personalized results are on the way."
  onSuccess="showResults"
  consentText="Send me my results and occasional cybersecurity tips."
>
  <FormField name="email" label="Email" type="email" required placeholder="your@email.com" />
  <FormField name="firstname" label="First Name" placeholder="Optional" />
</HubSpotForm>

Note: Use onSuccess to call a JavaScript function after submission (e.g., to show results section).


HubSpot Configuration

Create Forms in HubSpot

For each form type, create a form in HubSpot Marketing → Forms:

Contact Form

Setting Value
Form Name Website Contact Form
Lifecycle Stage Lead
Notification Your team email(s)
Follow-up Email Optional welcome/thank you

Tool Gate Form (create one, reuse for all tools)

Setting Value
Form Name Tool Gate - Generic
Lifecycle Stage Lead
Notification Marketing team
Required Fields Email only

Resource Download Gate Form

Setting Value
Form Name Resource Download Gate
Form GUID 10e58860-c835-4b22-91b5-a6854b71648b
Lifecycle Stage Lead
Notification Marketing team
Required Fields Email only
Optional Fields First Name, Last Name, Company
Hidden Fields resource_name, form_source, page_url, UTM parameters
Component ResourceGate.astro
Used On /learn/resources (listing), /learn/resources/[slug] (detail pages)

Configure Lifecycle Stages

In HubSpot form settings → Options:

  1. Select "Set lifecycle stage when a contact submits this form"
  2. Choose appropriate stage (e.g., Lead, Marketing Qualified Lead)

Configure Notifications

In HubSpot form settings → Options:

  1. Enable "Send submission notifications"
  2. Add recipient email addresses
  3. Customize notification template if needed

Field Reference

Standard HubSpot Properties

These field names auto-map to HubSpot contact properties:

Field Name HubSpot Property Type
email Email Required
firstname First Name Text
lastname Last Name Text
phone Phone Number Phone
company Company Name Text
jobtitle Job Title Text
website Website URL URL
address Street Address Text
city City Text
state State/Region Text
zip Postal Code Text

Custom Properties

For custom fields (like tool_name), first create the property in HubSpot:

  1. Settings → Properties → Create Property
  2. Set Internal Name (e.g., tool_name)
  3. Use exact internal name in form field name attribute

Security & Spam Protection

Cloudflare Turnstile

Turnstile provides invisible CAPTCHA protection:

<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onTurnstileSuccess"></div>

Configuration options:

Attribute Description
data-sitekey Your Turnstile site key
data-theme light, dark, or auto
data-size normal or compact
data-callback JS function on success
data-error-callback JS function on error

Rate Limiting

HubSpot has built-in rate limiting:

  • 10 requests per second per IP
  • 100 requests per minute per form

No additional configuration needed.

Input Validation

Client-side validation (HTML5 + JavaScript):

  • Required fields
  • Email format
  • Phone format (optional)
  • Max length limits

Server-side validation happens at HubSpot API level.


Testing Guide

Local Development

  1. Add test values to .env.local:
PUBLIC_HUBSPOT_PORTAL_ID=your-portal-id
PUBLIC_TURNSTILE_SITE_KEY=your-turnstile-key
  1. Start dev server:
npm run dev
  1. Test form submission with test contact email

Test Checklist

Test Expected Result
Submit with valid data Contact created in HubSpot
Submit without required field Validation error shown
Submit without consent Form blocked
Check HubSpot analytics Submission appears with source URL
Check lifecycle stage Contact has correct stage
Check notifications Email received by configured recipients
Test hidden fields tool_name property populated
Test UTM attribution Campaign data captured
Mobile responsive Form usable on mobile
Screen reader Accessible form experience

HubSpot Test Contact

Use HubSpot's test submission feature:

  1. Open form in HubSpot
  2. Click "Test" tab
  3. Submit test data
  4. Verify all configurations working

Troubleshooting

Common Issues

"Submission failed" error

Cause: Form GUID doesn't match a form in your portal

Fix:

  1. Verify formId matches the GUID in HubSpot form URL
  2. Check portal ID is correct
  3. Ensure form is published (not draft)

Consent not recorded

Cause: legalConsentOptions payload malformed

Fix:

  1. Verify consent checkbox is checked before submit
  2. Check consentToProcess: true is set
  3. Ensure text field has the consent message

UTM parameters not captured

Cause: HubSpot tracking cookie (hutk) not set

Fix:

  1. Verify tracking script is loaded
  2. Check for cookie blockers
  3. User may be new visitor (cookie sets on page load)

Turnstile not loading

Cause: Site key incorrect or domain not configured

Fix:

  1. Verify site key in Cloudflare dashboard
  2. Add domain to Turnstile allowed hosts
  3. Check for script loading errors in console

Hidden fields not populated

Cause: Property doesn't exist in HubSpot

Fix:

  1. Create custom property in HubSpot first
  2. Use exact internal name (not display name)
  3. Verify field is in fields array

Debug Mode

Add to form for debugging:

console.log('HubSpot Payload:', JSON.stringify(payload, null, 2));

Check browser console for:

  • Network requests to api.hsforms.com
  • Response status and body
  • Any CORS errors

Migration Status

Task Status
HubSpot tracking script in BaseLayout DONE
Cloudflare Turnstile site created DONE
hubspot.ts central config DONE
HubSpotForm.astro reusable component DONE
FormField.astro field component DONE
TurnstileWidget.astro CAPTCHA DONE
ResourceGate.astro PDF gate DONE
Tool gate: Am I a Target (Forms API) DONE
Assessment forms (3 pages) DONE
Newsletter footer form DONE
Contact page (legacy iframe) DONE — not yet migrated to Forms API
Resource download gate DONE — wired to /learn/resources
Tool gates: remaining 13 tools NOT STARTED — still ungated
Migrate contact page to Forms API NOT STARTED (P2)
Wire 6 segment forms to landing pages NOT STARTED (post-MVP)
Form submission tracking (GA4) NOT STARTED

Configuration Reference

All HubSpot and Turnstile configuration is centralized in:

File: src/lib/hubspot.ts

// Portal ID
export const HUBSPOT_PORTAL_ID = '2962673';

// Turnstile site key (managed in Cloudflare dashboard)
export const TURNSTILE_SITE_KEY = '0x4AAAAAACfNh7MSRArWaAC8';

// Form GUIDs - create forms in HubSpot Marketing → Forms
export const HUBSPOT_FORMS = {
  TOOL_GATE: 'ef0ab13c-e7ae-4d3c-ac33-598e056c8e49',
  RESOURCE_GATE: '10e58860-c835-4b22-91b5-a6854b71648b',
  GENERAL_CYBERSECURITY: 'e9594ea7-e618-44b6-8d57-95a1c7248e8d',
  ASSESSMENT_GENERAL: '76f79cc6-8b28-4b6b-a13d-edead7720171',
  ASSESSMENT_LIFE_SCIENCE: '9010ded8-9c89-47fa-a33d-cb741db6e768',
  ASSESSMENT_NONPROFIT: 'd338559e-f9bd-4985-9b50-b6260d06bd79',
  ASSESSMENT_HEALTHCARE: '12957a4e-78ee-4e4d-a4fe-1aa1401d4a62',
  ASSESSMENT_LIFE_SCIENCE_RISK: '33215c2b-724f-4f51-8e19-a352ac595d66',
  MQL_LIFE_SCIENCE: 'bf1b773c-65ac-42e4-ba2f-82581d7462a9',
  MQL_NONPROFIT: 'ab82f70a-bf88-4d4f-a9ee-bb588974e58e',
  CONTACT_SALES: '2e205231-7952-46e3-936d-8c43042156ff',
} as const;

Note: The HubSpotForm component imports these automatically. No env vars needed for client-side forms.


Appendix: HubSpot Forms API Reference

Endpoint

POST https://api.hsforms.com/submissions/v3/integration/submit/{portalId}/{formGuid}

Request Body

{
  "fields": [
    { "name": "email", "value": "user@example.com" },
    { "name": "firstname", "value": "John" }
  ],
  "context": {
    "hutk": "hubspot-tracking-cookie-value",
    "pageUri": "https://centrexit.com/contact",
    "pageName": "Contact Us"
  },
  "legalConsentOptions": {
    "consent": {
      "consentToProcess": true,
      "text": "I agree to receive marketing communications..."
    }
  }
}

Response

Success (200):

{
  "inlineMessage": "Thanks for submitting the form."
}

Error (400):

{
  "status": "error",
  "message": "The request is invalid",
  "errors": [
    {
      "message": "Required field 'email' is missing",
      "errorType": "VALIDATION_ERROR"
    }
  ]
}

Communications Preferences Center (Subscription API)

The /preferences page uses HubSpot's Communication Preferences v3 API — a separate, authenticated API from the public Forms API used everywhere else on the site.

Key Differences from Forms API

Forms API Communication Preferences API
Auth Public (no API key) Private app token required
Endpoint api.hsforms.com api.hubapi.com/communication-preferences/v3
Purpose Create/update contacts via form submission Manage email subscription opt-in/out
Client-side? Yes No — proxied through server-side API route

Architecture

Browser (preferences.astro)
  → POST /api/subscriptions (Astro SSR endpoint)
    → HubSpot Communication Preferences v3 API
      (Authorization: Bearer {HUBSPOT_PRIVATE_APP_KEY})

API Proxy Actions

File: src/pages/api/subscriptions.ts (server-rendered, prerender = false)

Action Description HubSpot Endpoint
get Fetch subscription statuses for an email GET /status/email/{email}
update Subscribe/unsubscribe individual types POST /subscribe or POST /unsubscribe
unsubscribe_all Unsubscribe from all types POST /unsubscribe (looped)

Request Format

{ "action": "get", "email": "user@example.com" }
{ "action": "update", "email": "user@example.com", "subscriptions": [{ "id": "123", "name": "Newsletter", "status": "subscribed" }] }
{ "action": "unsubscribe_all", "email": "user@example.com" }

Environment Variable

Variable Required Scope
HUBSPOT_PRIVATE_APP_KEY Yes Server-side only (never exposed to client)

Create a HubSpot private app with the communication_preferences.read_write scope. Add the token to the hosting environment (e.g., Azure SWA application settings or Cloudflare Pages env vars).

Files

File Role
src/pages/preferences.astro Preferences page UI (prerendered)
src/pages/api/subscriptions.ts API proxy endpoint (server-rendered)
src/components/Footer.astro Contains "Email Preferences" link

End of Design Document

Last updated: February 18, 2026 • Version 1.1

Stay in the loop

Get IT insights, security updates, and centrexIT news. No spam, no fluff.