Associate Multiple Custom Objects to a single Contact

I’ve created a Custom Object that creates a custom invoice record based on a table I’ve imported into HubDB.
I’m trying to bulk create multiple custom invoice records using Hubspots APIs. At this time, I’m able to successfully create the records, however, for some reason, I can’t get the association between the Custom Object Records to work. Each Invoice can be associated with a contact based on the contact’s email, and each contact can have multiple Custom Invoice Records associated with it.
I’m using workflows to accomplish this, and this is my custom code step:

// Example Node.js code for an Operations Hub Custom Code Action

const axios = require('axios');

exports.main = async (event, callback) => {
 try {
 // 1. Retrieve your HubSpot Private App token from secrets
 const hubspotToken = process.env.marketing_secret;

 // 2. Configuration / Constants
 const HUBDB_TABLE_ID = '106013792';
 const CUSTOM_OBJECT_NAME = '2-39269971'; 
 // ^ Make sure this is the correct "objectTypeId" or "fullyQualifiedName" 
 // for your AR Invoices custom object.

 const PAGE_LIMIT = 100; // Max 100 rows per HubDB call
 const BATCH_SIZE = 50; // Number of items per batch read/update to avoid timeouts

 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // 1) FETCH ALL ROWS FROM HUBDB (WITH PAGINATION)
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 let allRows = [];
 let after = undefined;
 let hasMore = true;

 while (hasMore) {
 const hubdbUrl = `https://api.hubapi.com/cms/v3/hubdb/tables/${HUBDB_TABLE_ID}/rows`;
 const params = { limit: PAGE_LIMIT };
 if (after) {
 params.after = after;
 }

 const hubdbResponse = await axios.get(hubdbUrl, {
 headers: {
 Authorization: `Bearer ${hubspotToken}`,
 'Content-Type': 'application/json',
 },
 params,
 });

 const data = hubdbResponse.data;
 const rows = data.results || [];
 allRows = allRows.concat(rows);

 if (data.paging && data.paging.next && data.paging.next.after) {
 after = data.paging.next.after;
 } else {
 hasMore = false;
 }
 }

 console.log(`Fetched ${allRows.length} rows from HubDB.`);

 if (allRows.length === 0) {
 return callback({ message: 'No rows found in HubDB table.' });
 }

 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // 2) TRANSFORM THE ROWS INTO A SIMPLE ARRAY OF INVOICE DATA
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 let invoices = [];
 for (const row of allRows) {
 const vals = row.values;

 const invoiceId = vals.invoice_id;
 const invoiceAge = vals.invoice_age;
 const contactEmail = vals.invoice_contact_email_;
 const invoiceOpen = vals.invoice_open_;
 const shiftDate = vals.invoice_shift_date_ar_report_;
 const professionalName = vals.professional_name_ar_report_;

 // Skip row if missing required fields
 if (!invoiceId || !contactEmail) {
 continue;
 }

 // Add to our array
 invoices.push({
 invoiceId: invoiceId.toString(),
 invoiceAge,
 contactEmail,
 invoiceOpen,
 shiftDate,
 professionalName
 });
 }

 console.log(`Transformed rows into ${invoices.length} invoice objects.`);

 if (invoices.length === 0) {
 return callback({ message: 'No valid invoices to process.' });
 }

 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // 3) CHUNK THE INVOICES FOR BATCH PROCESSING
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 const invoiceChunks = chunkArray(invoices, BATCH_SIZE);

 let totalUpserted = 0;

 for (const chunk of invoiceChunks) {
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // (A) BATCH READ CONTACTS BY EMAIL
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 const emails = [...new Set(chunk.map((inv) => inv.contactEmail))]; // unique emails
 const contactsMap = await batchReadContacts(emails, hubspotToken); 
 // returns an object: { "email": "contactId", ... }

 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // (B) BUILD INPUTS FOR CUSTOM OBJECT BATCH UPSERT
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 const upsertInputs = [];
 for (const inv of chunk) {
 const contactId = contactsMap[inv.contactEmail];
 if (!contactId) {
 console.log(`No contact found for email: ${inv.contactEmail}. Skipping invoice ${inv.invoiceId}.`);
 continue;
 }

 // "idProperty" must match the name of your *unique* property in the custom object.
 // If "invoice_id" is set as unique, you use "invoice_id" here.
 const record = {
 properties: {
 invoice_id: inv.invoiceId, // Must match the unique property
 invoice_age: inv.invoiceAge,
 invoice_open_: inv.invoiceOpen,
 invoice_contact_email_: inv.contactEmail,
 invoice_shift_date_ar_report_: inv.shiftDate,
 professional_name_ar_report_: inv.professionalName
 },
 id: inv.invoiceId,
 idProperty: 'invoice_id', // Telling HubSpot to upsert based on this property

 // "associations" with the Contact
 associations: [
 {
 to: {
 id: contactId,
 },
 types: [
 {
 associationCategory: 'HUBSPOT_DEFINED',
 associationTypeId: 145,
 },
 ],
 },
 ],
 };

 upsertInputs.push(record);
 }

 if (upsertInputs.length === 0) {
 console.log(`No invoices to upsert in this chunk.`);
 continue;
 }

 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // (C) CALL THE "BATCH UPSERT" ENDPOINT FOR CUSTOM OBJECT
 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 // Endpoint: POST /crm/v3/objects/{objectType}/batch/upsert

 const batchUpsertUrl = `https://api.hubapi.com/crm/v3/objects/${CUSTOM_OBJECT_NAME}/batch/upsert`;

 const upsertBody = {
 inputs: upsertInputs,
 };

 const upsertResponse = await axios.post(batchUpsertUrl, upsertBody, {
 headers: {
 Authorization: `Bearer ${hubspotToken}`,
 'Content-Type': 'application/json',
 },
 });

 const upsertResults = upsertResponse.data;
 console.log(`Upserted ${upsertResults.results.length} AR Invoices in this chunk.`);
 console.log("UpsertResponse:", JSON.stringify(upsertResults, null, 2));
 totalUpserted += upsertResults.results.length;
 }

 console.log(`All chunks processed. Total AR Invoices upserted: ${totalUpserted}.`);
 callback({ message: 'Success', totalUpserted });
 } catch (error) {
 console.error('Error in invoice sync:', error.response?.data || error.message);
 callback({ message: 'Failed', error: error.message });
 }
};

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// HELPER FUNCTIONS
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

