skip to content
san.is
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.write scope (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.

  1. Go to Settings → Integrations → Private Apps

  2. Click Create a private app

  3. Name it something descriptive (e.g., “Workflow Property Manager”)

  4. 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
  5. 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

  1. In your workflow, click + to add an action
  2. Select Custom code
  3. Choose Node.js 18.x or Python 3.9

Add Secrets

Before writing code, add your private app token as a secret:

  1. In the custom code action, click Secrets
  2. Add a secret named HUBSPOT_TOKEN with your private app access token

Add Input Properties

Configure which properties to pass into your code:

  1. Click Properties to include in code
  2. 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 os
import re
import 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 NameType
statusString
messageString
optionValueString

Step 4: Complete the Workflow

After the custom code action, add a Set property value action:

  1. Select the target dropdown property (e.g., partner_name)
  2. Use the optionValue output 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 property
Else
→ Continue to set property value

Important 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:

  1. Partner custom object stores full partner details
  2. Workflow on Partner creation updates the dropdown property
  3. Dropdown property provides quick selection in deals
  4. 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:

  1. Test with a known value — Use a test deal with a partner name you know doesn’t exist
  2. Verify the option was added — Check Settings → Properties → Your Property
  3. Test duplicate handling — Run the workflow again with the same value
  4. Test edge cases — Special characters, very long names, empty values
  5. 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

  1. Dynamic dropdowns are possible — Just not with standard workflow actions
  2. Operations Hub Pro is required — For custom coded actions
  3. Include all options in updates — The API replaces, not appends
  4. Normalise values carefully — Watch for whitespace and special characters
  5. Handle concurrency — Multiple simultaneous updates can conflict
  6. 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.