skip to content
san.is
Table of Contents

Custom coded workflow actions are one of the most powerful features in HubSpot, and one of the least documented. The official docs show you the syntax, but they don’t show you how to solve real problems. It’s a bit like being given a piano and told “the keys make sounds” — technically accurate, but not especially illuminating.

After implementing dozens of custom coded actions across enterprise projects (and debugging far too many at 11 PM), I’ve developed a set of patterns that work reliably. This article covers the patterns I use most frequently, complete with production-ready code.

Requirements: Operations Hub Professional or Enterprise.

The Fundamentals

Before diving into patterns, let’s cover the essentials that trip people up. Trust me, you want to read this section — I’ve seen each of these cause actual tears.

The Execution Model

Custom coded actions run on AWS Lambda, managed by HubSpot. This means:

  • Timeout: Maximum 20 seconds (previously was shorter)
  • Memory: 128 MB
  • Cold starts: First execution may be slower
  • Stateless: No persistence between executions (with one exception, see below)

The Callback Pattern

Every custom coded action must call the callback function to return results:

exports.main = async (event, callback) => {
// Your logic here
callback({
outputFields: {
result: 'success',
data: someValue
}
});
};

Critical: If you don’t call the callback, the action will timeout and fail. Ask me how I know.

Accessing Secrets

Store API keys and tokens as secrets, not in code:

// Access via environment variables
const apiKey = process.env.MY_API_KEY;
const hubspotToken = process.env.HUBSPOT_TOKEN;

Important: You must select which secrets are available to each action in the UI. If you forget to select a secret, it won’t be accessible and you’ll get undefined. This will happen to you at least once, and you’ll spend 20 minutes debugging before remembering to check.

Accessing Input Properties

Properties are passed via event.inputFields:

exports.main = async (event, callback) => {
const email = event.inputFields['email'];
const firstName = event.inputFields['firstname'];
const recordId = event.object.objectId;
// ...
};

Console Logging

Use console.log() for debugging. Logs appear in workflow execution history:

console.log('Processing record:', event.object.objectId);
console.log('Input fields:', JSON.stringify(event.inputFields));

Limitation: Log output is truncated at ~4KB. Large objects will be cut off at exactly the point where the useful information was about to appear. This is a universal law.

Pattern 1: External API Data Enrichment

The most common pattern — call an external API and write data back to HubSpot. This is where custom code really earns its keep.

Example: Company Enrichment from Clearbit

const axios = require('axios');
exports.main = async (event, callback) => {
const domain = event.inputFields['domain'];
if (!domain) {
return callback({
outputFields: {
status: 'skipped',
reason: 'No domain provided'
}
});
}
try {
// Call Clearbit API
const response = await axios.get(
`https://company.clearbit.com/v2/companies/find?domain=${domain}`,
{
headers: {
'Authorization': `Bearer ${process.env.CLEARBIT_API_KEY}`
},
timeout: 15000 // 15 second timeout
}
);
const company = response.data;
callback({
outputFields: {
status: 'enriched',
industry: company.category?.industry || '',
employeeCount: company.metrics?.employees || 0,
annualRevenue: company.metrics?.estimatedAnnualRevenue || '',
linkedinUrl: company.linkedin?.handle
? `https://linkedin.com/company/${company.linkedin.handle}`
: '',
description: (company.description || '').substring(0, 500)
}
});
} catch (error) {
// Handle 404 (company not found) vs other errors
if (error.response?.status === 404) {
return callback({
outputFields: {
status: 'not_found',
reason: 'Company not in Clearbit database'
}
});
}
console.error('Clearbit error:', error.message);
callback({
outputFields: {
status: 'error',
reason: error.message
}
});
}
};

Follow-up actions: Use “Copy property value” actions to map the outputs to company properties.

Best Practices for External APIs

  1. Always set timeouts: Default axios timeout is infinite. Set it below 15 seconds.
  2. Handle rate limits: Check for 429 responses and implement backoff if needed.
  3. Validate inputs: Return early if required data is missing.
  4. Distinguish error types: Not found vs API error vs timeout need different handling.

Pattern 2: Conditional Record Updates via HubSpot API

Sometimes you need to update records based on logic that can’t be expressed in workflow branches. HubSpot’s branching is powerful, but it wasn’t designed for “if this and this but not that unless it’s a Thursday” scenarios.

Example: Weighted Lead Assignment