/**
 * chunkArray(array, size)
 * - Splits an array into smaller arrays of length "size".
 */
function chunkArray(array, size) {
 const results = [];
 for (let i = 0; i < array.length; i += size) {
 results.push(array.slice(i, i + size));
 }
 return results;
}

/**
 * batchReadContacts(emails, hubspotToken)
 * - Accepts an array of unique emails
 * - Uses the Batch Read endpoint for Contacts to get their IDs by email.
 * - Returns a map: { email: contactId, ... }
 */
async function batchReadContacts(emails, hubspotToken) {
 if (emails.length === 0) {
 return {};
 }

 const BATCH_READ_LIMIT = 100;
 const emailChunks = chunkArray(emails, BATCH_READ_LIMIT);

 const emailToId = {};

 for (const chunk of emailChunks) {
 const url = 'https://api.hubapi.com/crm/v3/objects/contacts/batch/read';
 const body = {
 idProperty: 'email',
 properties: ['email'], // or any other properties you need
 inputs: chunk.map((email) => ({ id: email })),
 };

 const readResponse = await axios.post(url, body, {
 headers: {
 Authorization: `Bearer ${hubspotToken}`,
 'Content-Type': 'application/json',
 },
 });

 const foundContacts = readResponse.data.results || [];
 for (const contact of foundContacts) {
 const cEmail = contact.properties.email;
 emailToId[cEmail] = contact.id;
 }
 }

 return emailToId;
}

Hey, @EDokken :waving_hand: If we take a step back from your custom code, have you been able to make these associations work, even in a simplified manner, using something like Postman?

Thanks! — Jaycee

Hey @Jaycee_Lewis ,
I was eventually able to get the associations to work using the individual association endpoint rather than the bulk endpoint here:

