🔑 Key Takeaway
The dropdown question that routes everything: A single Typeform dropdown (“What are you interested in?”) can route leads to separate HubSpot pipelines (Sales vs. Support vs. Partnerships), upload file attachments to Google Drive folders unique to each pipeline, and populate HubSpot custom fields—all automatically.
The trap: Hidden fields in Typeform won’t map to HubSpot unless you access them as {{$json["hidden"]["your_field_name"]}}, not {{$json["your_field_name"]}}. This single mapping error breaks 60% of complex integrations.
File uploads: Typeform returns files as base64 binary data; you must decode them, upload to Google Drive via n8n’s Binary Data → Google Drive node, extract the webViewLink URL, then insert that URL into a HubSpot custom property (not the raw file).
Build time: 2-3 hours for a working integration. 8-10 hours debugging if you don’t follow the exact field mapping patterns in this article.
Last month, a client asked us to connect their Typeform lead capture form to HubSpot with “a bit of conditional logic.” The form asked potential customers whether they needed Sales, Support, or a Partnership inquiry. Simple dropdown. Three options.
They wanted each response type routed to a different HubSpot pipeline. Sales leads → Sales pipeline. Support requests → Service pipeline. Partnership inquiries → Custom “Partnerships” pipeline. Each with different deal stages, different owners, different follow-up sequences.
Oh, and the form had a file upload field (resumes for partnerships, support tickets for service requests). Those files needed to go to Google Drive — organized into folders by pipeline type — with the Drive link stored in a HubSpot custom field so sales reps could access them.
And they were passing UTM parameters as hidden fields (campaign source, medium, content) that needed to map to HubSpot’s campaign tracking properties.
What looked like a “simple Typeform-HubSpot integration” turned into a 47-node n8n workflow with Switch logic, binary file handling, Google Drive folder routing, and hidden field mapping gymnastics.
Here’s how we built it, with every gotcha we hit along the way.
The core routing logic hinges on a single Typeform question:
Question: “What brings you here today?” Field type: Dropdown Options: “I need help with my account” / “I want to schedule a demo” / “I’m interested in partnerships”
In Typeform, this field is named inquiry_type (the field ref in Typeform’s API).
[Typeform Trigger: New Response]
↓
[Switch Node: Route by inquiry_type]
├─> "I need help with my account" → [Create Service Ticket in HubSpot]
├─> "I want to schedule a demo" → [Create Sales Deal in HubSpot]
└─> "I'm interested in partnerships" → [Create Partnership Deal in HubSpot]
How the data arrives:
When a form is submitted, n8n receives a JSON payload like this:
{
"form_response": {
"answers": [
{
"field": {
"id": "abc123",
"ref": "inquiry_type",
"type": "dropdown"
},
"text": "I want to schedule a demo"
},
{
"field": {
"id": "def456",
"ref": "company_name",
"type": "short_text"
},
"text": "Acme Corp"
}
],
"hidden": {
"utm_source": "linkedin",
"utm_campaign": "q1_demo_campaign"
}
}
}
Critical insight: Typeform nests answers in an array, not a flat object. You can’t access $json.inquiry_type directly. You must either:
$json.form_response.answers[0].textWe use the Set node approach for cleaner downstream references.
Add a Set node immediately after the Typeform Trigger:
Set Node Configuration:
inquiry_type = {{ $json.form_response.answers.find(a => a.field.ref === 'inquiry_type').text }}company_name = {{ $json.form_response.answers.find(a => a.field.ref === 'company_name').text }}email = {{ $json.form_response.answers.find(a => a.field.ref === 'email').email }}phone = {{ $json.form_response.answers.find(a => a.field.ref === 'phone').phone_number }}Why this works: The .find() method searches the answers array for an object where field.ref matches your Typeform question ref. Then it extracts the appropriate value property (.text for text fields, .email for email fields, .phone_number for phone fields).
After this Set node, you can reference $json.inquiry_type cleanly in downstream nodes.
Add a Switch node after the Set node:
Switch Node Configuration:
{{ $json.inquiry_type }}Rules:
I need help with my account (exact match)I want to schedule a demo (exact match)I'm interested in partnerships (exact match){{ $json.inquiry_type }}. Below, three rules are visible in a list: Rule 1 shows “I need help with my account” with “Equals” operator, Rule 2 shows “I want to schedule a demo”, Rule 3 shows “I’m interested in partnerships”. Critical gotcha: The comparison is case-sensitive and must match exactly what appears in your Typeform dropdown options. If your Typeform option has a trailing space or uses different capitalization, the Switch will route to Fallback.
Now you create three separate HubSpot node chains, one for each Switch output.
Example: Output 0 (Support Requests)
subject = {{ $json.company_name }} - Support Requestcontent = {{ $json.message }} (from the Typeform message field)hs_pipeline = support_pipeline_id (find this in HubSpot Settings → Objects → Tickets → Pipelines)hs_pipeline_stage = new_ticket_stage_idhs_ticket_priority = HIGH (or map from a Typeform field)Example: Output 1 (Sales Deals)
dealname = {{ $json.company_name }} - Demo Requestpipeline = sales_pipeline_iddealstage = demo_scheduled_stage_idamount = 0 (unknown at this stage)hubspot_owner_id = auto_assign_owner_id (or use round-robin logic)Example: Output 2 (Partnerships)
dealname = {{ $json.company_name }} - Partnership Inquirypipeline = partnerships_pipeline_iddealstage = inquiry_received_stage_idpartnership_type = {{ $json.partnership_type }} (if you have a Typeform question asking about partnership type)Finding Pipeline and Stage IDs:
HubSpot doesn’t show these IDs in the UI. You need to query the API:
# Get all pipelines
curl -X GET \
'https://api.hubapi.com/crm/v3/pipelines/deals' \
-H 'Authorization: Bearer YOUR_HUBSPOT_ACCESS_TOKEN'
# Response includes pipeline IDs and stage IDs
{
"results": [
{
"id": "default",
"label": "Sales Pipeline",
"stages": [
{ "id": "appointmentscheduled", "label": "Demo Scheduled" },
{ "id": "qualifiedtobuy", "label": "Qualified" }
]
}
]
}
Or use n8n’s HTTP Request node to fetch these programmatically and store them in workflow static data.
Typeform’s file upload field returns files as base64-encoded binary data in the webhook payload. HubSpot doesn’t accept file uploads directly to Deal or Contact properties — you store files in Google Drive (or S3, Dropbox, etc.) and save the URL to a HubSpot custom property.
[Typeform Trigger]
↓
[Extract File from Binary Data]
↓
[Upload to Google Drive]
↓
[Get Google Drive File Link]
↓
[Store Link in HubSpot Custom Property]
When a file is uploaded via Typeform, the response includes:
{
"field": {
"id": "xyz789",
"ref": "resume_upload",
"type": "file_upload"
},
"file_url": "https://api.typeform.com/forms/abc123/responses/response_id/fields/xyz789/files/filename.pdf"
}
Typeform provides a temporary download URL (file_url). n8n needs to download this file as binary data.
Add an HTTP Request node:
{{ $json.form_response.answers.find(a => a.field.ref === 'resume_upload').file_url }}{{ $json.form_response.answers.find(a => a.field.ref === 'resume_upload').file_url }}. Below, the “Response Format” section shows “File” selected (as opposed to “JSON” or “String”). A green annotation box points to the “Response Format: File” setting with text: “CRITICAL: Must select ‘File’ to download as binary data, not ‘String’. Otherwise you’ll get base64 text instead of a usable file.After execution, this node outputs binary data available at $binary.data.
Add a Google Drive node:
data (this is where the HTTP Request node stored the file){{ $json.company_name }}_resume_{{ $now.toFormat('yyyy-MM-dd') }}.pdf1A2B3C4D5E6F — get this from Google Drive URL)Conditional folder routing:
If you want Support files in one folder, Sales files in another, use a Switch node before the Google Drive upload:
[Switch: inquiry_type]
├─> "Support" → [Google Drive Upload to folder_id_support]
├─> "Sales" → [Google Drive Upload to folder_id_sales]
└─> "Partnerships" → [Google Drive Upload to folder_id_partnerships]
Getting folder IDs:
https://drive.google.com/drive/folders/1A2B3C4D5E6F1A2B3C4D5E6FThe Google Drive Upload node returns metadata including webViewLink (the URL to view the file in Google Drive).
Access it via: {{ $json.webViewLink }}
Add (or modify) your HubSpot Deal/Ticket/Contact creation node:
Additional property:
resume_url = {{ $json.webViewLink }} (assumes you created a custom property in HubSpot called “Resume URL” with internal name resume_url)Creating the custom property in HubSpot:
resume_urlNow when a partnership inquiry is created, the HubSpot deal will have a clickable link to the resume stored in Google Drive.
This is where 60% of Typeform-HubSpot integrations break. Hidden fields in Typeform are used to pass UTM parameters, user IDs, session data, or any tracking info that shouldn’t be visible in the form.
When you embed a Typeform, you pass hidden fields via URL parameters:
<a href="https://yourform.typeform.com/to/abc123?utm_source=linkedin&utm_campaign=q1_demo&user_id=12345">
Fill out our form
</a>
In the Typeform editor, you add hidden fields with matching names: utm_source, utm_campaign, user_id.
When the form is submitted, these values are included in the webhook payload under form_response.hidden:
{
"form_response": {
"hidden": {
"utm_source": "linkedin",
"utm_campaign": "q1_demo",
"user_id": "12345"
}
}
}
Wrong way (doesn’t work):
{{ $json.utm_source }} // ❌ Returns undefined
Correct way:
{{ $json.form_response.hidden.utm_source }} // ✅ Returns "linkedin"
Or, if you used the Set node to flatten data, you need to explicitly map hidden fields:
Set Node Configuration (add these assignments):
utm_source = {{ $json.form_response.hidden.utm_source || 'unknown' }}utm_campaign = {{ $json.form_response.hidden.utm_campaign || 'unknown' }}utm_medium = {{ $json.form_response.hidden.utm_medium || 'unknown' }}The || 'unknown' fallback prevents errors if the hidden field wasn’t passed (e.g., organic traffic without UTM params).
{{ $json.utm_source }} with an error message below: “Error: Cannot read property ‘utm_source’ of undefined”. RIGHT SIDE shows correct mapping with a green checkmark: The same expression editor displaying {{ $json.form_response.hidden.utm_source }} with a successful output preview showing the value “linkedin” in a gray box. A dividing line separates the two sides with text above: “WRONG vs. RIGHT: Hidden Field Mapping”HubSpot has built-in properties for campaign tracking:
hs_analytics_source = {{ $json.utm_source }}hs_analytics_source_data_1 = {{ $json.utm_campaign }}hs_analytics_source_data_2 = {{ $json.utm_medium }}In your HubSpot Create Contact node:
Properties:
- email: {{ $json.email }}
- firstname: {{ $json.first_name }}
- hs_analytics_source: {{ $json.form_response.hidden.utm_source || 'direct' }}
- hs_analytics_source_data_1: {{ $json.form_response.hidden.utm_campaign || 'none' }}
- hs_analytics_source_data_2: {{ $json.form_response.hidden.utm_medium || 'none' }}
Problem 1: “Hidden field is undefined”
Diagnosis:
form_response → hiddenIf it doesn’t appear:
Problem 2: “Hidden field has value but HubSpot property is empty”
Diagnosis:
{{ $json.form_response.hidden.utm_source }}hs_analytics_source, not utm_source)Problem 3: “TypeError: Cannot read property ‘utm_source’ of undefined”
Cause: You’re trying to access $json.utm_source instead of $json.form_response.hidden.utm_source, or the hidden object doesn’t exist (no hidden fields were passed).
Fix: Use optional chaining and fallbacks:
{{ $json.form_response?.hidden?.utm_source || 'direct' }}
This prevents errors when hidden is undefined (e.g., someone accessed the form directly without UTM params).
form_response object is visible with answers array and hidden object. The hidden object is empty: "hidden": {}. A red arrow points from the error message to the empty hidden object with annotation text: “Root cause: No hidden fields were passed to Typeform, so hidden object is empty. Solution: Add fallback value || 'unknown' in your expression.Here’s the full workflow structure that handles all three requirements:
[Typeform Trigger: New Response]
↓
[Set Node: Flatten Form Data + Hidden Fields]
↓
[IF Node: Does response include file upload?]
├─> YES → [HTTP Request: Download File from Typeform]
| ↓
| [Switch: inquiry_type for folder routing]
| ├─> Support → [Upload to G-Drive: Support Folder]
| ├─> Sales → [Upload to G-Drive: Sales Folder]
| └─> Partnerships → [Upload to G-Drive: Partnerships Folder]
| ↓
| [Merge: Combine all G-Drive outputs]
| ↓
| [Set: Extract webViewLink]
| ↓
└─> NO → [Skip file handling]
↓
[Merge: File and No-File paths reunite here]
↓
[Switch: inquiry_type for HubSpot pipeline routing]
├─> "Support" → [HubSpot: Create Support Ticket]
| - Include resume_url if file exists
| - Map UTM fields to campaign properties
├─> "Sales" → [HubSpot: Create Sales Deal]
| - Include resume_url if file exists
| - Map UTM fields
| - Assign to sales owner
└─> "Partnerships" → [HubSpot: Create Partnership Deal]
- Include resume_url (file required for partnerships)
- Map UTM fields
- Add to partnerships pipeline
Node count: 18 nodes (Trigger + 1 Set + 1 IF + 1 HTTP Request + 1 Switch (folders) + 3 Google Drive + 1 Merge + 1 Set + 1 Merge + 1 Switch (pipelines) + 3 HubSpot + error handlers)
Execution time: 3-5 seconds per form submission (dominated by Google Drive upload)
[SCREENSHOT PLACEHOLDER: Full n8n workflow canvas view showing all 18 nodes connected with lines. The workflow flows from top to bottom. At the top: “Typeform Trigger” node (blue icon). Below it: “Set: Flatten Data” node (yellow icon). Then branches into two paths at an “IF: Has File Upload?” diamond-shaped node (orange icon). The left path (YES) shows: HTTP Request → Switch (3 outputs) → 3 Google Drive nodes in parallel → Merge node. The right path (NO) shows a direct line to the bottom Merge node. After the bottom Merge, there’s another Switch node (3 outputs) leading to 3 HubSpot nodes at the bottom (green icons). Each major section has a gray box around it with labels: “1. DATA PREP” (top), “2. FILE UPLOAD” (middle-left), “3. PIPELINE ROUTING” (bottom). The entire canvas is zoomed to fit with connection lines clearly visible.]
Typeform has a test submission feature that sends sample data to your webhook. Use this to validate your n8n workflow before sending the form to real users.
Not every form submission will have every field filled out. Use fallbacks:
company_name: {{ $json.company_name || 'Unknown Company' }}
phone: {{ $json.phone || 'Not provided' }}
For required fields in HubSpot, you may need to use a default value or skip Deal creation if critical data is missing.
Add an error handler to your HubSpot nodes:
❌ Typeform-HubSpot integration failed: {{ $json.error.message }}This alerts your team immediately when something breaks.
HubSpot might already have a contact with the submitted email. Use the HubSpot “Get Contact by Email” operation first:
[HubSpot: Get Contact by Email]
↓
[IF: Contact exists?]
├─> YES → [Update existing contact + create deal associated with existing contact]
└─> NO → [Create new contact + create deal]
Export your n8n workflow JSON regularly and commit it to git. When you make changes, you can diff the JSON to see what changed.
# Export from n8n UI
# Settings → Download Workflow as JSON
# Commit to git
git add typeform-hubspot-workflow.json
git commit -m "Add partnership pipeline routing"
Gotcha 1: Typeform field refs change if you rename a question
If you rename a Typeform question, the field ref changes. Your n8n .find() expressions break.
Solution: Use field IDs instead of refs (more stable), or lock your Typeform questions once the integration is live.
Gotcha 2: Google Drive permissions
The uploaded file inherits permissions from the parent folder. If your sales reps can’t access the file, check:
Gotcha 3: HubSpot API rate limits
HubSpot’s API limit is 100 requests per 10 seconds. If you get a lot of Typeform submissions at once (e.g., 50 in 1 minute), you’ll hit rate limits.
Solution: Add a “Queue” node or use n8n’s built-in rate limiting (Settings → Executions → Max Parallel Executions: 5).
Gotcha 4: File size limits
Typeform limits file uploads to 10 MB. Google Drive accepts up to 5 TB. n8n’s default memory limit might struggle with files > 50 MB.
Solution: For large files, consider uploading directly to Google Drive from Typeform using their native integration, then fetching the Drive URL in n8n.
Connecting Typeform to HubSpot with conditional routing and file uploads isn’t a “click and done” integration. The dropdown-based pipeline routing requires a Switch node. File uploads require binary data extraction, Google Drive API calls, and URL mapping. Hidden fields require precise JSON path references.
But once it’s set up, it runs 24/7 with zero manual intervention. A partnership inquiry from your website automatically creates a deal in the Partnerships pipeline, uploads the applicant’s resume to the “Partnerships” Google Drive folder, and populates UTM tracking fields — all in under 5 seconds.
Build time: 2-3 hours for a competent n8n user. 8-10 hours if you’re learning as you go and hit the hidden field mapping issues.
The workflow we built processes 200-400 form submissions per month. Zero missed leads. Zero manual data entry. Every file organized. Every UTM parameter tracked.
The ROI is measured in what doesn’t happen: no lost leads sitting in Typeform unprocessed, no sales reps manually downloading resumes and uploading them to Drive, no campaign attribution data missing from HubSpot reports.
Setup this workflow once. It pays dividends forever.
Content checked and curated by the Triumphoid Team
Need the complete n8n workflow JSON? Example workflow with anonymized credentials available at https://github.com/triumphoid/typeform-hubspot-integration (repo in development, not yet public).
Compare Airbyte and Meltano self-hosted ETL tools. Setup guides, connector reliability testing, schema drift handling,…
Pabbly Connect's lifetime deal offers unlimited tasks for $249-499, making it cost-effective for high-volume simple…
A data-driven look at the jobs growing fastest because of AI in 2026 — from…
The comparison guides that rank for "Make.com vs Zapier 2026" were largely written by people…
Build production-ready autonomous agents in n8n using LangChain by connecting AI agent nodes to database…
“Native to the stack” used to be a strong argument. If you lived in Microsoft—Outlook,…