const hubspot = require('@hubspot/api-client');
exports.main = async (event, callback) => {
const hubspotClient = new hubspot.Client({
accessToken: process.env.HUBSPOT_TOKEN
});
const leadSource = event.inputFields['lead_source'];
const country = event.inputFields['country'];
const contactId = event.object.objectId;
// Define assignment rules
const assignmentRules = [
{
condition: () => leadSource === 'Partner Referral',
ownerId: '12345678', // Partner team lead
weight: 100
},
{
condition: () => country === 'United Kingdom',
ownerId: '23456789', // UK sales rep
weight: 80
},
{
condition: () => ['Germany', 'France', 'Spain'].includes(country),
ownerId: '34567890', // EMEA rep
weight: 70
},
{
condition: () => true, // Default
ownerId: '45678901', // General queue
weight: 0
}
];
// Find highest weight matching rule
const matchingRule = assignmentRules
.filter(rule => rule.condition())
.sort((a, b) => b.weight - a.weight)[0];
try {
await hubspotClient.crm.contacts.basicApi.update(contactId, {
properties: {
hubspot_owner_id: matchingRule.ownerId
}
});
callback({
outputFields: {
status: 'assigned',
assignedTo: matchingRule.ownerId
}
});
} catch (error) {
console.error('Assignment error:', error.message);
callback({
outputFields: {
status: 'error',
reason: error.message
}
});
}
};

Pattern 3: Cross-Object Data Aggregation

Calculate values from associated records that native workflows can’t handle. This is where people start asking “why isn’t this a native feature?” and I shrug sympathetically.

Example: Company Revenue Rollup from Deals

const hubspot = require('@hubspot/api-client');
exports.main = async (event, callback) => {
const hubspotClient = new hubspot.Client({
accessToken: process.env.HUBSPOT_TOKEN
});
const companyId = event.object.objectId;
try {
// Get associated deals
const associations = await hubspotClient.crm.associations.v4.basicApi.getPage(
'company',
companyId,
'deal',
undefined,
500
);
if (!associations.results || associations.results.length === 0) {
return callback({
outputFields: {
totalRevenue: 0,
dealCount: 0,
avgDealSize: 0
}
});
}
const dealIds = associations.results.map(a => a.toObjectId);
// Batch get deal properties
const deals = await hubspotClient.crm.deals.batchApi.read({
inputs: dealIds.map(id => ({ id })),
properties: ['amount', 'dealstage', 'closedate']
});
// Calculate metrics for closed-won deals only
const closedWonStages = ['closedwon', 'closed_won']; // Adjust for your pipeline
const wonDeals = deals.results.filter(deal =>
closedWonStages.includes(deal.properties.dealstage?.toLowerCase())
);
const totalRevenue = wonDeals.reduce((sum, deal) => {
return sum + (parseFloat(deal.properties.amount) || 0);
}, 0);
const avgDealSize = wonDeals.length > 0
? totalRevenue / wonDeals.length
: 0;
callback({
outputFields: {
totalRevenue: Math.round(totalRevenue * 100) / 100,
dealCount: wonDeals.length,
avgDealSize: Math.round(avgDealSize * 100) / 100
}
});
} catch (error) {
console.error('Rollup error:', error.message);
callback({
outputFields: {
totalRevenue: 0,
dealCount: 0,
avgDealSize: 0,
error: error.message
}
});
}
};

Pattern 4: Webhook to External System

Push data to external systems when HubSpot events occur.

Example: Slack Notification with Context

const axios = require('axios');
exports.main = async (event, callback) => {
const dealName = event.inputFields['dealname'];
const amount = event.inputFields['amount'];
const ownerEmail = event.inputFields['hubspot_owner_email'];
const companyName = event.inputFields['associated_company_name'];
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
const message = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: '🎉 Deal Closed Won!',
emoji: true
}
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Deal:*\n${dealName}`
},
{
type: 'mrkdwn',
text: `*Amount:*\n£${parseFloat(amount).toLocaleString()}`
},
{
type: 'mrkdwn',
text: `*Company:*\n${companyName || 'N/A'}`
},
{
type: 'mrkdwn',
text: `*Owner:*\n${ownerEmail}`
}
]
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View in HubSpot'
},
url: `https://app.hubspot.com/contacts/${process.env.PORTAL_ID}/deal/${event.object.objectId}`
}
]
}
]
};
try {
await axios.post(slackWebhookUrl, message, {
timeout: 10000
});
callback({
outputFields: {
status: 'sent'
}
});
} catch (error) {
console.error('Slack error:', error.message);
callback({
outputFields: {
status: 'failed',
error: error.message
}
});
}
};

