Custom Coded Workflow Patterns That Actually Work
/ 12 min read
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 variablesconst 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
- Always set timeouts: Default axios timeout is infinite. Set it below 15 seconds.
- Handle rate limits: Check for 429 responses and implement backoff if needed.
- Validate inputs: Return early if required data is missing.
- 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 executionsconst 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:
const main = require('./your-action.js').main;
// Mock event objectconst mockEvent = { object: { objectId: '12345' }, inputFields: { firstname: 'Test' }};
// Mock environment variablesprocess.env.HUBSPOT_TOKEN = 'your-test-token';process.env.MY_API_KEY = 'your-api-key';
// Mock callbackfunction mockCallback(result) { console.log('Output:', JSON.stringify(result, null, 2));}
// Run the actionmain(mockEvent, mockCallback);Testing in HubSpot
- Create a test workflow with manual enrollment
- Add your custom code action
- Enroll a test record
- Check workflow history for logs and outputs
Key Takeaways
- Always call callback — Even on errors, return something
- Set timeouts on HTTP calls — Default infinite timeout will cause failures
- Validate all inputs — Assume properties can be null or undefined
- Use secrets for credentials — Never hardcode API keys
- Log strategically — Debugging is hard without logs, but truncation is real
- Handle errors gracefully — Distinguish between “not found” and “system error”
- 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.