view .md
Guides · Build

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 current platform versions 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.

Time
≈ 12 min
Outcome
Email Guard's validate-email action registered to your portal, callable from any contact workflow, fully testable from hs-x dev, with the same Worker that runs your syncs.
Prerequisites
  • The Email Guard project from the Getting started guide, or any working HS-X project. Either gets you there in about 15 minutes.
  • A HubSpot portal on platform version 2025.2 or 2026.03 (the CLI defaults new projects to 2026.03). Older portals still use serverless.json; HS-X targets the current contract only.
  • A HubSpot portal with workflows enabled. App-defined custom workflow actions don't require the Data Hub tier; in-portal custom code actions do.
  • About 12 minutes and a workflow you can safely enroll one test contact in.

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.

From platform version 2025.2 on (2026.03 is the current default), the source-of-truth file is *-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

Three constraints shape every action invocation. HS-X cannot paper over them, but designing for them up front is cheap.

  • HubSpot enforces a response timeout. Its published reference does not state the number, so treat any slow third-party call as one that belongs behind retryLater rather than inline, and return inside a few hundred milliseconds on the happy path.
  • 128 MB of memory. Cloudflare Workers cap memory at 128 MB. Streaming over a large export is fine; pulling 100k records into an array is not.
  • Output values are for branching, not payloads. HubSpot stores them as action outputs and merge tokens; keep them small and log the full result in your Worker instead of returning it through the workflow.

Where the action runs in the workflow lifecycle

The typed input 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 install

