I am attempting to build a custom coded workflow that will apply an association label to the most recently updated custom object associated with a contact.
1. Search to see if there is more than one custom object associated, if only one, apply active policy label to that object and contact.
2. If thereis more than 1 custom object on the record, add the association label to the most updated custom object and remove it from the older one.
It is running successfully but not adding the label. Here is the code I am using.
For the scopes, we have contact read and write and custom object read and write.
/**
* Policy-based Custom Code (Node 18)
* Discovers the correct associationTypeId + direction for label `active_policy`
* and applies it so exactly one Policy per Contact is labeled (winner = most recent by hs_lastmodifieddate).
* Logs full diagnostics and returns them via outputFields.debug.
*/
const HUBSPOT_API_BASE = ‘https://api.hubapi.com’;
const POLICY_TYPE_ID = ‘2-49987112’; // custom object type id
const CONTACTS_TYPE = ‘contacts’;
const SORT_PROPERTY = ‘hs_lastmodifieddate’;
const LABEL_INTERNAL = ‘active_policy’;
async function hsFetch(url, options) {
const res = await fetch(url, options);
const txt = await res.text();
if (!res.ok) throw new Error(`${options?.method || ‘GET’} ${url} → ${res.status}: ${txt.slice(0, 1200)}`);
try { return JSON.parse(txt); } catch { return {}; }
}
async function hsFetchAllow404(url, options) {
const res = await fetch(url, options);
const txt = await res.text();
if (res.status === 404) return null;
if (!res.ok) throw new Error(`${options?.method || ‘GET’} ${url} → ${res.status}: ${txt.slice(0, 1200)}`);
try { return JSON.parse(txt); } catch { return {}; }
}
async function listAssociations(headers, fromType, fromId, toType) {
let after, out = [];
do {
const u = new URL(`${HUBSPOT_API_BASE}/crm/v4/objects/${fromType}/${fromId}/associations/${toType}`);
if (after) u.searchParams.set(‘after’, after);
const d = await hsFetch(u.toString(), { headers });
(d.results || []).forEach(x => out.push({
toObjectId: String(x.toObjectId),
labels: Array.isArray(x.labels) ? x.labels : []
}));
after = d.paging?.next?.after;
} while (after);
return out;
}
async function batchReadPolicies(headers, ids) {
if (!ids.length) return [];
const d = await hsFetch(`${HUBSPOT_API_BASE}/crm/v3/objects/${POLICY_TYPE_ID}/batch/read`, {
method: ‘POST’,
headers,
body: JSON.stringify({ properties:[SORT_PROPERTY], inputs: ids.map(id => ({ id:String(id) })) })
});
return (d.results || []).map(o => ({
id: String(o.id),
lastModified: o.properties?.[SORT_PROPERTY] ? Date.parse(o.properties[SORT_PROPERTY]) : 0
}));
}
// Discover label definitions for a direction (fromType → toType)
async function discoverLabelDefs(headers, fromType, toType) {
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/labels`;
const d = await hsFetch(url, { headers }); // { results: [ {associationTypeId, label:{internalName,name}} ] }
const results = Array.isArray(d.results) ? d.results : [];
const hit = results.find(x =>
x?.label?.internalName === LABEL_INTERNAL || x?.label?.name === LABEL_INTERNAL
);
return { all: results, defId: hit ? String(hit.associationTypeId) : null };
}
// Try both shapes to set labels for ONE association
async function setLabels(headers, fromType, fromId, toType, toId, defId, labels) {
// A) definition id in PATH
const urlA = `${HUBSPOT_API_BASE}/crm/v4/objects/${fromType}/${fromId}/associations/${toType}/${toId}/labels/${defId}`;
const resA = await hsFetchAllow404(urlA, { method: ‘PUT’, headers, body: JSON.stringify({ labels }) });
if (resA) return { ok: true, mode: ‘path’ };
// B) definition id in BODY
const urlB = `${HUBSPOT_API_BASE}/crm/v4/objects/${fromType}/${fromId}/associations/${toType}/${toId}/labels`;
const resB = await hsFetchAllow404(urlB, {
method: ‘PUT’, headers, body: JSON.stringify({ labels, associationTypeId: Number(defId) })
});
if (resB) return { ok: true, mode: ‘body’ };
return { ok: false, mode: null };
}
exports.main = async (event, callback) => {
const token = process.env.PRIVATE_APP_TOKEN;
if (!token) {
console.error(‘Missing PRIVATE_APP_TOKEN secret.’);
return callback({ outputFields: { debug: JSON.stringify({ error: ‘Missing PRIVATE_APP_TOKEN’ }) } });
}
const policyId =
event?.inputFields?.policyId ||
event?.inputFields?.hs_object_id ||
event?.object?.objectId ||
null;
if (!policyId) {
console.error(‘Missing policyId. Map input “policyId” to {{ object.hs_object_id }}.’);
return callback({ outputFields: { debug: JSON.stringify({ error: ‘Missing policyId’ }) } });
}
const headers = { Authorization: `Bearer ${token}`, ‘Content-Type’: ‘application/json’ };
// Ensure policy exists
await hsFetch(`${HUBSPOT_API_BASE}/crm/v3/objects/${POLICY_TYPE_ID}/${policyId}?properties=${encodeURIComponent(SORT_PROPERTY)}`, { headers });
// Contacts associated with this policy (Policy → Contacts)
const contactAssocs = await listAssociations(headers, POLICY_TYPE_ID, policyId, CONTACTS_TYPE);
const contactIds = contactAssocs.map(a => a.toObjectId);
if (!contactIds.length) {
const payload = { success: true, info: ‘No contacts associated with this policy.’, policyId };
console.log(JSON.stringify(payload, null, 2));
return callback({ outputFields: { debug: JSON.stringify(payload) } });
}
// Discover label defs in BOTH directions
const fwd = await discoverLabelDefs(headers, CONTACTS_TYPE, POLICY_TYPE_ID); // contacts → policy
const rev = await discoverLabelDefs(headers, POLICY_TYPE_ID, CONTACTS_TYPE); // policy → contacts
// Choose the direction that actually has the label
// If both directions expose it, prefer reverse so it shows on the Policy page
let APPLY_FROM, APPLY_TO, DEF_ID;
if (rev.defId) {
APPLY_FROM = POLICY_TYPE_ID; APPLY_TO = CONTACTS_TYPE; DEF_ID = rev.defId;
} else if (fwd.defId) {
APPLY_FROM = CONTACTS_TYPE; APPLY_TO = POLICY_TYPE_ID; DEF_ID = fwd.defId;
} else {
const payload = {
success: false,
error: `Label “${LABEL_INTERNAL}” not found by API in either direction.`,
labelsForward: fwd.all, labelsReverse: rev.all
};
console.log(JSON.stringify(payload, null, 2));
return callback({ outputFields: { debug: JSON.stringify(payload) } });
}
const actions = [];
// For each contact, ensure exactly one active label
for (const contactId of contactIds) {
// All policies for this contact (Contact → Policy)
const policyAssocsForContact = await listAssociations(headers, CONTACTS_TYPE, contactId, POLICY_TYPE_ID);
if (!policyAssocsForContact.length) {
actions.push({ contactId, info: ‘Contact has no policy associations.’ });
continue;
}
// Determine winner (single policy or most recent by hs_lastmodifieddate)
let winnerId = null;
if (policyAssocsForContact.length === 1) {
winnerId = policyAssocsForContact[0].toObjectId;
} else {
const policyIds = […new Set(policyAssocsForContact.map(p => p.toObjectId))];
const details = await batchReadPolicies(headers, policyIds);
if (details.length) {
details.sort((a,b)=> (b.lastModified||0)-(a.lastModified||0));
winnerId = details[0].id;
}
}
if (!winnerId) {
actions.push({ contactId, warning: ‘No winner policy resolved.’ });
continue;
}
// Apply label to winner and clear from others in the chosen direction
for (const assoc of policyAssocsForContact) {
const pId = assoc.toObjectId;
const isWinner = pId === winnerId;
const desired = isWinner ? [LABEL_INTERNAL] : [];
// Map from/to IDs for the chosen direction
const fromId = (APPLY_FROM === POLICY_TYPE_ID) ? pId : contactId;
const toId = (APPLY_TO === POLICY_TYPE_ID) ? pId : contactId;
const res = await setLabels(headers, APPLY_FROM, fromId, APPLY_TO, toId, DEF_ID, desired);
actions.push({
step: ‘apply’,
direction: `${APPLY_FROM}→${APPLY_TO}`,
defId: DEF_ID,
contactId, policyId: pId,
isWinner,
appliedLabels: desired,
ok: res.ok,
mode: res.mode
});
}
// Verify from the perspective that will be visible on the Policy page
const verifyFromPolicy = await listAssociations(headers, POLICY_TYPE_ID, winnerId, CONTACTS_TYPE);
actions.push({ step: ‘verify’, winnerPolicyId: winnerId, seenFromPolicy: verifyFromPolicy });
}
const payload = {
success: true,
ranForPolicyId: String(policyId),
chosenDirection: `${APPLY_FROM}→${APPLY_TO}`,
defIdUsed: DEF_ID,
contactsTouched: contactIds,
diagnostics: { labelsForward: fwd.all, labelsReverse: rev.all },
actions
};
console.log(JSON.stringify(payload, null, 2));
return callback({ outputFields: { debug: JSON.stringify(payload) } });
};