Pattern 5: Date/Time Calculations

Native workflows struggle with complex date logic. Custom code makes it straightforward. If you’ve ever tried to calculate business days with workflow branches, I’m sorry.

Example: Calculate Business Days Until Renewal

exports.main = async (event, callback) => {
const renewalDate = event.inputFields['renewal_date'];
if (!renewalDate) {
return callback({
outputFields: {
businessDaysUntilRenewal: null,
renewalUrgency: 'unknown'
}
});
}
const today = new Date();
const renewal = new Date(renewalDate);
// Calculate business days (excluding weekends)
let businessDays = 0;
const current = new Date(today);
while (current < renewal) {
current.setDate(current.getDate() + 1);
const dayOfWeek = current.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
businessDays++;
}
}
// Determine urgency
let urgency;
if (businessDays < 0) {
urgency = 'overdue';
} else if (businessDays <= 5) {
urgency = 'critical';
} else if (businessDays <= 15) {
urgency = 'high';
} else if (businessDays <= 30) {
urgency = 'medium';
} else {
urgency = 'low';
}
callback({
outputFields: {
businessDaysUntilRenewal: businessDays,
renewalUrgency: urgency
}
});
};

Pattern 6: Data Transformation and Formatting

Clean, format, and transform data that arrives in inconsistent formats. Because apparently, there are 47 different ways to write a phone number and your contacts have discovered all of them.

Example: Phone Number Standardisation

exports.main = async (event, callback) => {
const rawPhone = event.inputFields['phone'];
const country = event.inputFields['country'] || 'GB';
if (!rawPhone) {
return callback({
outputFields: {
formattedPhone: '',
phoneValid: false
}
});
}
// Remove all non-numeric characters except +
let cleaned = rawPhone.replace(/[^\d+]/g, '');
// Country-specific formatting
const countryConfigs = {
'GB': { code: '44', length: 10, format: /(\d{4})(\d{3})(\d{3})/ },
'US': { code: '1', length: 10, format: /(\d{3})(\d{3})(\d{4})/ },
'DE': { code: '49', length: 10, format: /(\d{3})(\d{7})/ }
};
const config = countryConfigs[country] || countryConfigs['GB'];
// Handle different input formats
if (cleaned.startsWith('+')) {
// Already international format
} else if (cleaned.startsWith('00')) {
cleaned = '+' + cleaned.substring(2);
} else if (cleaned.startsWith('0')) {
// Local format - add country code
cleaned = '+' + config.code + cleaned.substring(1);
} else {
// Assume local without leading zero
cleaned = '+' + config.code + cleaned;
}
// Validate length (rough check)
const digitsOnly = cleaned.replace(/\D/g, '');
const isValid = digitsOnly.length >= 10 && digitsOnly.length <= 15;
callback({
outputFields: {
formattedPhone: cleaned,
phoneValid: isValid
}
});
};

Pattern 7: Search and Match

Find related records that don’t have explicit associations.

Example: Match Contact to Existing Company by Domain

const hubspot = require('@hubspot/api-client');
exports.main = async (event, callback) => {
const hubspotClient = new hubspot.Client({
accessToken: process.env.HUBSPOT_TOKEN
});
const email = event.inputFields['email'];
const contactId = event.object.objectId;
if (!email || !email.includes('@')) {
return callback({
outputFields: {
matchStatus: 'invalid_email',
matchedCompanyId: ''
}
});
}
// Extract domain from email
const domain = email.split('@')[1].toLowerCase();
// Skip common free email providers
const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com'];
if (freeProviders.includes(domain)) {
return callback({
outputFields: {
matchStatus: 'free_email',
matchedCompanyId: ''
}
});
}
try {
// Search for company with matching domain
const searchResponse = await hubspotClient.crm.companies.searchApi.doSearch({
filterGroups: [
{
filters: [
{
propertyName: 'domain',
operator: 'EQ',
value: domain
}
]
}
],
limit: 1
});
if (searchResponse.total === 0) {
return callback({
outputFields: {
matchStatus: 'no_match',
matchedCompanyId: ''
}
});
}
const company = searchResponse.results[0];
// Create association
await hubspotClient.crm.associations.v4.basicApi.create(
'contact',
contactId,
'company',
company.id,
[{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 1 }]
);
callback({
outputFields: {
matchStatus: 'matched',
matchedCompanyId: company.id,
matchedCompanyName: company.properties.name || ''
}
});
} catch (error) {
console.error('Match error:', error.message);
callback({
outputFields: {
matchStatus: 'error',
matchedCompanyId: '',
error: error.message
}
});
}
};