That gives you a package.json, tsconfig.json, hsx.config.ts, one worker under src/workers/ with a single worker.action(...), and a few agent/readme files: 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 ?? '');
    const strictness = input.strictness ?? 'standard';
    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 }),
    });
    const verdict = (await res.json()) as {
      status: 'deliverable' | 'risky' | 'undeliverable';
      score: number;
    };
 
    if (input.writeBack ?? true) {
      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:

  • objectType is required and decides which workflow types can use this action. 'contact', 'deal', 'company', and 'ticket' (singular or plural) map to HubSpot’s enums; 'contact' makes validate-email available in contact-based workflows. For an app object use its declared name ('SUBSCRIPTION'). Portal custom objects are uppercased on the way out (p1234_orders becomes P1234_ORDERS); confirm the id HubSpot expects in a test portal.
  • input is the portal-side form schema and the handler’s argument type. enumeration renders as a dropdown (strictness); boolean as a toggle (writeBack); a string field with supportedValueTypes: ['OBJECT_PROPERTY'] renders as a HubSpot property picker, which is how the marketer maps the contact’s email property into email without typing anything. default is a handler-side convenience only: it is not written into the HubSpot form, so apply it in code (input.strictness ?? 'standard'). required: true (or isRequired) makes HubSpot require the field; fields are optional by default. supportedValueTypes defaults to ['STATIC_VALUE'], and HubSpot accepts exactly one value per field.
  • output is what your handler returns inside { status: 'ok', output: {...} }. The portal exposes these as action outputs in the workflow editor, available for downstream if/then branches and as merge tokens in later steps; branching on status is the whole point of Email Guard’s action. Skip output if your action has nothing for downstream steps to read.
  • handler is your code. It always receives a typed input, the enrolledObject, a hubspot client, a logger, and the raw request if you need headers. It also receives install (portal id and config), execution (workflowId, callbackId, actionDefinitionId from HubSpot’s envelope), store (install-scoped key/value in tenant D1), appObjects, appEvents, and, when configured, flags and billing. The env bag carries your Worker secrets; EMAILCHECK_API_KEY here is the verification provider’s token, set with wrangler secret put per the secrets guide. HS-X does not yet request enrolled-object properties from HubSpot, so enrolledObject.properties is {} at runtime; map what you need through input with OBJECT_PROPERTY, or read the record with hubspot.

What the declaration generates

When you run hs-x deploy (or hs-x dev), HS-X writes one src/app/workflow-actions/<id>-hsmeta.json per action into your project, with the inputFields, outputFields, and objectTypes HubSpot’s project build expects. You never edit that file by hand; the next deploy overwrites it, and a ledger under .hs-x/ records which files the CLI generated so it can tell its own output from yours. The generated file lives in src/app/, so committing it is fine: PR reviewers see manifest changes alongside the code change that caused them. The generated labels use your label for actionName, actionCardContent, and actionDescription (there is no separate description field in the metadata yet), and the section header in HubSpot’s Choose an action panel reads HS-X; there is currently no way to set appDisplayName from the declaration.

Common declaration issues

  • An enumeration input with no options. hs-x deploy rejects an enumeration field that lists no options while it generates the hsmeta. strictness and the status output both carry one; add the options: [...] array wherever the type is enumeration.
  • Two actions, same id. worker.action('validate-email', ...) twice in the same worker throws a duplicate-id error when the manifest is built, which hs-x check, hs-x dev, and hs-x deploy all surface. Ids are unique per capability kind: tool and action share one namespace (they are the same primitive), while a trigger or sync may reuse an action’s id.
  • 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; a portal custom object id is uppercased as-is, so verify the result in a test portal.

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 dev

Then in your portal, build a one-step workflow:

  1. Workflows → Create workflow → Contact-based, matching objectType: 'contact'.
  2. Add the trigger you want; for Email Guard the natural one is “Contact property changed: Email,” so every address edit gets re-verified.
  3. Add action → Custom actions → your action label (Validate email address here).
  4. Fill in the form rendered from your input schema: pick the contact’s email property for the email field, leave strictness on standard.
  5. 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.

Expect
$ hs-x dev
# hs-x  dev  *  portal 46993937

[ok] server   http://127.0.0.1:8787  ready in 412ms
[ok] workers  1 worker · 2 capabilities
[ok] portal   46993937  dev override registered
[ok] logs     streaming on http://127.0.0.1:9099
[ok] tunnel   https://cool-mongoose-23.trycloudflare.com

* Press Ctrl+C to stop

* [info] verified {"id":"3301452","status":"deliverable","score":0.96}
POST   /_hsx/invoke/validate-email           200    389ms  ✓  local

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.” A brand-new action needs one hs-x deploy to register its contract before the workflow editor lists it; the dev override only makes the handler body hot. After that first deploy the editor may still need a refresh.
  • “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 dev re-registers the action against a fresh URL.
  • “Request timed out.” 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 (a token bucket per portal), retries 429s and 5xxs with exponential backoff (up to five attempts, 200 ms base, 10 s cap), 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 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 takes a short lease from a per-portal bucket before each call. When the local budget is exhausted the client throws RuntimeHubSpotBackpressureError with a retryAfterMs instead of spending a real request; catch it and return retryLater(message, seconds) so HubSpot re-enqueues the step, and do the same when a third-party call is the one over budget (next step).
  • Batch endpoints, first-class. crm.objects.contacts.batch.update, .batch.create, .batch.get, and .batch.upsert take up to 100 inputs per call and run inside the same rate budget, so the three update calls 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 writes email_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 HubSpot’s response timeout 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 usual cause of “the action worked but the workflow did something weird.”

StatusWhen to useWhat the workflow does
okSuccess. Optionally include output for downstream steps.Advances to the next step. Output is available as merge tokens.
fail-continueA recoverable error you want to record but not halt on.HS-X answers 200 with hs_execution_state: FAIL_CONTINUE; HubSpot marks the action failed in history and advances anyway.
fail-stopA failure you do not want retried.HS-X answers HTTP 400; HubSpot records the action as failed and continues the enrollment. There is no “halt this record” state in HubSpot’s contract.
retry-laterA transient failure or you ran out of budget.With retryAfterSeconds HS-X answers 429 plus Retry-After, which HubSpot honors; without it HS-X answers 503 and HubSpot retries with exponential backoff for up to three days.
blockA pause: this record should wait here.HS-X answers hs_execution_state: BLOCK with no expiration, so HubSpot pauses this enrollment at the step for its default one-week window and then resumes with the following actions. Completing it early means calling HubSpot’s /callbacks/{callbackId}/complete yourself with ctx.execution.callbackId; HS-X does not expose a completion API for inline actions today.

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 waits here (up to a week) instead of being re-verified.
// enrolledObject.properties is empty at runtime, so read the record first.
const contact = await hubspot.crm.objects.contacts.get(enrolledObject.id, {
  properties: ['email', 'email_suppressed'],
});
if (contact.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 (!contact.properties.email) {
  return { status: 'fail-stop', message: 'Contact has no email address' };
}

Why re-enrollment behavior matters

Workflows have a re-enrollment setting that decides what happens when a record meets the trigger again. Neither fail-stop nor block changes it. block pauses the record at this step for up to a week (HubSpot’s default expiration) and then lets the workflow continue; fail-stop fails the step immediately and continues. Pick block when you want a cooling-off period or plan to complete the callback out of band; pick fail-stop when the record should move on now and try again only if the workflow re-enrolls it.

Throwing vs returning

An uncaught throw becomes an HTTP 500, which HubSpot treats as a temporary failure and retries with exponential backoff for up to three days. That is almost never what you want for a deterministic error: catch and return failContinue(...) or failStop(...) explicitly, and reserve throws for genuinely transient faults where a retry can succeed. During local dev a throw is still useful for surfacing the stack trace immediately.

Deploy and register the contract

Same hs-x deploy that ships syncs and UI extensions ships the action. The deploy regenerates the action’s *-hsmeta.json from the declaration, validates the project, uploads it for HubSpot’s remote build, and prints the per-component verdict. The registered contract — input, output, objectType — is whatever the last successful build carried.

hs-x deploy
Expect
$ hs-x deploy
# hs-x  deploy  *  email-guard

[ok] Validating project  1 workers, 2 capabilities
* Cloudflare deploy: hsx-local-865a8a52-email-guard-email-guard
* HubSpot build #3: SUCCESS
*   [ok] email-guard (APPLICATION)
*   [ok] validate-email (WORKFLOW_ACTION)
* Generated .hs-x/manifest.json and refs stubs.

The [ok] validate-email (WORKFLOW_ACTION) line is HubSpot’s build accepting the action’s metadata. Workflows that already use the action pick up the new contract from that build; there is no per-workflow version pin to manage.

What changes on the portal side

The Worker push and the HubSpot build are one command, but they answer different questions. The Worker push changes what the handler does; the HubSpot build changes what the workflow editor shows. Editing the handler body alone (and deploying) changes behavior for every workflow on the next execution, with no editor change. Editing input or output changes the form and the branchable outputs, and the workflow editor may need a close-and-reopen before it renders the new shape.

Contract changes in practice

Two patterns hold up. Either you keep input strictly additive — new optional fields with defaults, never a removed field or a narrowed enum — so existing workflows keep working through every deploy; or you treat a breaking change as a new action with a new id, migrate workflows to it one by one, and delete the old declaration once nothing references it. HS-X regenerates the metadata either way; the discipline is yours.

Common workflow-action issues

The failure modes below are the ones the workflow history panel surfaces most clearly. Each has a tell in that panel and a one-line fix.

Timeout

The workflow history panel shows the action timing out and your logger.info lines are present up to the cutoff. The handler is doing too much synchronously; a verification API that takes tens of 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 (ctx.store) on the next attempt, or return block and finish the work out of band (a scheduled sync, a Cloudflare Queue consumer, or a worker.trigger on a HubSpot event the action causes, such as contact.propertyChange on a status property it writes), completing the callback when done. Triggers fire only on HubSpot webhooks; an action cannot enqueue one directly.

Output values rejected as too large

HubSpot rejects a response whose output values exceed its size limits; the published reference does not state the numbers, so keep outputs to the small, branchable values downstream steps need. The full payload is still in your Cloudflare Worker tail (wrangler tail or the dashboard). Two fixes:

  • 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.

input 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:

  1. The user editing the workflow has permission to edit workflows in the portal.
  2. objectType matches the workflow type. A 'contact' action like validate-email does not appear in deal workflows.
  3. The deploy that registered it succeeded: hs-x deploy printed [ok] validate-email (WORKFLOW_ACTION) in the HubSpot build verdict. A failed component prints its error in the same list.