# HubSpot Integration Guide

**Last Updated:** 2026-02-07

Comprehensive guide for integrating HubSpot Forms API v3 and CRM API into Ordio's lead capture and form submission workflows.

> **Note:** For complete API reference documentation, see:
>
> - **[HubSpot API Reference](systems/apis/HUBSPOT_API_REFERENCE.md)** - Complete reference for all 9 endpoints
> - **[Form Configuration Reference](systems/forms/FORM_CONFIGURATION_REFERENCE.md)** - Form GUIDs and field mappings
> - **[Form-to-Page Mapping](systems/forms/FORM_TO_PAGE_MAPPING.md)** - Which endpoints are used where

## Table of Contents

1. [Overview](#overview)
2. [Forms API v3 vs CRM API Decision Tree](#forms-api-v3-vs-crm-api-decision-tree)
3. [Forms API v3 Integration](#forms-api-v3-integration)
4. [CRM API Integration](#crm-api-integration)
5. [Helper Functions](#helper-functions)
6. [JavaScript Integration](#javascript-integration)
7. [Testing Checklist](#testing-checklist)
8. [Troubleshooting](#troubleshooting)

## Overview

Ordio uses HubSpot for lead capture, contact management, and marketing attribution. This guide covers best practices for integrating HubSpot APIs into PHP endpoints and JavaScript frontend code.

### Key Concepts

- **Forms API v3:** Preferred for form submissions, preserves activity history, links to browser sessions
- **CRM API:** Used for contact updates, fine-grained property control, fallback option
- **hubspotutk (hutk):** Critical cookie/token that links form submissions to browser sessions
- **Context Fields:** pageUri, pageName, ipAddress, hutk - essential for proper attribution

## Forms API v3 vs CRM API Decision Tree

### Use Forms API v3 When:

- ✅ Creating new contacts from form submissions
- ✅ Need to preserve activity history (page views, sessions)
- ✅ Want automatic workflow triggers
- ✅ Need to link submissions to existing browser sessions
- ✅ Form submission with standard fields

### Use CRM API When:

- ✅ Updating existing contacts (by ID or email)
- ✅ Need fine-grained control over contact properties
- ✅ Forms API v3 is not available or fails
- ✅ Complex contact relationships or custom objects

### Hybrid Approach (Recommended for Webinars):

- Use Events API to track page views BEFORE contact creation
- Use CRM API to create/update contact
- Use Events API to track page views AFTER contact creation

## Forms API v3 Integration

### Endpoints

HubSpot Forms API v3 provides two endpoints:

**1. Public Endpoint (No Authentication Required):**

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

- **Use when:** No server access to set environment variables, client-side submissions, or when authentication is not available
- **Rate limit:** 50 requests per 10 seconds
- **CORS:** Supported (can be called from browser)
- **Examples:** `collect-lead.php`, `shiftops-hubspot.php`, `include_email_form.php`

**2. Secure Endpoint (Authentication Required):**

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

- **Use when:** Server-side submissions with API token available, need higher rate limits (100-200 requests/10 seconds)
- **Rate limit:** 100-200 requests per 10 seconds (depending on account tier)
- **CORS:** Not supported (server-side only)
- **Examples:** `lead-capture.php`, `submit-template.php`, `addon-request.php`

### Required Headers

**For Public Endpoint:**

```php
[
    'Content-Type: application/json',
    'Accept: application/json'
]
```

**For Secure Endpoint:**

```php
[
    'Content-Type: application/json',
    'Authorization: Bearer ' . HUBSPOT_API_TOKEN,
    'Accept: application/json'
]
```

### Request Structure

```php
$hubspotData = [
    "submittedAt" => round(microtime(true) * 1000), // Milliseconds timestamp
    "fields" => [
        [
            "name" => "email",
            "value" => $email
        ],
        // ... more fields ...
    ],
    "context" => array_filter([
        "hutk" => !empty($hubspotutk) ? $hubspotutk : null,
        "ipAddress" => $_SERVER['REMOTE_ADDR'] ?? null,
        "pageUri" => getActualPageUrl($page_url, $referrer),
        "pageName" => getPageNameFromUrl($page_url, $defaultPageName)
    ])
];
```

### Critical Implementation Steps

#### 1. Extract hubspotutk (Cookie-First Pattern)

```php
// CRITICAL: Prioritize cookie over input parameter
$hubspotutk = isset($_COOKIE['hubspotutk']) ? $_COOKIE['hubspotutk'] : (!empty($input['hubspotutk']) ? trim($input['hubspotutk']) : '');
if (!empty($hubspotutk)) {
    $utmSource = isset($_COOKIE['hubspotutk']) ? 'cookie' : 'input';
    error_log("filename.php - hubspotutk source: $utmSource");
}
```

**Why:** Cookie is more reliable because it's set by HubSpot tracking code and persists across page navigation.

#### 2. Include Complete Context

```php
"context" => array_filter([
    "hutk" => !empty($hubspotutk) ? $hubspotutk : null, // CRITICAL for session tracking
    "ipAddress" => $_SERVER['REMOTE_ADDR'] ?? null, // For geolocation
    "pageUri" => getActualPageUrl($page_url, $referrer), // Conversion page
    "pageName" => getPageNameFromUrl($page_url, "Page Name") // Human-readable name
])
```

**Why each field matters:**

- `hutk`: Links form submission to browser session, enables activity tracking
- `ipAddress`: Helps with geolocation and fraud detection
- `pageUri`: Tracks conversion page for attribution
- `pageName`: Human-readable page name for reporting

#### 3. Implement Retry Logic

```php
$maxRetries = 3;
$retryDelay = 1; // Start with 1 second

for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
    $ch = curl_init();
    // ... curl setup ...

    $result = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlError = curl_error($ch);
    curl_close($ch);

    error_log("filename.php - Forms API v3 Attempt $attempt/$maxRetries: HTTP $httpCode");

    // Check for HTTP 200 OR 204 (success)
    if ($result !== false && ($httpCode === 200 || $httpCode === 204)) {
        if ($attempt > 1) {
            error_log("filename.php - Forms API v3 succeeded on attempt $attempt");
        }
        break;
    }

    // Exponential backoff: 1s, 2s, 4s
    if ($attempt < $maxRetries) {
        sleep($retryDelay);
        $retryDelay *= 2;
        error_log("filename.php - Forms API v3 failed (HTTP $httpCode), retrying in {$retryDelay}s...");
    }
}
```

**Why:** HubSpot API can experience transient failures. Retry logic significantly improves submission success rate.

### Example: Complete Forms API v3 Submission

See `v2/api/collect-lead.php`, `v2/api/submit-template.php`, `v2/api/addon-request.php`, `v2/api/export-workdays.php`, `v2/api/shiftops-nps.php` for complete implementations.

## CRM API Integration

### When to Use CRM API

- Updating existing contacts
- Forms API v3 fallback
- Webinar registrations (with Events API for page views)

### Endpoint

```
POST https://api.hubapi.com/crm/v3/objects/contacts
PUT https://api.hubapi.com/crm/v3/objects/contacts/{contactId}
```

### Page View Tracking with Events API

When using CRM API, track page views separately:

```php
// Track page view BEFORE contact creation (if hubspotutk exists)
if (!empty($hubspotutk)) {
    trackPageViewBeforeCreation($hubspotutk, $pageUrl, $utmParams);
}

// Create contact via CRM API
$contactId = createHubSpotContact($data);

// Track page view AFTER contact creation (using contact ID)
if (!empty($contactId)) {
    trackPageViewAfterCreation($contactId, $email, $pageUrl, $utmParams, $hubspotutk);
}
```

**Why:** CRM API doesn't automatically track page views like Forms API v3 does.

### Example: CRM API with Events API

See `v2/api/webinar-registration.php`, `v2/api/payroll-webinar-registration.php` for complete implementations.

## Helper Functions

### Location

`v2/helpers/hubspot-context.php`

### Functions

#### `getActualPageUrl($pageUrl, $referrer, $logPrefix = null)`

Resolves the actual page URL for HubSpot context, never returning API file URLs.

**Priority order:**

1. `$pageUrl` from input (if valid URL)
2. `$referrer` from input (if valid URL)
3. `$_SERVER['HTTP_REFERER']` (if valid URL and not API file)
4. `null` (never use API file URL)

**Usage:**

```php
require_once __DIR__ . '/../helpers/hubspot-context.php';

$pageUri = getActualPageUrl($page_url, $referrer, 'collect-lead.php');
```

#### `getPageNameFromUrl($pageUrl, $defaultPageName, $stripExtensions = true)`

Extracts a readable page name from URL for HubSpot pageName context.

**Usage:**

```php
$pageName = getPageNameFromUrl($page_url, "Default Page Name");
// Returns: "Tools Arbeitstage Rechner" from "/v2/pages/tools_arbeitstage_rechner.php"
```

**Features:**

- Strips file extensions (.php, .html)
- Converts dashes/underscores to spaces
- Handles common page name mappings
- Returns default if URL is empty or invalid

### Migration from Duplicate Functions

Several API files have their own implementations. Consider migrating to shared utility:

**Files with duplicate functions:**

- `v2/api/collect-lead.php`
- `v2/api/submit-template.php`
- `v2/api/addon-request.php`
- `v2/api/export-workdays.php`
- `v2/api/shiftops-hubspot.php`
- `v2/api/lead-capture.php`

**Migration pattern:**

```php
// Before
function getActualPageUrl($pageUrl = '', $referrer = '') {
    // ... local implementation ...
}

// After
require_once __DIR__ . '/../helpers/hubspot-context.php';
// Use shared function directly
```

## JavaScript Integration

### UTM Tracker Integration

All forms should use `window.utmTracker.getUTMDataForAPI()` which now includes `hubspotutk`:

```javascript
// Get UTM data (includes hubspotutk)
let utmData = {};
if (
  window.utmTracker &&
  typeof window.utmTracker.getUTMDataForAPI === "function"
) {
  utmData = window.utmTracker.getUTMDataForAPI();
} else if (window.utmTracking) {
  // Fallback to direct window object
  utmData = {
    utm_source: window.utmTracking.utm_source || "",
    // ... other fields ...
    hubspotutk:
      window.utmTracker && typeof window.utmTracker.getHubspotutk === "function"
        ? window.utmTracker.getHubspotutk() || ""
        : "",
  };
}
```

### Cookie Helper Functions

`v2/js/utm-tracking.js` provides:

```javascript
// Get any cookie value
const value = window.utmTracker.getCookie("cookieName");

// Get hubspotutk specifically
const hubspotutk = window.utmTracker.getHubspotutk();
```

### Including hubspotutk in Request Body

**Always include explicitly:**

```javascript
body: JSON.stringify({
  // ... form fields ...
  ...utmData, // Includes hubspotutk from getUTMDataForAPI()
  hubspotutk: utmData.hubspotutk || "", // Explicit inclusion as fallback
});
```

**Why:** Explicit inclusion ensures hubspotutk is sent even if cookie extraction fails.

### Example: Complete JavaScript Integration

```javascript
// Get UTM data from tracker
let utmData = {};
if (
  window.utmTracker &&
  typeof window.utmTracker.getUTMDataForAPI === "function"
) {
  utmData = window.utmTracker.getUTMDataForAPI();
}

// Submit to API
const response = await fetch("/v2/api/collect-lead.php", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: email,
    tool_name: "Tool Name",
    // Include UTM parameters
    utm_source: utmData.utm_source,
    utm_medium: utmData.utm_medium,
    utm_campaign: utmData.utm_campaign,
    utm_term: utmData.utm_term,
    utm_content: utmData.utm_content,
    gclid: utmData.gclid,
    leadSource: utmData.leadSource,
    partner: utmData.partner,
    signuptype: utmData.signuptype,
    page_url: utmData.page_url,
    referrer: utmData.referrer,
    hubspotutk: utmData.hubspotutk || "", // CRITICAL: Include hubspotutk for Forms API v3 context
  }),
});
```

## Testing Checklist

### Pre-Deployment Testing

- [ ] Test form submission with valid data
- [ ] Test form submission with missing hubspotutk cookie
- [ ] Test form submission with invalid data
- [ ] Verify HubSpot contact creation/update
- [ ] Verify activity timeline shows page views
- [ ] Verify UTM parameters are stored correctly
- [ ] Test retry logic (simulate API failure)
- [ ] Test JavaScript cookie extraction
- [ ] Verify hubspotutk is sent in request body
- [ ] Test across different browsers/devices

### Post-Deployment Verification

- [ ] Monitor HubSpot logs for errors
- [ ] Check contact activity timelines
- [ ] Verify UTM parameter storage
- [ ] Monitor API response times
- [ ] Check retry attempt logs
- [ ] Verify page view tracking

### Manual Testing Steps

1. **Test with hubspotutk cookie:**

   - Visit page with HubSpot tracking code loaded
   - Submit form
   - Verify contact created in HubSpot
   - Check activity timeline for page views

2. **Test without hubspotutk cookie:**

   - Clear cookies or use incognito mode
   - Submit form
   - Verify contact still created (fallback to input parameter)
   - Check that page views may not be tracked (expected)

3. **Test retry logic:**
   - Temporarily block HubSpot API (firewall/test)
   - Submit form
   - Verify retry attempts in logs
   - Unblock API
   - Verify eventual success

## Troubleshooting

### Contact Created But No Page Views

**Possible causes:**

- Missing `hutk` in context
- `hutk` value is invalid or expired
- Page view tracking not implemented (CRM API forms)

**Solution:**

- Verify `hutk` is extracted from cookie
- Check context includes `hutk` field
- For CRM API forms, implement Events API page view tracking

### UTM Parameters Not Stored

**Possible causes:**

- UTM fields not included in form submission
- Field names don't match HubSpot custom properties
- Form doesn't have UTM fields configured

**Solution:**

- Verify UTM data is sent in request body
- Check HubSpot form field configuration
- Verify custom property names match (source**c, utm_medium**c, etc.)

### Retry Logic Not Working

**Possible causes:**

- HTTP status code check incorrect
- Retry delay too short
- API failure is permanent (not transient)

**Solution:**

- Verify HTTP 200 or 204 check
- Check retry delay increases exponentially
- Review error logs for permanent failures

### JavaScript hubspotutk Not Sent

**Possible causes:**

- `getUTMDataForAPI()` not called
- Cookie helper functions not available
- Fallback logic not implemented

**Solution:**

- Verify `window.utmTracker.getUTMDataForAPI()` is called
- Check `getHubspotutk()` helper exists
- Implement fallback to direct cookie reading

## Content/Download Page Form Submissions

### `/form-hs` Endpoint Requirements

The `/form-hs` endpoint (`html/form-hs.php`) handles form submissions from content pages and download pages. It must properly extract `hubspotutk` and include it in Forms API v3 context.

**Required Implementation:**

```php
// CRITICAL: Extract hubspotutk - prioritize cookie (more reliable for session tracking), then fallback to input parameter
$hubspotutk = isset($_COOKIE['hubspotutk']) ? $_COOKIE['hubspotutk'] : (!empty($_POST['hubspotutk']) ? trim($_POST['hubspotutk']) : '');
if (!empty($hubspotutk)) {
    $utmSource = isset($_COOKIE['hubspotutk']) ? 'cookie' : 'input';
    error_log("form-hs.php - hubspotutk source: $utmSource");
}

// Extract page URL and referrer from POST data
$pageUrl = !empty($_POST['page_url']) ? trim($_POST['page_url']) : '';
$referrer = !empty($_POST['referrer']) ? trim($_POST['referrer']) : '';

// Use helper functions for context
require_once __DIR__ . '/../v2/helpers/hubspot-context.php';
$pageUri = getActualPageUrl($pageUrl, $referrer, 'form-hs.php');
$pageName = getPageNameFromUrl($pageUrl, "Content Download Form");

// Forms API v3 context
"context" => array_filter([
    "hutk" => !empty($hubspotutk) ? $hubspotutk : null, // CRITICAL for session tracking
    "ipAddress" => $_SERVER['REMOTE_ADDR'] ?? null,
    "pageUri" => $pageUri,
    "pageName" => $pageName
])
```

**Frontend Pattern (Content/Download Pages):**

```javascript
// Extract hubspotutk and include in FormData
const hubspotutk =
  window.utmTracker && typeof window.utmTracker.getHubspotutk === "function"
    ? window.utmTracker.getHubspotutk() || ""
    : (document.cookie.match(/(?:^|.*;\s*)hubspotutk\s*=\s*([^;]*).*$/) ||
        [])[1] || "";
formData.append("hubspotutk", hubspotutk);
```

**Files Using `/form-hs`:**

- `v2/pages/content_sozialversicherung.php`
- `v2/pages/content_in-8-schritten-digital-gastronomie.php`
- `v2/pages/download_lohnabrechnungen.php`
- `v2/pages/download_trinkgeld.php`
- `v2/pages/download_onboarding_checklist.php`
- `v2/pages/download_zeiterfassung.php`

**Files Using `/html/form-hs.php`:**

- `v2/pages/event_supper_club.php`

## Reference Files

### PHP API Endpoints

- `v2/api/collect-lead.php` - Universal tools lead collection
- `v2/api/submit-template.php` - Template submissions
- `v2/api/addon-request.php` - Add-on requests
- `v2/api/export-workdays.php` - Workdays export
- `v2/api/shiftops-nps.php` - ShiftOps NPS survey
- `v2/api/shiftops-hubspot.php` - ShiftOps data submission
- `v2/api/webinar-registration.php` - Webinar registrations (CRM API)
- `v2/api/payroll-webinar-registration.php` - Payroll webinar registrations (CRM API)
- `v2/api/lead-capture.php` - Lead capture popup (two-step flow)
- `html/form-hs.php` - Content/download page form handler (requires manual review)
- `v2/api/contact.php` - Email-only contact form (intentionally does not integrate with HubSpot - sends emails directly to team for add-on/enterprise inquiries)

### Base Form Includes

**HubSpot Forms (submit to `/form-hs`):**

- `v2/base/include_form-hs.php` - Main HubSpot form component (includes hubspotutk extraction)
- `v2/base/include_form-hs-sdr.php` - SDR-specific HubSpot form (includes hubspotutk extraction)
- `v2/base/include_form-gated-content.php` - Gated content form (includes hubspotutk extraction)
- `v2/base/include_form-webinar.php` - Webinar form (includes hubspotutk extraction)

**Salesforce Forms (do NOT integrate with HubSpot):**

- `v2/base/include_form.php` - Salesforce form (submits to `/form` with Salesforce org ID)
- `v2/base/include_form-salesforce-sdr.php` - Salesforce SDR form (submits to `/v2/base/salesforce-handler.php`)

**JavaScript Pattern for Base Form Includes:**

All HubSpot base form includes extract `hubspotutk` before form submission:

```javascript
// Extract and populate hubspotutk for Forms API v3 context
// Priority: window.utmTracker.getHubspotutk() → cookie fallback
let hubspotutk = "";
if (
  window.utmTracker &&
  typeof window.utmTracker.getHubspotutk === "function"
) {
  hubspotutk = window.utmTracker.getHubspotutk() || "";
} else {
  // Fallback: direct cookie extraction
  const match = document.cookie.match(
    /(?:^|.*;\s*)hubspotutk\s*=\s*([^;]*).*$/
  );
  hubspotutk = match ? decodeURIComponent(match[1]) : "";
}
const hubspotutkField = document.getElementById("hubspotutk");
if (hubspotutkField) {
  hubspotutkField.value = hubspotutk;
}
```

**Hidden Form Field:**

All HubSpot base form includes include a hidden field:

```html
<!-- CRITICAL: Include hubspotutk for Forms API v3 context to link form submission to browser session -->
<input type="hidden" name="hubspotutk" id="hubspotutk" value="" />
```

### Helper Files

- `v2/helpers/hubspot-context.php` - Shared helper functions
- `v2/js/utm-tracking.js` - JavaScript UTM tracking and cookie helpers

### Documentation

- `.cursor/rules/api-endpoints.mdc` - API endpoint patterns and best practices
- `scripts/hubspot/README.md` - Scripts documentation
- `docs/guides/HUBSPOT_TESTING_CHECKLIST.md` - Testing checklist
