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

[figure: Dupe-as-testbed · in-place cutover]

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`):

```sh
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:

```ts
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:

```ts
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](/docs/answers/legacy-public-app-to-projects) and the [marketplace listing guide](/docs/guides/marketplace-listing). For card swaps on listed apps, HubSpot auto-hides new app cards until you delete the rollout feature flags; the [legacy CRM cards answer](/docs/answers/legacy-crm-cards-to-ui-extensions) 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

```sh
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](/docs/answers/hs-project-migrate-troubleshooting), and the version background lives in [which platform version should I be on](/docs/answers/which-hubspot-platform-version).

**Where next**

- [Migrating a legacy public app to projects](/docs/answers/legacy-public-app-to-projects)
- [Where did serverless functions go in 2025.2?](/docs/answers/hubspot-serverless-functions-2025-2)
- [Local dev: test every migrated capability](/docs/guides/dev-mode)

