Workflow actions: shipping code that HubSpot's workflow engine calls.
A custom workflow action is a function HubSpot calls when an enrolled record reaches an action step in a workflow. HS-X lets you declare one with worker.action(...) — typed input fields, typed output, async handler — and generates the *-hsmeta.json that 2025.2 portals demand. You get the same worker that runs your syncs, no serverless.json files, no PATCH-deploy dance.
Before you begin
A HubSpot custom workflow action is a function the workflow engine calls when an enrolled record (a contact, deal, ticket, or custom object) reaches an action step in a workflow. HubSpot sends a POST with the enrolled object, the input fields the workflow editor collected from the marketer who built the workflow, and a small origin envelope. Your code runs, returns a result, and the workflow either moves on to the next step, branches, retries later, or halts enrollment.
On platform version 2025.2, the source-of-truth file moved to *-hsmeta.json. Older portals registered actions through the Custom workflow actions REST API with a separate actionUrl definition. Neither survives a 2025.2 portal upgrade. HS-X writes the *-hsmeta.json for you from the worker.action(...) declaration, which is the only spot you ever edit. The same worker.ts that owns your syncs owns your actions.
The runtime constraints
HubSpot's workflow engine imposes three hard limits on every action invocation. They are not negotiable, and HS-X cannot paper over them — but it can warn you before you hit them.
- 20-second wall-clock timeout. HubSpot will not wait longer than 20 seconds for your response. Past that, the action is recorded as
FAILEDregardless of what you return. Slow third-party calls go insideretryLater, not inline. - 128 MB of memory. Cloudflare Workers cap memory at 128 MB and HubSpot's documented ceiling matches. Streaming over a large export is fine; pulling 100k records into an array is not.
- Output string fields cap at 65,000 characters. HubSpot rejects responses with
OUTPUT_VALUES_TOO_LARGEwhen any string output exceeds that limit. Log a Worker tail URL instead of dumping the full payload back through the workflow.
Where the action runs in the workflow lifecycle
An enrolled record hits an action step; the workflow engine POSTs your worker; your handler returns a result that drives the next step.
The handler runs on your Cloudflare Worker, in your account. The workflow engine waits at most 20 seconds for a response.
The typed inputFields declaration plays two roles. On the portal side it generates the form the marketer fills out when adding your action to a workflow (text inputs, enum dropdowns, property pickers). At runtime it narrows the input argument in your handler to exactly those fields, in exactly those types. One declaration, both sides.
Declare the action in worker.ts
Open the worker.ts from your scaffolded project and add an action. The shape is the same as worker.tool — workflow actions and agent tools are the same primitive under the hood — with the addition of inputFields for the portal-side form. The handler is fully async and receives a typed input object, the enrolled record, an injected hubspot client, and a logger.
import { defineWorker } from '@hs-x/sdk';
export default defineWorker(({ worker }) => {
worker.action('scoreDeal', {
label: 'Score deal with external model',
description: 'Calls the pricing model and writes a score to the deal.',
objectTypes: ['DEAL'],
input: {
model: {
type: 'enum',
label: 'Model version',
values: ['v1', 'v2-experimental'],
default: 'v1',
},
writeBack: {
type: 'boolean',
label: 'Write score to deal property',
default: true,
},
},
output: {
score: 'number',
tier: { type: 'enum', values: ['cold', 'warm', 'hot'] },
},
async handler({ input, enrolledObject, hubspot, logger }) {
const amount = Number(enrolledObject.properties.amount ?? 0);
const score = await fetch('https://pricing.internal/score', {
method: 'POST',
body: JSON.stringify({ amount, model: input.model }),
}).then((r) => r.json());
if (input.writeBack) {
await hubspot.crm.deals.update(enrolledObject.id, {
properties: { hsx_score: score.value },
});
}
logger.info('scored', { id: enrolledObject.id, score: score.value });
return { status: 'ok', output: { score: score.value, tier: score.tier } };
},
});
});What each field controls
The declaration is doing four jobs at once. Worth walking through:
objectTypesdecides which workflow types can use this action.['DEAL']makes the action available in deal-based workflows;['CONTACT']in contact workflows;['TICKET']in ticket workflows. Custom objects work with their fully-qualified type id.inputis the portal-side form schema and the handler's argument type.enumrenders as a dropdown;booleanas a toggle;stringwithpropertyPicker: truerenders as a HubSpot property picker so marketers can wire a workflow property straight into your input. Defaults flow through to the form's initial values.outputis what your handler returns inside{ status: 'ok', output: {...} }. The portal exposes these as action outputs in the workflow editor, available for downstreamif/thenbranches and as merge tokens in later steps. Skipoutputif your action has nothing for downstream steps to read.handleris your code. It always receives a typedinput, theenrolledObject, anhubspotclient, alogger, and the rawrequestif you need headers.
What the declaration generates
When you run hs-x deploy (or hs-x dev), HS-X writes one *-hsmeta.json per action under your project's HubSpot build dir, with the inputFields, outputFields, objectTypes, and the functionFile pointing at the generated handler shim. You never edit that file by hand. If you do, the next deploy overwrites it. The diff is checked into git so PR reviewers can see manifest changes alongside the code change that caused them.
Common declaration issues
- "Property
modelof typeenumrequiresvalues." You declared an enum input without listing its values. Add thevalues: [...]array. - Two actions, same
id.worker.action('scoreDeal', ...)twice in the same worker throws at boot. Action ids are unique acrosstool,action,trigger, andsyncnamespaces. objectTypes: ['CONTACT']but the action shows up under deals in the editor. Double-check the strings against HubSpot's object-type ids. Uppercase singular (CONTACT,DEAL,TICKET) for stock objects; the fullp1234_orders-shaped id for custom objects.
Test from hs-x dev and wire a real workflow
The Worker doesn't need to be deployed to test the action — hs-x dev runs it locally and tunnels a temporary URL into your dev portal. HubSpot's workflow engine treats the tunnel like any other workflow-action endpoint; you can enroll a record, watch the request hit your terminal, edit your handler, and re-fire without redeploying.
See /docs/guides/dev-mode for the full dev-loop walkthrough. Short version:
hs-x devThen in your portal, build a one-step workflow:
- Workflows → Create workflow → Contact-based (or Deal-based, matching your
objectType). - Add the trigger you want — for a smoke test, "Contact property changed: First name."
- Add action → Custom actions → your action label (
Score deal with external modelhere). - Fill in the input fields the form rendered from your
inputschema. - Enroll one test record by editing its triggering property.
Watching the request
The dev CLI prints one line per inbound action invocation: the action id, the enrolled object id, the elapsed time, and the returned status. Every logger.info call surfaces in the same stream so you can console.log-debug without redeploying.
$ hs-x dev → worker.ts bundled · 92 kb → action.scoreDeal registered · v0 (dev tunnel) → dev portal connected · my-portal-dev [action] scoreDeal deal=8801234567 → ok 124ms score=0.71
The first hit usually lands within a few seconds of saving the workflow. If nothing shows up after a minute, the workflow's enrollment trigger probably didn't fire — open the workflow's Enrollment history tab and check whether the contact was enrolled at all.
Common dev-loop issues
- "Action not appearing in the workflow editor's custom-actions list." The dev tunnel registers the action under a
v0-devrevision tagged for the current dev session. The workflow editor may need a refresh after the first deploy. - "Workflow ran, my handler never fired." Open
/docs/guides/dev-modeand check the tunnel section — sometimes the tunnel reconnects under a new URL and the portal's cached registration points at the dead one. Restartinghs-x devre-registers the action against a fresh URL. - "Request timed out at 20s." Your handler is too slow on the cold path. Move the slow work behind
retryLater(step 4) and return inside a few hundred milliseconds.
Read and write HubSpot data from the handler
The injected hubspot client is the same one available in syncs, triggers, and agent tools. It's rate-limit-aware (token-bucket per portal, shared across all your workers in the same Cloudflare account), retries 429s and 5xxs with exponential backoff, and emits structured logs for every call so you can diff what your action did against the workflow history panel.
async handler({ input, enrolledObject, hubspot, logger }) {
// Read — full deal record with associated company and primary contact
const deal = await hubspot.crm.deals.get(enrolledObject.id, {
properties: ['amount', 'dealstage', 'closedate', 'hsx_score'],
associations: ['companies', 'contacts'],
});
// Batch — write back to many records in one round-trip
await hubspot.crm.contacts.batchUpdate(
deal.associations.contacts.map((c) => ({
id: c.id,
properties: { last_scored_at: new Date().toISOString() },
})),
);
logger.info('updated contacts', { count: deal.associations.contacts.length });
return { status: 'ok' };
}What the client handles for you
The HubSpot API will rate-limit you eventually, and the failure modes are not friendly. The injected client absorbs the worst of it:
- Token-bucket rate limiting. The Worker tracks portal-wide consumption against HubSpot's published headers and queues requests inside the 20-second budget. If queue depth means you'd miss the deadline, the client throws
BudgetExceeded— catch it and returnretryLater(next step). - Automatic batching.
batchUpdate,batchCreate, andbatchReadchunk into HubSpot's max-batch (100 for most objects) and parallelize within the rate budget. One call from your handler becomes N round-trips; you don't see the seams. - Typed property bags. If your project has a
schema/deal.ts, properties get narrowed to the declared types. Asking foramountreturnsnumber | null, notstring | undefined.
When to bypass the client
For one-off endpoints not covered by the typed client (occasional Marketing API surfaces, the Files v3 multipart endpoint), reach for hubspot.raw.fetch(path, init). It uses the same OAuth token and rate budget but returns a raw Response. Keep raw calls inside the action's 20s budget like any other.
Return the right result shape
A workflow action handler has five legitimate return shapes. Each one tells the workflow engine something different about what to do next with the enrolled record. Picking the wrong one is the most common source of "the action worked but the workflow did something weird" bug reports.
| Status | When to use | What the workflow does |
|---|---|---|
ok | Success. Optionally include output for downstream steps. | Advances to the next step. Output is available as merge tokens. |
fail-continue | A recoverable error you want to record but not halt on. | Marks the action FAILED in history, advances anyway. |
fail-stop | An unrecoverable error specific to this record. | Halts enrollment for this record. Other records keep flowing. |
retry-later | A transient failure or you ran out of budget. | Requeues for up to 3 days with exponential backoff (max 8-hour gap). retryAfterSeconds is a hint. |
block | A policy decision — this record should not proceed. | Halts enrollment, distinct from fail-stop in history. |
// retry-later — third-party API is over its rate limit
if (err.status === 429) {
return {
status: 'retry-later',
message: 'Pricing API rate-limited; retry in 30s',
retryAfterSeconds: 30,
};
}
// block — record fails a policy check, don't enroll downstream
if (deal.properties.dealstage === 'closedlost') {
return { status: 'block', message: 'Deal is already closed-lost' };
}
// fail-stop — bad data, won't get better on retry
if (!enrolledObject.properties.amount) {
return { status: 'fail-stop', message: 'Deal has no amount' };
}Why re-enrollment behaviour matters
Workflows have a re-enrollment setting that decides what happens when a record meets the trigger again. fail-stop and block interact with that setting differently: a fail-stop record can still be re-enrolled the next time its trigger fires, but a block is sticky for the duration of the workflow run. Pick block when you want the record never to see this branch again on this run; pick fail-stop when you want it to retry on the next enrollment.
Throwing vs returning
An uncaught throw is treated as fail-continue with the error message in the workflow history. That's almost never what you want — wrap third-party calls in try/catch and return an explicit shape. The one place a throw is reasonable is during local dev when you'd rather the dev CLI surface the stack trace immediately; in production it costs you log fidelity.
Deploy and pin a workflow to a revision
Same hs-x deploy that ships syncs and UI extensions ships the action. The deploy validates the action's inputFields against any prior revision registered on the portal, writes the *-hsmeta.json, and registers a new revision with HubSpot. Existing workflows continue to use whatever revision they were pinned to.
hs-x deployReading the manifest diff
The deploy prints one line per action with the change-type and the revision bump. WILL CREATE for a new action, WILL UPDATE for a backwards-compatible input/output addition, WILL BREAK for anything that would invalidate existing workflows (removing an input field, narrowing an enum, changing a field type). Breaking changes prompt for confirmation and cut a new revision rather than overwriting.
$ hs-x deploy ✓ bundle 1.4s worker 96 kb ✓ validate 0.6s action.scoreDeal · 2 inputs · 2 outputs ✓ portal 2.3s action.scoreDeal v3 registered (v2 still live) ✓ worker 1.7s iad → my-portal.workers.dev → existing workflows pinned to v2 keep running v2 → new workflows pick up v3 by default
Workflows pin to a revision the first time you add the action. Bumping the revision in HS-X does not auto-upgrade live workflows — that's the property that makes revisions safe. Open a workflow and use Upgrade action to latest when you're ready.
What HubSpot does with the manifest
The portal stores each revision against the workflow that uses it. When a record enrolls, the workflow engine looks up the pinned revision, fetches its functionFile URL (your Worker's tunnel during dev, your deployed Worker URL in production), and POSTs there. Revisions are append-only — older revisions stick around as long as a workflow still references them, so a long-running workflow won't break when you ship v3.
Version-pinning in practice
Two patterns work in production. Either you ship breaking changes as a new revision and migrate workflows one-by-one (slow, safe, defensible to operations), or you keep inputFields strictly additive and never cut a breaking revision (fast, requires discipline, scales further than you'd think). HS-X warns on the breaking path; it never blocks you.
Common workflow-action issues
The handful of failure modes below cover most of the bug reports we see on worker.action. Each one has a tell in the workflow history panel and a one-line fix.
Timeout at 20 seconds
The workflow history panel shows TIMEOUT and your logger.info lines are present up to the cutoff. The handler is doing too much synchronously. Two options: split the work across an action that returns retryLater and resumes from a stored cursor on the next attempt, or push the slow work into a worker.trigger that the action enqueues and returns from immediately.
Output string fields rejected as too large
A handler that returns an output string longer than 65,000 characters is rejected by HubSpot with OUTPUT_VALUES_TOO_LARGE. The full payload is still in your Cloudflare Worker tail (wrangler tail or the dashboard). Two fixes worth considering:
- Truncate or summarize the value before returning it as a workflow output.
- Log a short summary plus a tail-search URL:
logger.info('scored', { id, traceUrl }).
Scope error from the HubSpot client
HubSpotApiError: missing scope crm.objects.deals.write means the OAuth scope set on your deployed app doesn't cover what the handler is trying to do. Add the scope to hs-x.toml under [hubspot.scopes], run hs-x deploy, and re-authorize the app in the portal when prompted. The CLI prints the exact re-authorization URL on the next deploy.
inputFields schema drift
The workflow editor shows a stale form (old field labels, missing defaults) after you've deployed a change. The workflow editor may need a refresh after the first deploy — close and reopen the workflow. If the drift persists after a minute, check hs-x deploy's output — a silent "no-op" usually means your declaration didn't change in a way the differ noticed.
"Action not appearing in workflow editor"
Three things to check, in order:
- The user editing the workflow has permission to edit workflows in the portal.
objectTypesmatches the workflow type. A['CONTACT']action does not appear in deal workflows.- The action's most recent revision is
published, notdraft.hs-x deploypublishes by default.
Where next
- How to · Expose the same action to Breeze as an agent tool —
worker.actionandworker.toolshare a primitive, so the same code can show up in both the workflow editor and the agent toolbelt. - How to · Run hs-x dev against a live portal — the full dev-loop, tunnel rotation, and how the dev CLI streams workflow-action invocations into your terminal.
- How to · Survive HubSpot rate limits in production — what the injected
hubspotclient does for you, when it gives up, and howretryLaterinteracts with HubSpot's tier-dependent daily call ceilings.