Writing actions
Turbyn uses HubSpot’s handler signature verbatim. Code written for HubSpot’s native custom code action runs here without changes.
The handler
Section titled “The handler”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 callback style also works
Section titled “The callback style also works”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.
The event object
Section titled “The event object”{ "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.
Outputs
Section titled “Outputs”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 |
|---|---|---|
output1–output5 |
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 |
Returning nothing
Section titled “Returning nothing”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.
Failing on purpose
Section titled “Failing on purpose”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.
Versions
Section titled “Versions”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.