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;
}