HubSpot Forms API Integration Guide
Version: 1.2 Last Updated: February 20, 2026 Status: Implemented
Table of Contents
- Form Inventory
- Overview
- Current vs. New Approach Comparison
- Architecture
- Prerequisites
- Form Gate Architecture — Where Forms Live
- Component Design
- Implementation Guide
- HubSpot Configuration
- Field Reference
- Security & Spam Protection
- Testing Guide
- 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 PDFspublic/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
legalConsentOptionspayload - 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)
- Replace
hbspt.forms.create()with<HubSpotForm>component - Remove
js.hsforms.netscript tag - Remove height-watching
setIntervalcode - Update
showResults()to trigger on API success
Tools Not Yet Gated (13 pages)
- Add
<HubSpotForm>component withtoolNameprop - 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.comstaging.centrexit.comzealous-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:
- Renders a styled download button (trigger)
- On click, opens a modal overlay with the HubSpot form
- Uses
RESOURCE_GATEform ID fromhubspot.ts - Passes
resourceNameas hiddenresource_namefield - On successful submission, auto-triggers PDF download via
pdfUrl - Shows "Your download is ready." confirmation with manual download fallback link
- 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
onSuccesscallback 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
turnstileSiteKeyprop 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
onSuccessto 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:
- Select "Set lifecycle stage when a contact submits this form"
- Choose appropriate stage (e.g., Lead, Marketing Qualified Lead)
Configure Notifications
In HubSpot form settings → Options:
- Enable "Send submission notifications"
- Add recipient email addresses
- 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 |
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:
- Settings → Properties → Create Property
- Set Internal Name (e.g.,
tool_name) - Use exact internal name in form field
nameattribute
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
- Add test values to
.env.local:
PUBLIC_HUBSPOT_PORTAL_ID=your-portal-id
PUBLIC_TURNSTILE_SITE_KEY=your-turnstile-key
- Start dev server:
npm run dev
- 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:
- Open form in HubSpot
- Click "Test" tab
- Submit test data
- Verify all configurations working
Troubleshooting
Common Issues
"Submission failed" error
Cause: Form GUID doesn't match a form in your portal
Fix:
- Verify
formIdmatches the GUID in HubSpot form URL - Check portal ID is correct
- Ensure form is published (not draft)
Consent not recorded
Cause: legalConsentOptions payload malformed
Fix:
- Verify consent checkbox is checked before submit
- Check
consentToProcess: trueis set - Ensure
textfield has the consent message
UTM parameters not captured
Cause: HubSpot tracking cookie (hutk) not set
Fix:
- Verify tracking script is loaded
- Check for cookie blockers
- User may be new visitor (cookie sets on page load)
Turnstile not loading
Cause: Site key incorrect or domain not configured
Fix:
- Verify site key in Cloudflare dashboard
- Add domain to Turnstile allowed hosts
- Check for script loading errors in console
Hidden fields not populated
Cause: Property doesn't exist in HubSpot
Fix:
- Create custom property in HubSpot first
- Use exact internal name (not display name)
- Verify field is in
fieldsarray
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