Skip to content

Writing actions

The handler signature, the event object, and how outputs reach the rest of your workflow.

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.