Workflow actions
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. This guide builds Email Guard's validate-email action, the one the Getting started guide sketched, field by field: the same worker that runs your syncs, with no serverless.json files and no PATCH-deploy dance.
TL;DR — Declare a custom workflow action with worker.action(...): typed input fields, typed output, an async handler. This guide builds Email Guard's validate-email, which scores a contact's email deliverability and lets the workflow branch on the result. HS-X generates the *-hsmeta.json HubSpot demands and registers the action on deploy; test it live with hs-x dev.
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
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
Starting from zero — just one action
If you only want a workflow action — no card, no sync, none of the rest of Email Guard — scaffold a minimal project and skip straight to the code below. This is a first-class path; nothing here requires the full example app:
hs-x init my-action --type workflow-action --no-ui-extension --object-type contact
cd my-action && bun installThat gives you five files and one worker.action(...) — no card, no sync, nothing to delete. --object-type picks the CRM object the action enrolls (contact, deal (default), company, or ticket) and sets the matching scopes, so the starter matches what you're building. hs-x check validates it, and hs-x deploy --plan verifies the whole thing without any credentials. Already have a project? Ignore this and read on.
Open the Worker under src/workers/ 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, so worker.action(...) and worker.tool(...) are aliases), and the input field map doubles as 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.
This is validate-email, Email Guard's wedge capability, in full. Compared to the Getting started version it adds a strictness knob and makes the write-back optional, which together exercise every input type the form can render:
import { defineWorker, failContinue, ok } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.action('validate-email', {
label: 'Validate email address',
description: 'Checks format and deliverability for the enrolled contact.',
objectType: 'contact',
input: {
email: {
type: 'string',
label: 'Email to validate',
supportedValueTypes: ['OBJECT_PROPERTY'],
},
strictness: {
type: 'enumeration',
label: 'Strictness',
options: ['standard', 'strict'],
default: 'standard',
},
writeBack: {
type: 'boolean',
label: 'Write result to contact properties',
default: true,
},
},
output: {
status: { type: 'enumeration', options: ['deliverable', 'risky', 'undeliverable'] },
score: { type: 'number' },
},
async handler({ input, enrolledObject, env, hubspot, logger }) {
const email = String(input.email ?? enrolledObject.properties.email ?? '');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return failContinue('Not a valid email address', { status: 'undeliverable', score: 0 });
}
const res = await fetch('https://api.emailcheck.example/v1/verify', {
method: 'POST',
headers: {
authorization: `Bearer ${String(env.EMAILCHECK_API_KEY)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ email, strictness: input.strictness }),
});
const verdict = (await res.json()) as {
status: 'deliverable' | 'risky' | 'undeliverable';
score: number;
};
if (input.writeBack) {
await hubspot.crm.objects.contacts.update(enrolledObject.id, {
properties: {
email_health_status: verdict.status,
email_health_score: String(verdict.score),
},
});
}
logger.info('verified', { id: enrolledObject.id, status: verdict.status, score: verdict.score });
return ok({ status: verdict.status, score: verdict.score });
},
});
export default worker;What each field controls
The declaration is doing four jobs at once:
objectTypedecides which workflow types can use this action.'contact'makesvalidate-emailavailable in contact-based workflows;'deal'and'ticket'do the same for theirs. Custom objects work with their fully-qualified type id.inputis the portal-side form schema and the handler's argument type.enumerationrenders as a dropdown (strictness);booleanas a toggle (writeBack); astringfield withsupportedValueTypes: ['OBJECT_PROPERTY']renders as a HubSpot property picker, which is how the marketer maps the contact's email property intoemailwithout typing anything. 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; branching onstatusis the whole point of Email Guard's action. 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. Theenvbag carries your Worker secrets;EMAILCHECK_API_KEYhere is the verification provider's token, set withwrangler secret putper the secrets guide.
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
- An enumeration input with no
options.hs-x checkrejects anenumerationfield that lists no options.strictnessand thestatusoutput both carry one; add theoptions: [...]array wherever the type isenumeration. - Two actions, same
id.worker.action('validate-email', ...)twice in the same worker throws at boot. Action ids are unique acrosstool,action,trigger, andsyncnamespaces. objectType: 'contact'but the action shows up in the wrong editor. Double-check the string against HubSpot's object-type ids. Singular stock names (contact,deal,ticket) resolve to HubSpot's enums; custom objects need the fullp1234_orders-shaped id.
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 the local dev guide for the full dev-loop walkthrough. Short version:
hs-x devThen in your portal, build a one-step workflow:
- Workflows → Create workflow → Contact-based, matching
objectType: 'contact'. - Add the trigger you want; for Email Guard the natural one is "Contact property changed: Email," so every address edit gets re-verified.
- Add action → Custom actions → your action label (
Validate email addresshere). - Fill in the form rendered from your
inputschema: pick the contact's email property for theemailfield, leavestrictnesson standard. - Enroll one test record by editing its email address.
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 → email-guard.ts bundled · 94 kb → action.validate-email registered · v0 (dev tunnel) → dev portal connected · email-guard-dev [action] validate-email contact=3301452 → ok 389ms status=deliverable score=0.96
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 the local dev guide and check the tunnel section: sometimes the tunnel reconnects under a new URL and the portal's cached registration points at the dead one. Restarting
hs-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.
A worked Email Guard extension of the handler: imports and form fills create duplicate contacts, so when an address turns out to be undeliverable, find every contact sharing it and stamp them all in one round-trip.
async handler({ enrolledObject, hubspot, logger }) {
// Read — the enrolled contact, including properties the workflow didn't send
const contact = await hubspot.crm.objects.contacts.get(enrolledObject.id, {
properties: ['email', 'email_health_status', 'email_health_score'],
});
const email = contact.properties.email ?? '';
// Search — every contact that shares this address
const matches = await hubspot.crm.objects.contacts.search({
after: '0',
limit: 100,
properties: ['email'],
sorts: ['createdate'],
filterGroups: [
{ filters: [{ propertyName: 'email', operator: 'EQ', value: email }] },
],
});
// Batch — stamp all of them in one round-trip (up to 100 inputs per call)
await hubspot.crm.objects.contacts.batch.update({
inputs: matches.results.map((match) => ({
id: match.id,
properties: { email_health_status: 'undeliverable', email_health_score: '0' },
})),
});
logger.info('suppressed duplicates', { email, count: matches.results.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). - Batch endpoints, first-class.
crm.objects.contacts.batch.update,.batch.create,.batch.get, and.batch.upserttake up to 100 inputs per call and run inside the same rate budget, so the threeupdatecalls you were about to write in a loop become one. - String property bags. HubSpot's wire format is strings: reads come back as
{ [property]: string }and writes must send strings, which is why the example writesemail_health_score: '0'and the card later parses it. Parse numbers at the edge of your handler, not in the middle of it.
When to bypass the client
For one-off endpoints not covered by the typed namespaces (occasional Marketing API surfaces, the Files v3 multipart endpoint), reach for the per-verb escape hatches: hubspot.get(path), hubspot.post(path, { body }), and friends. They use the same OAuth token and rate budget but give you the raw response. Keep these 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 with exponential backoff for up to 3 days. retryAfterSeconds becomes a Retry-After hint HubSpot respects. |
block | A pause: this record should wait here. | Pauses the enrollment at this step. Without a completion, the block expires (HubSpot default: one week) and the workflow resumes. |
Email Guard's validate-email already uses two of them: ok with the { status, score } output on success, and fail-continue when the address fails the format check, so the workflow records the miss but keeps the contact moving. The other three look like this in the same handler:
// retry-later — the verification API is over its rate limit
if (res.status === 429) {
return {
status: 'retry-later',
message: 'Verification API rate-limited; retry in 30s',
retryAfterSeconds: 30,
};
}
// block — policy: a suppressed address never gets re-verified on this run
if (enrolledObject.properties.email_suppressed === 'true') {
return { status: 'block', message: 'Address is on the suppression list' };
}
// fail-stop — bad data, won't get better on retry
if (!enrolledObject.properties.email) {
return { status: 'fail-stop', message: 'Contact has no email address' };
}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.validate-email · 3 inputs · 2 outputs ✓ portal 2.3s action.validate-email v3 registered (v2 still live) ✓ worker 1.7s iad → email-guard.yourteam.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; a verification API that takes 25 seconds to answer is a verification API you call asynchronously. 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. Email Guard eventually grows exactly that trigger (email-changed, on contact.propertyChange.email) so re-verification doesn't even need a workflow.
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('verified', { id, traceUrl }).
Scope error from the HubSpot client
HubSpotApiError: missing scope crm.objects.contacts.write means the OAuth scope set on your deployed app doesn't cover what the handler is trying to do; for validate-email the write-back needs it. Add the scope to the scopes array in hsx.config.ts, run hs-x deploy, and re-authorize the app in the portal: the new scope only lands on tokens minted by a fresh OAuth grant, so existing installs keep their old scope set until the merchant re-installs.
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.
objectTypematches the workflow type. A'contact'action likevalidate-emaildoes not appear in deal workflows.- The action's most recent revision is
published, notdraft.hs-x deploypublishes by default.
