Dynamic Dropdown Properties via Workflow
/ 9 min read
Table of Contents
Can you dynamically add dropdown options from a workflow? The standard answer is no.
But if you have Operations Hub Professional and are willing to write about 30 lines of JavaScript, yes — you can. I’ve used this pattern to auto-populate partner lists, campaign source dropdowns, and product catalogues that would otherwise require constant manual maintenance.
The Problem
HubSpot’s dropdown (enumeration) properties have a fixed list of options. You define them in property settings, and users select from that list. Simple and effective for stable data.
But some use cases need dynamic options:
- Partner management: Add a new partner option when onboarding a new channel partner
- Campaign tracking: Auto-create campaign source options based on UTM values
- Product catalogues: Sync product names from an external system
- Event tracking: Add event name options as new events are created
- Territory management: Create region options based on new market expansions
Manually maintaining these lists doesn’t scale. When you have 200+ partners or 500+ campaign sources, someone inevitably forgets to add an option, users can’t select the value they need, and data quality suffers.
The Architecture
The solution uses three components:
┌─────────────────────────────────────────────────────────────┐│ WORKFLOW TRIGGER ││ (New deal created, form submitted, etc.) │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ CUSTOM CODED ACTION ││ 1. Fetch current property options via API ││ 2. Check if new value already exists ││ 3. Add new option if missing ││ 4. Update property via PATCH request │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ SET PROPERTY VALUE ││ (Standard workflow action) │└─────────────────────────────────────────────────────────────┘Requirements:
- Operations Hub Professional or Enterprise (for custom coded actions)
- A private app with
crm.schemas.contacts.writescope (or equivalent for your object type)
Step 1: Create a Private App
Custom coded actions need API authentication. Create a private app with the appropriate scopes.
-
Go to Settings → Integrations → Private Apps
-
Click Create a private app
-
Name it something descriptive (e.g., “Workflow Property Manager”)
-
Under Scopes, add:
crm.schemas.contacts.write(for contact properties)crm.schemas.companies.write(for company properties)crm.schemas.deals.write(for deal properties)- Add other object types as needed
-
Create the app and copy the access token
Important: Store this token securely. You’ll add it as a secret in your workflow.
Step 2: Understand the Properties API
The Properties API lets you read and update property definitions, including dropdown options.
Get Current Property Options
GET /crm/v3/properties/{objectType}/{propertyName}Response includes the options array:
{ "name": "partner_name", "label": "Partner Name", "type": "enumeration", "fieldType": "select", "options": [ { "label": "Acme Corp", "value": "acme_corp", "displayOrder": 0, "hidden": false }, { "label": "Beta Industries", "value": "beta_industries", "displayOrder": 1, "hidden": false } ]}Update Property Options
PATCH /crm/v3/properties/{objectType}/{propertyName}Critical: When updating options, you must include ALL existing options plus your new one. The API replaces the entire options array, not appends to it.
Step 3: Build the Custom Coded Action
Create a deal-based workflow (or whichever object type you’re working with).
Add a Custom Code Action
- In your workflow, click + to add an action
- Select Custom code
- Choose Node.js 18.x or Python 3.9
Add Secrets
Before writing code, add your private app token as a secret:
- In the custom code action, click Secrets
- Add a secret named
HUBSPOT_TOKENwith your private app access token
Add Input Properties
Configure which properties to pass into your code:
- Click Properties to include in code
- Add the property containing the value you want to add (e.g.,
partner_source_raw)
The Code (Node.js)
const axios = require('axios');
exports.main = async (event, callback) => {
// Configuration const objectType = 'deals'; // contacts, companies, deals, etc. const propertyName = 'partner_name'; // Internal property name const accessToken = process.env.HUBSPOT_TOKEN;
// Get the new value from the enrolled record const newValue = event.inputFields['partner_source_raw'];
// Skip if no value provided if (!newValue || newValue.trim() === '') { callback({ outputFields: { status: 'skipped', message: 'No value provided' } }); return; }
// Normalise the value for internal use (lowercase, underscores) const internalValue = newValue .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '_') .replace(/^_|_$/g, '');
const displayLabel = newValue.trim();
try { // Step 1: Fetch current property definition const getResponse = await axios.get( `https://api.hubapi.com/crm/v3/properties/${objectType}/${propertyName}`, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } );
const property = getResponse.data; const currentOptions = property.options || [];
// Step 2: Check if option already exists const existingOption = currentOptions.find( opt => opt.value === internalValue || opt.label.toLowerCase() === displayLabel.toLowerCase() );
if (existingOption) { callback({ outputFields: { status: 'exists', message: `Option already exists: ${existingOption.label}`, optionValue: existingOption.value } }); return; }
// Step 3: Calculate display order for new option const maxDisplayOrder = currentOptions.reduce( (max, opt) => Math.max(max, opt.displayOrder || 0), -1 );
// Step 4: Create new options array with addition const newOption = { label: displayLabel, value: internalValue, displayOrder: maxDisplayOrder + 1, hidden: false };
const updatedOptions = [...currentOptions, newOption];
// Step 5: Update the property const patchResponse = await axios.patch( `https://api.hubapi.com/crm/v3/properties/${objectType}/${propertyName}`, { options: updatedOptions }, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } );
console.log(`Added new option: ${displayLabel} (${internalValue})`);
callback({ outputFields: { status: 'added', message: `Added new option: ${displayLabel}`, optionValue: internalValue } });
} catch (error) { console.error('Error:', error.response?.data || error.message);
callback({ outputFields: { status: 'error', message: error.response?.data?.message || error.message, optionValue: '' } }); }};The Code (Python)
import osimport reimport requests
def main(event):
# Configuration object_type = 'deals' property_name = 'partner_name' access_token = os.environ.get('HUBSPOT_TOKEN')
# Get the new value from the enrolled record new_value = event.get('inputFields', {}).get('partner_source_raw', '')
# Skip if no value provided if not new_value or not new_value.strip(): return { 'outputFields': { 'status': 'skipped', 'message': 'No value provided' } }
# Normalise the value display_label = new_value.strip() internal_value = re.sub(r'[^a-z0-9]+', '_', display_label.lower()) internal_value = internal_value.strip('_')
headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' }
try: # Fetch current property get_url = f'https://api.hubapi.com/crm/v3/properties/{object_type}/{property_name}' response = requests.get(get_url, headers=headers) response.raise_for_status()
property_data = response.json() current_options = property_data.get('options', [])
# Check if exists for opt in current_options: if opt['value'] == internal_value or opt['label'].lower() == display_label.lower(): return { 'outputFields': { 'status': 'exists', 'message': f"Option already exists: {opt['label']}", 'optionValue': opt['value'] } }
# Calculate display order max_order = max([opt.get('displayOrder', 0) for opt in current_options], default=-1)
# Add new option new_option = { 'label': display_label, 'value': internal_value, 'displayOrder': max_order + 1, 'hidden': False }
updated_options = current_options + [new_option]
# Update property patch_url = f'https://api.hubapi.com/crm/v3/properties/{object_type}/{property_name}' patch_response = requests.patch( patch_url, headers=headers, json={'options': updated_options} ) patch_response.raise_for_status()
return { 'outputFields': { 'status': 'added', 'message': f'Added new option: {display_label}', 'optionValue': internal_value } }
except Exception as e: return { 'outputFields': { 'status': 'error', 'message': str(e), 'optionValue': '' } }Configure Outputs
Add output fields to use in subsequent workflow actions:
| Output Name | Type |
|---|---|
status | String |
message | String |
optionValue | String |
Step 4: Complete the Workflow
After the custom code action, add a Set property value action:
- Select the target dropdown property (e.g.,
partner_name) - Use the
optionValueoutput from the custom code action
This sets the dropdown value on the enrolled record using the newly created (or existing) option.
Add Error Handling
Use an If/then branch after the custom code:
If status equals "error" → Send internal notification → Log to error tracking propertyElse → Continue to set property valueImportant Considerations
Validation Changes (November 2025)
HubSpot is implementing stricter validation for enumeration values starting November 10, 2025:
- No leading/trailing whitespace — Values like
" partner"or"partner "will be rejected - No invisible Unicode characters — No-break spaces and similar will cause 400 errors
The code above handles this with the normalisation step, but be aware if you’re customising.
Rate Limits
The Properties API shares rate limits with other CRM APIs:
- 190 requests per 10 seconds (burst)
- 500,000 requests per day
For high-volume scenarios (hundreds of new options daily), consider:
- Batching updates during off-peak hours
- Caching current options to reduce GET requests
- Adding delays between workflow executions
Option Limits
HubSpot dropdown properties can have thousands of options, but performance degrades with very large lists. If you’re approaching 1,000+ options, consider:
- Using a text field instead
- Creating a related custom object for lookups
- Implementing a searchable dropdown via custom UI
Concurrent Updates
If two workflow executions try to add options simultaneously, one may overwrite the other’s addition. The code handles this gracefully — the “lost” option will simply be re-added on the next relevant trigger.
For high-concurrency scenarios, consider:
- Adding a short random delay before execution
- Using a queuing mechanism (external)
- Implementing optimistic locking with version checks
Alternative Patterns
Pattern: Scheduled Sync from External Source
Instead of adding options on-demand, sync from an external system on a schedule:
// Scheduled workflow (daily at 2 AM)// Fetches partners from external CRM and syncs to dropdown
const axios = require('axios');
exports.main = async (event, callback) => {
const accessToken = process.env.HUBSPOT_TOKEN; const externalApiKey = process.env.EXTERNAL_API_KEY;
// Fetch partners from external system const externalPartners = await axios.get( 'https://external-crm.example.com/api/partners', { headers: { 'X-API-Key': externalApiKey } } );
// Fetch current HubSpot options const hubspotProperty = await axios.get( 'https://api.hubapi.com/crm/v3/properties/deals/partner_name', { headers: { 'Authorization': `Bearer ${accessToken}` } } );
const currentOptions = hubspotProperty.data.options; const currentValues = currentOptions.map(o => o.value);
// Find missing options const newOptions = externalPartners.data .filter(partner => !currentValues.includes(partner.id)) .map((partner, idx) => ({ label: partner.name, value: partner.id, displayOrder: currentOptions.length + idx, hidden: false }));
if (newOptions.length === 0) { callback({ outputFields: { added: 0 } }); return; }
// Update property with combined options await axios.patch( 'https://api.hubapi.com/crm/v3/properties/deals/partner_name', { options: [...currentOptions, ...newOptions] }, { headers: { 'Authorization': `Bearer ${accessToken}` } } );
callback({ outputFields: { added: newOptions.length } });};Pattern: Bi-Directional Sync with Custom Object
For complex partner management, use a custom object as the source of truth:
- Partner custom object stores full partner details
- Workflow on Partner creation updates the dropdown property
- Dropdown property provides quick selection in deals
- Association links deals to full partner records
This gives you both the convenience of a dropdown and the richness of a custom object.
Testing the Workflow
Before going live:
- Test with a known value — Use a test deal with a partner name you know doesn’t exist
- Verify the option was added — Check Settings → Properties → Your Property
- Test duplicate handling — Run the workflow again with the same value
- Test edge cases — Special characters, very long names, empty values
- Monitor the first few live executions — Check workflow history for errors
Real-World Example
A client managing 200+ channel partners previously had an admin manually adding new partners to a dropdown. Partners were often missing, leading to:
- Sales reps typing partner names in free text fields
- Inconsistent data (“Acme” vs “ACME” vs “Acme Corp”)
- Reports that missed partner attribution
After implementing this pattern:
- New partners auto-populate within seconds of deal creation
- Data consistency improved dramatically
- Reporting accuracy increased
- Admin time reduced from 2 hours/week to zero
Key Takeaways
- Dynamic dropdowns are possible — Just not with standard workflow actions
- Operations Hub Pro is required — For custom coded actions
- Include all options in updates — The API replaces, not appends
- Normalise values carefully — Watch for whitespace and special characters
- Handle concurrency — Multiple simultaneous updates can conflict
- Consider alternatives — Custom objects may be better for complex lookups
This pattern transforms dropdown properties from static lists into dynamic, self-maintaining fields — one less thing for admins to manage, one more thing that “just works.”
Building dynamic HubSpot integrations? I’d be interested to hear about your use cases — find me on LinkedIn.