Pattern 8: Retry Logic with Exponential Backoff

For unreliable external APIs, implement retry logic. Because third-party APIs will fail, usually at the worst possible moment.

const axios = require('axios');
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await axios(url, {
...options,
timeout: 10000
});
return response;
} catch (error) {
const isRetryable =
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT' ||
(error.response?.status >= 500 && error.response?.status < 600) ||
error.response?.status === 429;
if (isRetryable && attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt - 1) * 1000;
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
exports.main = async (event, callback) => {
try {
const response = await fetchWithRetry(
'https://api.example.com/data',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`
}
}
);
callback({
outputFields: {
status: 'success',
data: JSON.stringify(response.data)
}
});
} catch (error) {
callback({
outputFields: {
status: 'failed',
error: error.message
}
});
}
};

Common Pitfalls and Solutions

Pitfall 1: Forgetting to Select Secrets

Symptom: process.env.MY_SECRET returns undefined, and you question your understanding of environment variables.

Solution: In the workflow action, click “Choose a secret” and select your secret. Even if it’s defined, it won’t be available unless selected. Yes, this catches everyone.

Pitfall 2: Timeout on Large Operations

Symptom: Action fails after 20 seconds

Solutions:

  • Break large operations into smaller workflows
  • Use pagination and process in batches
  • Queue work externally and poll for completion

Pitfall 3: Not Handling Empty/Null Properties

Symptom: TypeError: Cannot read property of null

Solution: Always validate inputs:

const email = event.inputFields['email'] || '';
const amount = parseFloat(event.inputFields['amount']) || 0;

Pitfall 4: Log Truncation

Symptom: Large objects in logs are cut off

Solution: Log only what you need:

// Instead of:
console.log('Full response:', response);
// Use:
console.log('Response status:', response.status);
console.log('Record count:', response.data.results?.length);

Pitfall 5: Variable Re-use Between Executions

This is actually a feature, not a bug. Variables declared outside exports.main persist across executions:

// This client instance may be reused across executions
const hubspot = require('@hubspot/api-client');
let hubspotClient = null;
exports.main = async (event, callback) => {
// Initialize client once, reuse for subsequent executions
if (!hubspotClient) {
hubspotClient = new hubspot.Client({
accessToken: process.env.HUBSPOT_TOKEN
});
}
// Use hubspotClient...
};

Use case: Connection pooling, caching API clients.

Warning: Don’t store request-specific data outside exports.main.

Testing Custom Code Actions

Local Development Setup

Create a local testing environment:

test-harness.js
const main = require('./your-action.js').main;
// Mock event object
const mockEvent = {
object: {
objectId: '12345'
},
inputFields: {
firstname: 'Test'
}
};
// Mock environment variables
process.env.HUBSPOT_TOKEN = 'your-test-token';
process.env.MY_API_KEY = 'your-api-key';
// Mock callback
function mockCallback(result) {
console.log('Output:', JSON.stringify(result, null, 2));
}
// Run the action
main(mockEvent, mockCallback);

Testing in HubSpot

  1. Create a test workflow with manual enrollment
  2. Add your custom code action
  3. Enroll a test record
  4. Check workflow history for logs and outputs

Key Takeaways

  1. Always call callback — Even on errors, return something
  2. Set timeouts on HTTP calls — Default infinite timeout will cause failures
  3. Validate all inputs — Assume properties can be null or undefined
  4. Use secrets for credentials — Never hardcode API keys
  5. Log strategically — Debugging is hard without logs, but truncation is real
  6. Handle errors gracefully — Distinguish between “not found” and “system error”
  7. Test locally first — Faster iteration than workflow debugging

Custom coded actions unlock capabilities that simply aren’t possible with native workflow actions. These patterns cover the most common use cases I encounter in enterprise implementations.


Building complex HubSpot automations? I’d be interested to hear about your use cases — find me on LinkedIn.