Dynamic Association Labels in HubSpot
/ 7 min read
Table of Contents
Association labels in HubSpot are powerful for describing relationships between records. But what happens when you need a label that can only exist on one record at a time - like an “Active Deal” label that should always point to the most recent closed-won deal?
Standard workflows can apply labels, but managing exclusivity - ensuring only one record has the label - requires a more sophisticated approach.
The Business Requirement
A company tracks customer accounts using a custom object. Each account can have multiple associated deals over time. They need to instantly identify which deal represents the current active engagement:
- When a deal closes as won, it becomes the “Active Deal”
- Any previously labelled “Active Deal” should lose that designation
- The label should automatically transfer as new deals close
- Historical deals remain associated but without the special label
Why This Is Harder Than It Looks
HubSpot’s workflow actions for association labels can:
- ✅ Apply a label to an association
- ✅ Remove a label from an association
- ❌ Find all records with a specific label and remove it
- ❌ Compare dates across associated records to find the “most recent”
The challenge: you can’t create a single workflow that says “remove the Active Deal label from all other deals associated with this account before applying it to this deal.”
The Two-Workflow Solution
We solve this with complementary workflows working together.
Workflow 1: Apply “Active Deal” Label
Trigger: Deal Stage moves to Closed Won
Actions:
- Apply association label “Active Deal” between the deal and associated custom object (Account)
This workflow is straightforward - it simply applies the label when a deal closes.
Workflow 2: Clean Up Old Labels
Trigger: Custom Object (Account) based workflow, triggered when “Active Deal” association exists
This workflow runs on the account and removes the label from any deal that isn’t the most recent.
But here’s the challenge: native workflow actions can’t compare deal close dates to determine which is “most recent.” This is where custom code comes in.
The Custom Coded Solution
Prerequisites
- Association label created: In Settings → Objects → Associations, create a “Active Deal” label for Deal-to-Account associations
- Note the association type ID: View API details on the label to get the typeId
- Private app: Create one with associations.read and associations.write scopes
The Custom Code Action
Create an Account-based workflow that triggers when a deal is updated. Add a custom coded action:
const hubspot = require('@hubspot/api-client');
exports.main = async (event, callback) => { const hubspotClient = new hubspot.Client({ accessToken: process.env.HUBSPOT_API_KEY });
const accountId = event.object.objectId; const ACTIVE_DEAL_TYPE_ID = 42; // Replace with your actual association type ID const CUSTOM_OBJECT_TYPE = 'accounts'; // Replace with your object API name
try { // Step 1: Get all deals associated with this account const associations = await hubspotClient.crm.associations.v4.basicApi .getPage(CUSTOM_OBJECT_TYPE, accountId, 'deals', undefined, 500);
if (!associations.results || associations.results.length === 0) { callback({ outputFields: { status: 'no_deals' } }); return; }
// Step 2: Fetch deal details to find close dates const dealIds = associations.results.map(a => a.toObjectId);
const dealsResponse = await hubspotClient.crm.deals.batchApi.read({ inputs: dealIds.map(id => ({ id: id.toString() })), properties: ['dealstage', 'closedate', 'dealname'] });
// Step 3: Filter to closed-won deals and sort by close date const closedWonDeals = dealsResponse.results .filter(deal => deal.properties.dealstage === 'closedwon') .sort((a, b) => { const dateA = new Date(a.properties.closedate || 0); const dateB = new Date(b.properties.closedate || 0); return dateB - dateA; // Descending - most recent first });
if (closedWonDeals.length === 0) { callback({ outputFields: { status: 'no_closed_won' } }); return; }
const mostRecentDealId = closedWonDeals[0].id;
// Step 4: Check which deals currently have the Active Deal label const dealsWithActiveLabel = [];
for (const assoc of associations.results) { const hasActiveLabel = assoc.associationTypes?.some( type => type.typeId === ACTIVE_DEAL_TYPE_ID ); if (hasActiveLabel) { dealsWithActiveLabel.push(assoc.toObjectId.toString()); } }
// Step 5: Remove label from any deal that isn't the most recent for (const dealId of dealsWithActiveLabel) { if (dealId !== mostRecentDealId) { await hubspotClient.crm.associations.v4.basicApi.archive( CUSTOM_OBJECT_TYPE, accountId, 'deals', dealId, [{ associationCategory: 'USER_DEFINED', associationTypeId: ACTIVE_DEAL_TYPE_ID }] ); console.log(`Removed Active Deal label from deal ${dealId}`); } }
// Step 6: Apply label to most recent if not already there if (!dealsWithActiveLabel.includes(mostRecentDealId)) { await hubspotClient.crm.associations.v4.basicApi.create( CUSTOM_OBJECT_TYPE, accountId, 'deals', mostRecentDealId, [{ associationCategory: 'USER_DEFINED', associationTypeId: ACTIVE_DEAL_TYPE_ID }] ); console.log(`Applied Active Deal label to deal ${mostRecentDealId}`); }
callback({ outputFields: { status: 'success', active_deal_id: mostRecentDealId, labels_removed: dealsWithActiveLabel.filter(id => id !== mostRecentDealId).length } });
} catch (error) { console.error('Error:', error.message); callback({ outputFields: { status: 'error', message: error.message } }); }};Finding Your Association Type ID
Before running this workflow, you need to discover the type ID for your custom association label:
- Navigate to Settings → Objects → Your Custom Object → Associations
- Find your “Active Deal” label
- Click the three dots → “View API details”
- Note the
associationTypeIdvalue - Update the
ACTIVE_DEAL_TYPE_IDconstant in the code
Alternatively, query the API:
GET /crm/v4/associations/your_custom_object/deals/labelsWorkflow Trigger Strategy
Option A: Trigger on Deal Stage Change
Create a Deal-based workflow:
- Trigger: Deal Stage changes to Closed Won
- Action 1: Wait 2 minutes (allow association to be created)
- Action 2: Custom code action that finds the associated account and processes all deals
Option B: Trigger on Account Association Change
Create an Account-based workflow:
- Trigger: Deal association changes
- Action: Custom code action
Option C: Scheduled Nightly Cleanup
Create a scheduled workflow that processes all accounts:
- Trigger: Scheduled daily at 2 AM
- Enrollment: All accounts with associated deals
- Action: Custom code action
This approach is more reliable but less real-time.
Handling Edge Cases
Multiple Deals Closing Simultaneously
If two deals close on the same day, the code sorts by closedate. Add a secondary sort criterion:
.sort((a, b) => { const dateA = new Date(a.properties.closedate || 0); const dateB = new Date(b.properties.closedate || 0); if (dateB.getTime() === dateA.getTime()) { // Same date - use deal creation date or ID as tiebreaker return parseInt(b.id) - parseInt(a.id); } return dateB - dateA;});Deals Reopened After Closing
If a closed-won deal is moved back to an open stage, it should lose the Active Deal label:
const closedWonDeals = dealsResponse.results .filter(deal => deal.properties.dealstage === 'closedwon') // ...This filter automatically excludes reopened deals.
Large Numbers of Associated Deals
The batch read endpoint handles up to 100 deals. For accounts with more:
// Paginate if neededconst allDeals = [];let dealIdBatches = [];
for (let i = 0; i < dealIds.length; i += 100) { dealIdBatches.push(dealIds.slice(i, i + 100));}
for (const batch of dealIdBatches) { const response = await hubspotClient.crm.deals.batchApi.read({ inputs: batch.map(id => ({ id: id.toString() })), properties: ['dealstage', 'closedate', 'dealname'] }); allDeals.push(...response.results);}Alternative: Using HubSpot’s Native Workflow Actions
If you have fewer deals per account and don’t need custom date logic, you can partially solve this with native workflows:
- Workflow 1 (Deal-based): When deal closes, apply “Active Deal” label
- Workflow 2 (Account-based): When a new “Active Deal” association is detected, trigger email to ops team for manual cleanup
This hybrid approach works for low-volume scenarios but doesn’t scale.
Testing Checklist
- Create test account with multiple deals
- Close a deal - verify label applies
- Close a newer deal - verify label transfers
- Reopen a deal - verify label stays on most recent
- Test with deals closing on same day
- Verify workflow history shows correct operations
- Monitor API usage to stay within limits
Key Takeaways
- Exclusive association labels require custom code - native workflows can’t compare across records
- Use the v4 associations API to read and modify labels programmatically
- Sort associated records by relevant criteria (close date) to determine which gets the label
- Consider your trigger strategy: real-time vs scheduled cleanup
- Handle edge cases like ties, reopened deals, and high deal volumes
This pattern applies to any scenario where you need a “current” or “primary” designation that can only apply to one associated record at a time.
Building complex association logic in HubSpot? Connect with me on LinkedIn to discuss your implementation challenges.