Migrate an app
One command reads your legacy HubSpot project, classifies every feature, and generates a complete 2026.03 app you can test without risk. The generated project is a duplicate built for proving the migration. Your original app keeps running, untouched, until you decide to cut over.
TL;DR — Run hs-x migrate run . in your legacy project. It detects the platform version, classifies every feature into “ported automatically” or “needs your decision,” and generates a net-new 2026.03 project with typed capabilities replacing your serverless functions, webhooks, and workflow actions. Test the result with hs-x dev. The original app is never modified.
A duplicate you can afford to break
Every migration tool faces the same trust problem: the command is cheap to run and expensive to regret. HubSpot’s own hs project migrate converts your app in place, and several of its steps are one-way. That is the right final move and the wrong first move.
HS-X separates the two. hs-x migrate run produces a net-new project, a duplicate of your app expressed in 2026.03 terms. It has its own directory, and when you deploy it, it becomes its own app with its own app ID. You can build it, run it in hs-x dev, deploy it, install it on a test portal, and throw it away. Your original app, with its installs, credentials, and marketplace listing, does not know any of this is happening.
Cutover, the step that touches the original, comes later and only with your explicit confirmation. The testbed is how you arrive at that confirmation with evidence instead of hope.
Run it
From the root of the legacy project (the directory with hsproject.json):
hs-x migrate run . --out ./quote-tracker-hsxIn a terminal, plain hs-x migrate opens a guided flow instead. It asks where you are migrating from (a local legacy project, a live HubSpot app, or a cutover plan), discovers hsproject.json and migration.json sources under the workspace, previews the classification and the file count, and asks for confirmation before writing anything; the default answer is No, and Esc steps back a question. Outside a terminal, or with --json, the bare command refuses to run and names the explicit subcommands to use instead.
If the app was configured in HubSpot’s UI and has no project tree, hs-x migrate pull --app-id <id> writes a migration.json from the live app (its cards, webhook subscriptions, and workflow actions) that run, inspect, and report accept in place of a directory. It needs the credentials stored by hs-x connect hubspot, and without --app-id it lets you pick the app from your developer account’s list of migratable apps.
Here is a run against a 2025.1 public app with two serverless functions, a deal webhook, and a workflow action:
[ok] Generated migrated HS-X project (the dupe) 6 files ./quote-tracker-hsx
Ported automatically:
- Serverless function `quote-status` uses 1 secret(s); remap them to Worker secrets on deploy.
- 1 webhook subscription(s) can be ported to an HS-X trigger capability.
- Workflow action `flag-stale` (0 input field(s)) can be ported to an HS-X tool capability.
Scaffolded (stub — port body):
- Serverless function `quote-status` is scaffolded as a Worker capability stub; you must port its body.
- Serverless function `refresh-cache` is scaffolded as a Worker capability stub; you must port its body.
Decisions before cutover (the dupe is a testbed; these do not block it):
- [app.distribution.marketplace] This app distributes via the HubSpot Marketplace; cutover
affects the public listing and installed customers. docs: https://hs-x.dev/docs/guides/migrate#decisions
- [webhooks.target-url.forwarding] Events currently deliver to https://quotes.example.com/hooks;
confirm whether the migrated Worker should keep forwarding there or replace it. docs: https://hs-x.dev/docs/guides/migrate#decisions
- [workflow-action.action-url.forwarding] Executions of `flag-stale` currently POST to https://quotes.example.com/actions;
confirm whether the migrated tool should keep forwarding there or replace it. docs: https://hs-x.dev/docs/guides/migrate#decisions
[ok] Validation
Outstanding migration work: 2 TODO marker(s), 0 frontend serverless call(s) (2 total).
The dupe is scaffolded for testing; it is not production-ready yet.
Next steps:
cd quote-tracker-hsx && bun install
hs-x dev # test every migrated capability locally
hs-x deploy # upload the dupe as a NET-NEW app when readyServerless functions land under “Scaffolded (stub — port body)” because their structure ports and their bodies do not; the outstanding-work count at the end is your checklist for that. Every decision line ends with a link to the decisions section below.
If you want the analysis without generating anything, hs-x migrate inspect . prints the same classification with no side effects (it exits 1 while human-review findings remain, so CI can gate on it), and hs-x migrate report . adds a readiness summary and next steps.
What the generator carries
The generated project keeps your app’s real identity. Name, description, distribution mode, auth type, and scopes come from your legacy app.json or app-hsmeta.json and land in a typed defineApp block:
export default defineApp({
name: "Quote Tracker",
description: "Tracks quote status on deals",
distribution: "marketplace",
auth: "oauth",
platformVersion: "2026.03",
scopes: ["crm.objects.deals.read", "crm.objects.deals.write"],
});Features become typed capabilities, one worker file per feature family. A legacy serverless function turns into a tool whose handler is deliberately a stub:
worker.tool("quote-status", {
label: "Migrated: quote-status",
async handler() {
// TODO(migration): port the body of "quote-status.js" here.
// The legacy handler signature was `async (context) => result`; HS-X
// passes { input, enrolledObject } and returns ok(...)/err(...).
return ok({ ok: true });
},
});Structure, identity, and registration port automatically; your business logic and card-to-backend wiring do not, because silently transformed logic is how migrations break in production. The TODO comment documents both signatures so each port is mechanical. Functions that declared secrets get a comment naming them; HS-X never sees the legacy values, so you set each one on the Worker yourself before the first deploy. Shared files from the legacy function lib/ directory are preserved under src/workers/legacy/lib/ so helper code is never silently dropped.
The completion summary counts both TODO(migration) markers and frontend runServerlessFunction calls. Treat that count as the remaining migration checklist: hs-x check validates the scaffold’s structure, not the behavior of unported handlers.
Webhook subscriptions become trigger capabilities. Workflow actions become tools with their input fields carried over. Each generated file states what it replaced and where the original lived.
The findings only you can answer
Decision findings print with a bracketed code and never block the testbed. The codes you will see most often:
app.distribution.marketplace — Marketplace distribution
Your app is listed on the HubSpot Marketplace. The testbed is unaffected, but cutover touches a public listing and live customers, so plan it with the legacy public app guide and the marketplace listing guide. For card swaps on listed apps, HubSpot auto-hides new app cards until you delete the rollout feature flags; the legacy CRM cards answer walks that sequence.
webhooks.target-url.forwarding — Webhook target forwarding
Your legacy app delivers webhook events to an external URL. After migration, events arrive at your Worker’s trigger capability instead. Decide whether the Worker should keep forwarding events to the old endpoint (your backend keeps working unchanged) or replace it (the Worker becomes the backend). Forwarding is the safe first state; replacement is usually the destination.
workflow-action.action-url.forwarding — Action URL forwarding
Same choice, for workflow action executions. The legacy action POSTs to your server; the migrated tool runs in your Worker. Keep the Worker forwarding to the old actionUrl while you verify behavior in hs-x dev, then move the logic into the handler and drop the forward.
project-card.surface.confirm — Confirm the card surface
The card keeps its existing location by default so migration stays non-interactive. Confirm that carry-over or choose the surface that fits the 2026.03 experience: crm.record.tab, crm.record.sidebar, crm.preview, or helpdesk.sidebar. If you change it, update both the card metadata and the hubspot.extend generic.
project-card.serverless-call.routed — Card serverless calls are routed
The copied React hooks keep their legacy runServerlessFunction calls, but migration replaces the provider with a generated compatibility shim. Each statically named function becomes a worker.cardBackend; the shim sends its parameters through hubspot.fetch to /_hsx/cards/<name> and preserves the legacy { status, message, response } result shape. Input fields are inferred from frontend object literals and legacy context.parameters reads. Port the matching backend body, then exercise the complete card-to-worker request in hs-x dev.
The generated project uses https://runtime-origin.invalid as an explicit temporary allow-list value. hs-x deploy replaces it in the shim and regenerated HubSpot metadata with the resolved workers.dev origin before upload. If the card uses a provider shape migration cannot safely recognize, the original project-card.serverless-call.rewire human-review finding remains instead of editing arbitrary React.
Other codes, and which ones block cutover
The classifier emits a longer tail of codes that print with the same link:
serverless.endpoint.url-contract: a function exposed a public endpoint. Moving it to a Worker changes its URL contract, so the generator flags it instead of porting it silently.workflow-action.functions.manual: the action carries Lambda function snippets (pre-execution or fetch-options hooks); port that logic into the tool handler by hand.workflow-action.options-url.external: an input field fetches its options from an external URL; keep that endpoint or serve the options from the Worker.timeline.templates.grandfathered: the app defines v1/v3 timeline event templates. They keep working on the same app ID after cutover; new event surfaces should target app events.legacy-card.helpdesk-card.requiredandlegacy-card.fetch-url.missing: a pulled legacy CRM card supports tickets (so it needs a help desk replacement too) or has no fetch URL to convert.component.unsupported.<type>: a 2025.2 component with no HS-X generator yet (app events, app objects, settings, telemetry, theme). The dupe omits it; the original keeps it through cutover.webhooks.target-url.missing,webhooks.subscriptions.empty,workflow-action.action-url.missing,workflow-action.labels.missing,serverless.function.entry.missing,migration.input.unsupported: the source is missing something the migration needs.
The blocking rule is short. Codes ending in .missing or .empty, every component.unsupported.* code, and migration.input.unsupported are the only findings that block hs-x migrate cutover --execute. Everything else is an advisory the dry run tags confirm, and you acknowledge it at the confirmation prompt.
Test, deploy, and the road to cutover
cd quote-tracker-hsx && bun install
hs-x dev # exercise every migrated capability against your portal
hs-x deploy # uploads the testbed as a NET-NEW appWork through the TODO ports one capability at a time, testing each in hs-x dev as you go. When the testbed behaves identically to the original, plan the cutover: the in-place migration of your real app, which keeps its app ID, installs, and OAuth grants.
hs-x migrate cutover . # dry run: the ordered plan and every open decision; nothing changes
hs-x migrate cutover . --execute # one-way; type the project name to confirm, or pass --yes in automationThe dry run groups the steps into PREFLIGHT, IN-PLACE, and POST, and tags each gated step blocks or confirm. Only findings for something missing or unsupported block execution; forwarding and surface decisions are advisories you acknowledge at the prompt. --execute drives HubSpot’s own CLI, so install it and authenticate first (npm i -g @hubspot/cli, then hs accounts auth). A 2025.2 source gets its platformVersion bumped to 2026.03 and re-uploaded in place with hs project upload; if the build does not succeed, the bump is rolled back and the tree is left as found. A 2023.2 or 2025.1 source hands off to HubSpot’s interactive hs project migrate, which needs a real terminal to answer its feature prompts (--yes skips HS-X’s confirmation, not HubSpot’s). Cutover does not wrap non-project legacy apps; those run hs app migrate directly. Outside a terminal, --execute without --yes is refused rather than assumed.
HubSpot’s failure catalog for that step lives in the hs project migrate troubleshooting answer, and the version background lives in which platform version should I be on.
