Why contacts created via the HubSpot CRM API default to Offline Sources, and how to pass the hubspotutk browser cookie so paid ad clicks, UTM parameters, and page views stay attached to new users.
If you are seeing a growing list of HubSpot contacts with an Original Source of Offline Sources and no ad click history, the most common culprit is a custom app sign-up flow that creates contacts via the HubSpot CRM API without passing along the visitor’s browser tracking cookie.
This happens constantly at SaaS companies. Marketing runs paid search and social campaigns, the HubSpot tracking script records the website visits, and engineering wires the product registration endpoint into HubSpot. On the surface, the integration works: contacts appear in the CRM seconds after signing up. However, those contact records arrive completely stripped of their pre-signup session history, landing page views, and UTM parameters.
The Disconnect Between the App and the CRM
In a typical SaaS architecture, the marketing site and the application live in separate environments, and custom authentication handles account creation instead of an embedded HubSpot form.
- A prospect clicks a Google Ads or LinkedIn campaign.
- They land on a marketing page at
company.comwhere the HubSpot tracking code (hs-script-loader) initializes and sets thehubspotutkcookie. - They click the primary call to action and navigate to the registration flow, usually hosted at
app.company.com/signup. - The user submits their email, name, and password to your application’s backend.
- Your backend server provisions the user in your database and calls the HubSpot CRM Contacts API (
POST /crm/v3/objects/contactsor the batch upsert endpoint) to create or update the contact record.
The contact is created with the exact fields your backend sent over, and nothing else. Because the server-to-server CRM API call doesn’t include the visitor’s browser cookie, HubSpot has no way of knowing that the newly created database record is the same person who clicked an ad thirty seconds ago.
Identity Resolution and Session Stitching in HubSpot
To understand why the CRM API breaks attribution, it helps to look at how HubSpot’s identity resolution works under the hood.
When an anonymous visitor lands on a page running the HubSpot tracking script, HubSpot generates a unique tracking token and stores it in the user’s browser inside a first-party cookie named hubspotutk. Every subsequent page view, UTM parameter, referral URL, and ad click ID (gclid, fbclid, msclkid) is logged against that anonymous hubspotutk profile.
That data sits in an anonymous holding state until an identity event links the hubspotutk token to an email address. HubSpot only performs this merge during three specific events:
- A native HubSpot form submission (or a server-side submission to the HubSpot Forms API that explicitly includes the cookie token).
- A browser-side
identifycall via the_hsqJavaScript tracking queue, followed by a tracking event. - A tracked marketing email click from an existing HubSpot campaign.
Notice what is missing from that list: the HubSpot CRM Contacts API.
The /crm/v3/objects/contacts endpoints are built for direct database manipulation (like syncing billing status, CRM imports, or backend data hygiene). They do not accept the hubspotutk token, and any contact created through them defaults to an Original Source of OFFLINE (Original Source Drill-Down 1: API).
Diagnosing Broken Attribution in Your Portal
If you suspect your sign-up flow is dropping session data, you can verify it in a few minutes inside HubSpot:
- Inspect the Original Source: Navigate to CRM > Contacts and filter by
Original Sourceis any ofOffline Sources. AddOriginal Source Drill-Down 1andOriginal Source Drill-Down 2as columns. Contacts created by your backend without cookie stitching will showAPIorintegration-namein the drill-down fields. - Check a Contact’s Activity Timeline: Open a recent sign-up record and scroll to the very bottom of their activity timeline (make sure Page views and Marketing emails/Analytics are checked in the “Filter activity” dropdown).
- Healthy attribution: You will see the first page view, the original source attribution, and the form submission all grouped together at the start of the timeline.
- Broken attribution: The very first event on the timeline will be “Contact created from Offline Sources,” with zero page views listed prior to creation, or page views will only begin appearing after they later click a marketing email.
- Missing Ad Network Optimization Data: If you use HubSpot’s Google Ads or Meta Ads integration to sync lifecycle stage conversions (like MQLs or SQLs) back to the ad networks for Smart Bidding, those conversions will fail to sync. HubSpot requires the original click ID (captured during the initial browser session) to be attached to the contact record in order to attribute the downstream conversion back to the ad click.
Fix #1: Pass the hubspotutk Cookie via the Forms API (Recommended)
The most reliable, permanent fix is to keep your backend CRM API calls for ongoing user property updates, but trigger a server-side HubSpot Forms API submission at the exact moment of account registration.
The Forms API accepts a context object containing the visitor’s hubspotutk cookie, IP address, and page URL. When HubSpot receives this payload, it processes the sign-up identically to an embedded marketing form: it creates (or updates) the contact, links the anonymous hubspotutk history, sets the proper Original Source (e.g., Paid Search, Organic Search), and attaches all historical page views.
Implementation Steps
- Create a “headless” form in HubSpot: Go to Marketing > Forms and create a new regular form named something like
App Sign-Up (API). Add the basic fields you collect at registration (email,firstname,lastname). You won’t embed this form anywhere; you only need its Portal ID and Form GUID. - Read the cookie on your server: When the user submits your custom sign-up form, read the
hubspotutkcookie from the incoming HTTP request headers (or readdocument.cookieon the frontend and include it in your registration POST payload). - Submit to the HubSpot Forms V3 endpoint:
// Server-side handler executed immediately after a successful app sign-up
async function sendSignupToHubSpot(req, user) {
const PORTAL_ID = process.env.HUBSPOT_PORTAL_ID;
const FORM_GUID = process.env.HUBSPOT_SIGNUP_FORM_GUID;
const HUBSPOT_TOKEN = process.env.HUBSPOT_PRIVATE_APP_TOKEN;
// 1. Grab the HubSpot tracking cookie from the incoming request
const hutk = req.cookies?.hubspotutk;
// 2. Capture the client's real IP address for accurate geolocation
const ipAddress =
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.socket.remoteAddress;
// 3. Build the Forms API payload
const payload = {
fields: [
{ objectTypeId: '0-1', name: 'email', value: user.email },
{ objectTypeId: '0-1', name: 'firstname', value: user.firstName },
{ objectTypeId: '0-1', name: 'lastname', value: user.lastName },
],
context: {
...(hutk ? { hutk } : {}), // Only include hutk if present (e.g., not blocked by an ad blocker)
ipAddress,
pageUri: 'https://app.company.com/signup',
pageName: 'App Sign Up',
},
};
const response = await fetch(
`https://api.hsforms.com/submissions/v3/integration/secure/submit/${PORTAL_ID}/${FORM_GUID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${HUBSPOT_TOKEN}`,
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const errorBody = await response.text();
console.error('HubSpot Forms API Error:', response.status, errorBody);
}
}
Important Technical Details for Fix #1
- Do not send empty
hutkstrings: If a user has an ad blocker or rejected analytics cookies,req.cookies.hubspotutkwill beundefined. Omit thehutkkey from thecontextobject entirely if it is empty; passing an empty string or"null"will cause the Forms API to return a400 Bad Requestvalidation error. - Pass
ipAddressinsidecontext: Because this request originates from your backend server rather than the user’s browser, HubSpot will log your server’s data center IP (e.g., an AWSus-east-1IP) unless you explicitly pass the visitor’s IP address incontext.ipAddress. - Subdomain vs. Cross-Domain Cookie Access: By default, HubSpot sets the
hubspotutkcookie on the root domain (.company.com). If your marketing site is onwww.company.comand your app is onapp.company.com, the browser automatically sends thehubspotutkcookie to your app server. However, if your app lives on a completely separate root domain (e.g.,company.io), cookies cannot cross root domains automatically. You must add both domains under Settings > Tracking & Analytics > Tracking Code > Advanced Tracking and enable Automatic cross-domain linking so HubSpot appends the__hstc,__hssc, and__hsfpparameters to outbound links between your domains. - Syncing custom properties: If your backend also sets custom product properties (like
user_id,plan_tier, ortrial_expiration_date), you can either include those fields in the Forms API payload or continue running your existing CRM Contacts API upsert after the Forms API call completes.
Fix #2: Identify the User in the Browser (_hsq.push)
If your engineering backlog is locked down and you cannot modify the backend registration endpoint right now, you can bridge the session gap from the frontend using Google Tag Manager or your app’s client-side JavaScript.
When a user completes registration and lands on your onboarding or dashboard page, you can pass their email address to HubSpot’s client-side tracking queue using the identify method, immediately followed by a trackPageView or custom behavioral event call:
// Client-side, executed once immediately after registration completes
window._hsq = window._hsq || [];
window._hsq.push([
'identify',
{
email: user.email,
},
]);
window._hsq.push(['trackPageView']);
Client-Side Session Merging
The identify call does not send a network request on its own; it simply stores the user’s email inside the tracker queue in memory. The subsequent trackPageView call fires the tracking pixel (__ptq.gif) to HubSpot containing both the hubspotutk cookie and the identified email address.
Even if your backend already created the contact record via the CRM API a second earlier (initially marking them as Offline Sources), when HubSpot processes the browser pixel matching that email and cookie, it merges the anonymous browser session into the contact record and retroactively updates Original Source from Offline Sources to the true marketing source (such as Paid Search or Organic Social).
Risks and Edge Cases with Browser Identification
While client-side identification is a fast stopgap, it relies on browser state and introduces several sharp edge cases if not carefully scoped:
- “Invite a Teammate” and Referral Forms: If HubSpot’s tracking script has Collect data from website forms (Non-HubSpot Forms) turned on in your portal settings, or if developers indiscriminately call
identifywhenever an email is submitted in the UI, typing a colleague’s email into an “Invite User” input will re-bind the current user’shubspotutkcookie to the invited colleague’s contact record. Every page your primary user visits afterward will be logged on their coworker’s timeline. - Shared Devices and Account Switching: If a user logs out and logs into a secondary account (or an admin impersonates a customer), calling
identifywith the second email address links the same browser cookie to both contacts, muddying attribution and page view history across accounts. - Email Address Changes in Account Settings: When a user updates their email address inside your app, your backend should update the existing HubSpot contact record via the CRM API using their HubSpot
vid/hs_object_id(or by searching the old email and updating theemailproperty). If you call_hsq.push(['identify', { email: newEmail }])in the browser before or instead of updating the CRM record via API, HubSpot sees an unrecognized email address tied to an existing cookie and creates a brand-new duplicate contact record rather than updating the original contact’s email. HubSpot’s documentation doesn’t mention this anywhere, but it’s what happens in practice.
Customer Data Platforms (CDPs) like Segment solve this with an explicit alias or identity-graph merge call (something I cover in detail on my Segment consulting page). Because HubSpot’s browser tracker has no equivalent alias method, you should strictly limit browser identify calls to the single post-registration confirmation step, and ensure HubSpot’s automatic “Non-HubSpot Forms” scraper is disabled inside your authenticated app environment.
Re-Attributing Existing “Offline” Contacts
A common question once teams discover this issue is whether historical Offline Sources contacts can be re-attributed.
- If the user logs back into your app: If you deploy Fix #2 (or pass
hutkon login), and a recent user returns to your app using the same browser they used when they originally clicked your ad and signed up, HubSpot will still merge their historicalhubspotutksession into their contact record and update theirOriginal Sourceautomatically (provided theOriginal Sourcehasn’t been manually edited by a user in the CRM). - If the cookie is gone or they use a different device: Once the association between the browser cookie and the sign-up event is lost, HubSpot cannot reconstruct the session. Only if your own application database independently captured and stored UTM parameters (
utm_source,utm_medium,utm_campaign,gclid) at the time of registration can you write a script to map and backfill those values into custom HubSpot attribution fields.
Choosing the Right Approach
Whenever possible, implement Fix #1 via the HubSpot Forms API. For an engineering team, adding a single server-side POST request alongside your existing user creation logic takes minimal effort, follows HubSpot’s intended architecture, and completely avoids the duplicate-contact and cookie-hijacking edge cases of client-side workarounds. Use the browser identify method only as a temporary bridge if you need to restore ad attribution immediately while waiting on backend sprint capacity.
If your HubSpot attribution is dropping session data and you want an experienced set of eyes to audit your sign-up flow, take a look at my HubSpot development work.