Skip to content

Writing actions

Turbyn uses HubSpot’s handler signature verbatim. Code written for HubSpot’s native custom code action runs here without changes.

exports.main = async (event, ctx) => {
return { outputFields: { output1: "done" } };
};
async def main(event, ctx):
return {"outputFields": {"output1": "done"}}

ctx is the only addition and it’s optional. Omit the parameter entirely and your code behaves exactly as it would in HubSpot.

The older HubSpot signature is detected automatically:

exports.main = async (event, callback) => {
callback({ outputFields: { output1: "done" } });
};

Turbyn inspects the second parameter to tell the two apart, so pasting an existing action in requires no migration step. Both return and callback() are accepted, which answers HubSpot’s most common complaint: an action that reports success and produces nothing.

{
"callbackId": "ap-123456-789",
"origin": {
"portalId": 12345678,
"actionDefinitionId": 98765,
"actionDefinitionVersion": 1
},
"context": { "source": "WORKFLOWS", "workflowId": 4567890 },
"object": {
"objectId": 451,
"objectType": "CONTACT",
"properties": { "email": "j.chen@example.com" }
},
"inputFields": {
"field1": "j.chen@example.com",
"field2": "Acme Corp"
}
}

Two things catch people out:

object.properties is not the whole record. It contains only the properties the action definition pre-declared. Everything else needs a fetch via ctx.hubspot.

inputFields are already resolved. HubSpot substitutes the mapped CRM property or earlier-step output before calling you, so field1 is a value, not a token.

Return an object with outputFields:

return {
outputFields: {
output1: "Software",
output2: "250",
json: { raw: apiResponse },
},
};

The available fields are fixed by the action definition, because HubSpot needs to know them before your code runs so it can offer them in the workflow editor:

Field Type Use
output1output5 string Values for later steps to copy or branch on
json json Structured data that doesn’t fit five strings
errorCode string Set automatically on failure
errorMessage string Set automatically on failure

If you return nothing, or an object with no outputFields, the action still succeeds and downstream steps receive empty values. Turbyn warns about this in the editor when a test run produces no outputs, but at runtime it’s a legitimate result. Sometimes an action is only there for its side effects.

Throw. The run is recorded as failed, errorCode is set to RUNTIME_ERROR, and errorMessage carries your message:

if (!event.inputFields.domain) {
throw new Error("No domain on this record. Nothing to enrich");
}

That message appears in HubSpot’s workflow history, so write it for whoever is reading the workflow, not for you.

To handle a problem without failing the workflow, return a status in an output field and branch on it instead:

if (!domain) {
return { outputFields: { output1: "skipped", output2: "no domain" } };
}

This is usually the better pattern. A failed action stops that record’s journey through the workflow; a “skipped” output lets you route it deliberately.

Editing produces a draft. Only you see it, and only test runs execute it.

Publishing creates an immutable version: the code and its resolved dependency tree are frozen and given a number. HubSpot’s dropdown lists published actions only. Rolling back copies an old version into your draft so you can inspect it before publishing again.

Live workflows always use the published version. There’s no way to break a running workflow from the editor.