A workflow has a custom code action that has worked for two years. Then HubSpot bumps the Node runtime, the action throws on a breaking change in the standard library, and 4,000 stranded contacts wait for routing. Custom code actions are powerful and easy to abandon. The discipline that prevents incidents looks a lot like writing real backend code, because that is what it is.
What custom code actions are
A workflow step that runs JavaScript (Node) or Python with access to the workflow event data, environment secrets, network, and the HubSpot API. Available on Operations Hub Professional and above. Execution capped at 20 seconds per run.
exports.main = async (event, callback) => {
const contactId = event.object.objectId;
const enrichmentApiKey = process.env.ENRICH_API_KEY;
const resp = await fetch(`https://enrich.example.com/v1/${contactId}`, {
headers: { Authorization: `Bearer ${enrichmentApiKey}` }
});
if (!resp.ok) {
callback({ outputFields: { enriched: false, error: `${resp.status}` } });
return;
}
const data = await resp.json();
callback({
outputFields: {
enriched: true,
industry: data.industry,
employee_count: data.employees
}
});
};
What you can and cannot do
Can:
- Fetch HTTP from any allowed domain
- Call HubSpot API with platform-bound auth
- Read inputs from prior steps
- Write outputs consumed by downstream steps
- Read portal secrets
Cannot:
- Run longer than 20 seconds per execution
- Persist state between runs (no filesystem)
- Open long-lived connections (no websockets)
- Use native modules outside the runtime allowlist
For multi-second external calls, batch and parallelize within the 20-second budget or move the work to your own queue and have the workflow poll for completion.
Secret management
Use Settings > Secrets to register portal-level secrets. Access via process.env.NAME. Never commit a secret in code or paste one in the editor:
const apiKey = process.env.ENRICH_API_KEY;
if (!apiKey) {
throw new Error("Missing ENRICH_API_KEY secret");
}
Rotate secrets annually and immediately when an integration owner changes.
Outputs that downstream steps can rely on
Outputs declared in the action’s UI become workflow variables. Type them and document expected values:
Output: enriched
Type: Boolean
Expected: true on success, false on error or skip
Output: industry
Type: Single-line text
Expected: SIC industry classification or "unknown"
Downstream branches reference outputs by name. Renaming an output silently breaks every reference — search workflow JSON before any rename.
Test mode is mandatory
The test mode panel runs the action with a sample event payload. Build a test corpus covering:
- Happy path with full input
- Missing optional input fields
- Upstream API failure (5xx)
- Upstream API timeout
- Rate-limit response (429)
- Malformed external response
Save sample payloads in your repo so any change can re-run the same tests.
Logging that survives the rotation
console.log output appears in the action run history for a limited window. For longer-term observability, ship structured logs to your own platform via a non-blocking call:
function log(level, msg, fields) {
const payload = { ts: new Date().toISOString(), level, msg, ...fields };
fetch(process.env.LOG_INGEST_URL, {
method: "POST",
headers: { "X-Token": process.env.LOG_INGEST_TOKEN },
body: JSON.stringify(payload)
}).catch(() => {});
}
Idempotency and retries
A workflow may retry on platform error. Build the action so a retry is safe — pass deterministic idempotency keys to external systems and check before writing.
What to do this week
Inventory your custom code actions, add owner + purpose metadata to each, build a test payload set for the top three, and confirm secrets are not hardcoded before next quarter’s runtime upgrade.