In this guide
Turbyn handlers receive an event and can return a JSON object. When moving code from HubSpot’s native action, review input mapping, output handling and secret access rather than assuming the contracts are identical.
The handler
exports.main = async (event, ctx) => {
const email = event.inputs.email;
return { text: String(email ?? "").toLowerCase(), boolean: true };
};
async def main(event, ctx):
email = (event.get("inputs") or {}).get("email")
return {"text": (email or "").lower(), "boolean": True}
ctx provides Turbyn’s run capabilities and is optional. Omit it if your handler only needs the event.
Prefer a returned object
For new Turbyn actions, return the result directly:
exports.main = async (event) => {
return { text: "done", boolean: true };
};
Native HubSpot JavaScript examples commonly use callback({ outputFields: ... }). Do not copy that invocation unchanged: Turbyn’s legacy callback adapter uses an error-first callback. A direct return avoids that distinction. Also move mapped values to event.inputs and credentials to ctx.secrets as appropriate.
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" }
},
"inputs": {
"email": "j.chen@example.com",
"company": "Acme Corp"
},
"inputFields": {
"inputs": "{ \"email\": \"j.chen@example.com\", \"company\": \"Acme Corp\" }"
}
}
object.objectType can be any object type the workflow runs on: contacts, companies, deals, tickets or custom objects.
Inputs
The step’s Inputs box is JSON. Insert data tokens as string values:
{
"email": "[Email]",
"greeting": "Hi [First name], thanks for signing up"
}
([Email] stands for a token inserted from the data panel.) HubSpot resolves the tokens before calling Turbyn:
const email = event.inputs.email;
email = event["inputs"]["email"]
- JSON objects, arrays and nested values are kept as parsed JSON.
- The raw text is also available as
event.inputFields.inputs.
Two things catch people out:
object.properties is not the whole record. Pass what you need through Inputs. Fetch any other fields from the HubSpot API with a private app or service key stored as a secret. See Calling HubSpot and other APIs.
Inputs are already resolved. HubSpot substitutes data tokens, mapped properties and earlier-step outputs before calling you, so event.inputs.email is a value, not a token.
Outputs
Return the JSON you want the rest of the workflow to see. Use one slot per HubSpot property type so Edit record can pick a compatible Action output:
return {
text: email,
number: score,
boolean: true,
options: "qualified",
date: "2026-09-22",
datetime: Date.now(),
phone: "+14155550123",
};
HubSpot Action outputs:
| Slot | HubSpot type | Edit record target |
|---|---|---|
text |
string / text | Single-line or multi-line text |
number |
number | Number (also text) |
boolean |
bool | Single checkbox (true / false) |
options |
enumeration / select | Dropdown, radio, or checkbox option value |
date |
date | Date picker (midnight UTC milliseconds or YYYY-MM-DD) |
datetime |
datetime | Date and time picker (UNIX milliseconds) |
phone |
string / phonenumber | Phone number |
outputs |
string | The whole JSON object |
One value per type. Extra keys stay inside outputs JSON. If you omit the typed keys, a first string/number/boolean in the return is inferred onto text / number / boolean. errorCode and errorMessage are set on failure.
Returning nothing
If you return nothing, the action still succeeds and downstream steps receive empty outputs. Turbyn warns about this in the editor. Sometimes an action is only there for its side effects.
Failing on purpose
Throw. The run is recorded as failed, errorCode is set to RUNTIME_ERROR, and errorMessage carries your message:
if (!event.inputs.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 the JSON and branch on it in a later step:
if (!domain) {
return { status: "skipped", reason: "no domain" };
}
This is usually the better pattern. A failed action stops that record’s journey through the workflow; a skipped status lets you route it deliberately.
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.