skip to content
san.is
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:

  1. 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

  1. Association label created: In Settings → Objects → Associations, create a “Active Deal” label for Deal-to-Account associations
  2. Note the association type ID: View API details on the label to get the typeId
  3. 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:

  1. Navigate to Settings → Objects → Your Custom Object → Associations
  2. Find your “Active Deal” label
  3. Click the three dots → “View API details”
  4. Note the associationTypeId value
  5. Update the ACTIVE_DEAL_TYPE_ID constant in the code

Alternatively, query the API:

Terminal window
GET /crm/v4/associations/your_custom_object/deals/labels

Workflow 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 needed
const 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:

  1. Workflow 1 (Deal-based): When deal closes, apply “Active Deal” label
  2. 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

  1. Exclusive association labels require custom code - native workflows can’t compare across records
  2. Use the v4 associations API to read and modify labels programmatically
  3. Sort associated records by relevant criteria (close date) to determine which gets the label
  4. Consider your trigger strategy: real-time vs scheduled cleanup
  5. 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.