PUT /crm/v3/objects/${from.type}/${from.id}/associations/${to.type}/${to.id}/${ASSOCIATION_TYPE}

Additionally, I realized that I was using the ‘EQ’ operator when attempting to search for contacts that had the same email associated with my custom object, and instead needed to use the ‘IN’ operator to ensure that the associated contacts could be found.
Doing this seemed to resolve my issue, and I ended up with the following solution:

/**
 * 
 * - Reads HubDB rows in pages (v3 endpoint).
 * - Collects unique emails and chunk-searches Contacts by email using the `IN` operator.
 * - Splits AR Invoices by whether they exist (searched by invoice_id) or not, then updates or creates.
 * - Associates both newly created and updated records to Contacts by email using the per-association endpoint.
 * 
 */

const axios = require('axios');

exports.main = async (event, callback) => {

 // 1) Create an Axios instance for HubSpot
 const hubspotClient = axios.create({
 baseURL: 'https://api.hubapi.com',
 headers: {
 // Use your private app token in Ops Hub Secrets Manager
 Authorization: `Bearer ${process.env.marketing_secret}`,
 'Content-Type': 'application/json',
 },
 });

 // -- CONFIG VALUES (Adjust these) --
 const HUBDB_TABLE_ID = '106013792'; // HubDB table ID
 const HUBDB_PAGE_LIMIT = 50; // Rows per page from HubDB
 const CUSTOM_OBJECT_NAME = '2-39269971'; // AR Invoices objectTypeId
 const ASSOCIATION_TYPE = 'ar_invoices'; // Association type name
 const BATCH_PROCESS_LIMIT = 20; // How many invoice records to upsert at once
 const CONTACT_SEARCH_CHUNK = 20; // Emails per chunk for CRM search
 // ----------------

 // Helper to chunk arrays
 function chunkArray(array, size) {
 const chunks = [];
 for (let i = 0; i < array.length; i += size) {
 chunks.push(array.slice(i, i + size));
 }
 return chunks;
 }

 try {
 // ------------------------------------------------------------------
 // STEP A: Read All Rows from HubDB (v3) + Collect Emails
 // ------------------------------------------------------------------
 let after = undefined;
 let hasMore = true;
 const allRows = [];
 const emailSet = new Set();

 console.log('Reading HubDB rows...');
 while (hasMore) {
 const res = await hubspotClient.get(
 `/cms/v3/hubdb/tables/${HUBDB_TABLE_ID}/rows/draft`,
 { params: { limit: HUBDB_PAGE_LIMIT, after } }
 );
 const rows = res.data?.results || [];
 if (!rows.length) break;

 for (const row of rows) {
 allRows.push(row);
 const email = row.values?.invoice_contact_email_;
 if (email && typeof email === 'string') {
 emailSet.add(email.trim().toLowerCase());
 }
 }

 // Check if there's a next page
 if (res.data?.paging?.next?.after) {
 after = res.data.paging.next.after;
 hasMore = true;
 } else {
 hasMore = false;
 }
 }

 console.log(`Loaded ${allRows.length} HubDB rows; found ${emailSet.size} unique email(s).`);

 // ------------------------------------------------------------------
 // STEP B: Chunked Search for Contacts by Email -> emailToContactIdMap
 // ------------------------------------------------------------------
 const emailArr = Array.from(emailSet);
 const emailChunks = chunkArray(emailArr, CONTACT_SEARCH_CHUNK);
 const emailToContactIdMap = {};

 console.log(`Searching contacts in ${emailChunks.length} chunk(s)...`);

 for (const chunk of emailChunks) {
 // Use the IN operator to search for multiple emails in a single filter
 const searchRequest = {
 filterGroups: [
 {
 filters: [
 {
 propertyName: 'email',
 operator: 'IN',
 values: chunk,
 },
 ],
 },
 ],
 properties: ['email'],
 limit: 100,
 };

 console.log(`Searching for Contacts with emails: ${JSON.stringify(chunk)}`);

 try {
 const searchRes = await hubspotClient.post('/crm/v3/objects/contacts/search', searchRequest);
 const foundContacts = searchRes.data?.results || [];
 console.log(`Found Contacts in this chunk: ${foundContacts.length}`);

 for (const c of foundContacts) {
 const foundEmail = c.properties?.email?.toLowerCase();
 if (foundEmail) {
 emailToContactIdMap[foundEmail] = c.id;
 }
 }
 } catch (err) {
 console.error('Error searching contact chunk:', err.response?.data || err.message);
 }
 }

 console.log(`Built emailToContactIdMap with ${Object.keys(emailToContactIdMap).length} entries.`);
 console.log('emailToContactIdMap:', emailToContactIdMap);

 // ------------------------------------------------------------------
 // STEP C: Create an array of "invoicesData" from HubDB rows
 // ------------------------------------------------------------------
 const invoicesData = allRows.map((row) => {
 const v = row.values;
 return {
 invoice_id: v.invoice_id,
 properties: {
 invoice_id: v.invoice_id,
 invoice_age: v.invoice_age,
 invoice_open_: v.invoice_open_,
 invoice_contact_email_: v.invoice_contact_email_,
 invoice_shift_date_ar_report_: v.invoice_shift_date_ar_report_,
 professional_name_ar_report_: v.professional_name_ar_report_,
 },
 };
 });

 let totalUpdated = 0;
 let totalCreated = 0;
 let totalAssociated = 0;

 // Upsert (search+update or create) in chunks to avoid timeouts
 const invoiceChunks = chunkArray(invoicesData, BATCH_PROCESS_LIMIT);
 console.log(`Upserting invoices in ${invoiceChunks.length} chunk(s).`);

 for (const chunk of invoiceChunks) {
 // ----------------------------------------------------------------
 // STEP C1: Search existing records by invoice_id in this chunk
 // ----------------------------------------------------------------
 const invoiceIds = chunk
 .map((item) => item.invoice_id)
 .filter((id) => !!id);

 // Map of invoice_id -> existing HubSpot record ID
 let existingMap = {};

 if (invoiceIds.length > 0) {
 const invoiceSearchRequest = {
 filterGroups: [
 {
 filters: [
 {
 propertyName: 'invoice_id',
 operator: 'IN',
 values: invoiceIds,
 },
 ],
 },
 ],
 properties: ['invoice_id'],
 limit: 100,
 };

 console.log(`Searching for existing AR Invoices with invoice_ids: ${JSON.stringify(invoiceIds)}`);

 try {
 const searchRes = await hubspotClient.post(
 `/crm/v3/objects/${CUSTOM_OBJECT_NAME}/search`,
 invoiceSearchRequest
 );
 const foundRecords = searchRes.data?.results || [];
 for (const rec of foundRecords) {
 const recProps = rec.properties || {};
 const foundIdVal = recProps.invoice_id; 
 if (foundIdVal) {
 existingMap[foundIdVal] = rec.id;
 }
 }
 console.log(`Found ${foundRecords.length} existing invoice(s) in this chunk.`);
 } catch (err) {
 console.error('Error searching existing invoices:', err.response?.data || err.message);
 }
 }

 // Separate into "toUpdate" vs "toCreate"
 const toUpdate = [];
 const toCreate = [];

 for (const item of chunk) {
 const existingRecordId = existingMap[item.invoice_id];
 if (existingRecordId) {
 // Update this record
 toUpdate.push({
 id: existingRecordId,
 properties: item.properties,
 });
 } else {
 // Create a new record
 toCreate.push({ properties: item.properties });
 }
 }

 // ----------------------------------------------------------------
 // STEP C2: Batch Update existing records
 // ----------------------------------------------------------------
 let updatedRecords = [];
 if (toUpdate.length > 0) {
 console.log(`Batch updating ${toUpdate.length} invoice(s)...`);
 try {
 const updateRes = await hubspotClient.post(
 `/crm/v3/objects/${CUSTOM_OBJECT_NAME}/batch/update`,
 { inputs: toUpdate }
 );
 updatedRecords = updateRes.data?.results || [];
 totalUpdated += updatedRecords.length;
 console.log(`Updated ${updatedRecords.length} invoice(s).`);
 } catch (err) {
 console.error('Error batch-updating invoices:', err.response?.data || err.message);
 }
 }

 // ----------------------------------------------------------------
 // STEP C3: Batch Create new records
 // ----------------------------------------------------------------
 let newlyCreated = [];
 if (toCreate.length > 0) {
 console.log(`Batch creating ${toCreate.length} new invoice(s)...`);
 try {
 const createRes = await hubspotClient.post(
 `/crm/v3/objects/${CUSTOM_OBJECT_NAME}/batch/create`,
 { inputs: toCreate }
 );
 newlyCreated = createRes.data?.results || [];
 totalCreated += newlyCreated.length;
 console.log(`Created ${newlyCreated.length} new invoice(s).`);
 } catch (err) {
 console.error('Error batch-creating invoices:', err.response?.data || err.message);
 }
 }

 // ----------------------------------------------------------------
 // STEP C4: Associate both newly created and updated records to Contacts
 // ----------------------------------------------------------------
 const associationInputs = [];

 // Handle associations for newly created records
 for (const rec of newlyCreated) {
 const recProps = rec.properties || {};
 const rawEmail = recProps.invoice_contact_email_?.toLowerCase();
 if (!rawEmail) {
 console.warn(`Invoice ID ${rec.id} has no email to associate.`);
 continue;
 }

 const contactId = emailToContactIdMap[rawEmail];
 if (!contactId) {
 console.warn(`No matching contact found for email: ${rawEmail}`);
 continue;
 }

 associationInputs.push({
 from: { id: rec.id, type: CUSTOM_OBJECT_NAME },
 to: { id: contactId, type: 'contacts' },
 });
 }

 // Handle associations for updated records
 for (const rec of updatedRecords) {
 const recProps = rec.properties || {};
 const rawEmail = recProps.invoice_contact_email_?.toLowerCase();
 if (!rawEmail) {
 console.warn(`Invoice ID ${rec.id} has no email to associate.`);
 continue;
 }

 const contactId = emailToContactIdMap[rawEmail];
 if (!contactId) {
 console.warn(`No matching contact found for email: ${rawEmail}`);
 continue;
 }

 associationInputs.push({
 from: { id: rec.id, type: CUSTOM_OBJECT_NAME },
 to: { id: contactId, type: 'contacts' },
 // associationType is handled via the URL
 });
 }

 // Perform individual associations using the per-association endpoint
 if (associationInputs.length > 0) {
 console.log(`Associating ${associationInputs.length} invoice(s) to contacts...`);

 // Create a single association using PUT
 const createAssociation = async (assoc) => {
 const { from, to } = assoc;
 const url = `/crm/v3/objects/${from.type}/${from.id}/associations/${to.type}/${to.id}/${ASSOCIATION_TYPE}`;
 console.log(`Creating association: ${url}`);
 try {
 await hubspotClient.put(url, {});
 totalAssociated++;
 console.log(`Successfully associated AR Invoice (${from.id}) with Contact (${to.id}).`);
 } catch (err) {
 console.error(`Error associating AR Invoice (${from.id}) with Contact (${to.id}):`, err.response?.data || err.message);
 }
 };

 // Create an array of association promises
 const associationPromises = associationInputs.map(assoc => createAssociation(assoc));

 // Execute all association promises concurrently
 await Promise.allSettled(associationPromises);

 console.log(`Finished associating ${associationInputs.length} invoice(s) to contacts.`);
 } else {
 console.log('No associations to create in this chunk.');
 }
 }

 // ------------------------------------------------------------------
 // STEP 😧 DONE
 // ------------------------------------------------------------------
 const finalMsg = `Done! Updated: ${totalUpdated}, Created: ${totalCreated}, Associated: ${totalAssociated}`;
 console.log(finalMsg);
 return callback({
 outputFields: { finalMsg },
 });
 } catch (error) {
 console.error('Unexpected error:', error.response?.data || error.message);
 return callback({
 outputFields: { error: error.message },
 });
 }
};

Hey, @EDokken :waving_hand: Thank you so much for looping back and sharing your solution :raising_hands: — Jaycee