view .md
Guides · Migrate

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.

Outcome
A generated 2026.03 testbed app with every portable feature carried across, a printed list of the decisions only you can make, and a clear path into hs-x dev.
Prerequisites
  • A legacy HubSpot project on 2023.2, 2025.1, or 2025.2 (hsproject.json in the root)
  • @hs-x/cli installed (npm i -g @hs-x/cli)

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 easy 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-hsx

Here is a real 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 can be ported to a Worker-backed capability.
   - Serverless function quote-status uses 1 secret(s); remap them to Worker secrets on deploy.
   - Serverless function refresh-cache can be ported to a Worker-backed capability.
   - 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.
 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.
   - [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.
 
[ok] Validation
 
 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 ready

If you want the analysis without generating anything, hs-x migrate inspect . prints the same classification with no side effects, and hs-x migrate report . adds a readiness summary.

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 });
  },
});

This is an honest boundary. 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, and hs-x injects the values as Worker secrets on 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 current set:

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.

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.

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.

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.

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.

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 app

Work 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, you are ready for cutover planning: the in-place migration of your real app, which keeps its app ID, installs, and OAuth grants. The decision tree and failure catalog for that step live in the hs project migrate troubleshooting answer, and the version background lives in which platform version should I be on.