> ## Documentation Index
> Fetch the complete documentation index at: https://developers.hubspot.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate design manager serverless functions to developer projects

> Move CMS serverless functions from the design manager to developer projects, update the pages that call them, and switch over safely with a tested rollback path.

Serverless functions uploaded to the design manager (served from `/_hcms/api/`) are being sunset. Serverless functions built with developer projects (served from `/hs/serverless/`) are the supported replacement.

* **November 3, 2026:** you can no longer create new design manager serverless functions.
* **November 17, 2026:** you can no longer edit existing design manager serverless functions, including staging and deploying serverless packages through the private beta. Existing functions keep running.
* **February 1, 2027:** design manager serverless functions stop running, and requests to `/_hcms/api/` fail.

Learn more in the [changelog announcement](https://developers.hubspot.com/changelog/sunsetting-design-manager-serverless-functions-and-older-node.js-runtimes-for-custom-code-actions).

> [!NOTE]
>
> Serverless functions require ***Content Hub** Enterprise*. If your account doesn't have Content Hub Enterprise, you don't have any actively used design manager serverless functions, and you don't need to follow this guide.

HubSpot can't migrate your functions automatically, because each function's code and the pages that call it are unique to your site. This guide walks you through three phases, plus a recommended follow-up:

1. **Discover:** find every design manager function, its endpoint, and every page, template, module, and setting that calls it.
2. **Migrate:** rebuild each function in a developer project on the latest platform version.
3. **Test and switch over:** point your front-end code at the new endpoints one at a time, verify, and roll back if anything breaks.
4. **Follow-up:** once the migration is verified, update your function dependencies and any HubSpot API calls to the latest versions.

The recommended path is to hand the steps to an AI coding agent using the [migration prompt](#migrate-with-an-ai-agent-recommended). You can also [follow the steps yourself](#migrate-manually).

## Prerequisites

* ***Content Hub** Enterprise*, which serverless functions require.
* The latest [HubSpot CLI](https://developers.hubspot.com/docs/developer-tooling/local-development/hubspot-cli/install-the-cli.md) (version `8.4.0` or later), authenticated to the account that owns the functions. Check with `hs --version` and `hs accounts list`.
* Node.js v20 or later installed locally, which is what the HubSpot CLI requires. Your functions don't run on your machine, so your local version doesn't determine the Node version your deployed functions use.
* **If you're using AI to help migrate:** the [HubSpot developer MCP server](https://developers.hubspot.com/docs/developer-tooling/local-development/developer-mcp/setup.md) installed in your agentic coding tool. It gives the agent current HubSpot documentation and project schemas, which it needs to produce working code.
* Permissions in your HubSpot Account to edit the templates, modules, and website settings that call your functions.
* **If you have more than one account in the CLI:** run `hs accounts use <account>` first, or add `--account <account>` to each command. `hs project logs` doesn't accept `--account` and always uses the default account.

## Choose what to migrate

> [!TIP]
>
> **Recommended for most people: migrate only the serverless functions.** Leave your templates and modules where they are, and change only the endpoint URL they call. This is the smallest, safest change, and the only option we recommend if you're not a developer.

You can choose to move the front-end code that calls a function into the same project. The front-end and the function then deploy together in one build, so they can't drift out of sync, and a single rollback reverts both.

| Scope | Best for | Trade-off |
| - | - | - |
| Functions only | Everyone (recommended) | Front end and back end deploy separately. You roll back the front end by restoring the old endpoint URL. |
| Functions and individual modules | Developers whose module depends closely on the function's response | You must swap the old module for the new project module on every page that uses it. |
| The whole theme and functions together | Not recommended | Large migration that requires swapping templates on every page, with a high risk of unrelated breakage. |

To move a HubL module into a project, put it in a `hubl-modules` folder and rename its files with a `.hubl` extension, as described in [migrate a HubL theme to a project](https://developers.hubspot.com/docs/cms/start-building/introduction/react-plus-hubl/migrations/migrate-a-hubl-theme-to-projects.md). You can also rebuild it as a [React module](https://developers.hubspot.com/docs/cms/start-building/introduction/react-plus-hubl/modules.md). Either way, it becomes a new module in your account.

If you have many functions, consider grouping them into more than one project by what they're for, such as one project for form handling and one for a product catalog. Your old `.functions` folders are often a good starting point. A project deploys and rolls back as one unit, and its app's scopes apply to every function in it, so separate projects keep a rollback or a scope change from affecting unrelated features. Each project needs its own install and build history, though, so keep a few related functions in one project. Endpoint paths and secrets are shared across the whole account, whichever project uses them.

## Migrate with an AI agent (recommended)

Copy the prompt below into an AI coding agent, such as Claude Code, Cursor, or VS Code with GitHub Copilot, that has the HubSpot developer MCP server and the HubSpot CLI available. Run it from an empty working folder. The agent asks for your approval before it uploads, deploys, or changes anything live.

Migrate design manager serverless functions to developer projects, update their callers, and verify each one with a rollback path.

You are migrating HubSpot CMS serverless functions from the design manager (served at `/_hcms/api/<endpoint>`, being sunset) to a HubSpot developer project (served at `/hs/serverless/<path>`).

**Done means:** every function the user chose is running in a project on the current platform version, every caller points at the new URL, each one is tested on its live pages, and the user has your final report. The design manager functions are still in place, untouched, as a fallback.

**Rules**

* **Get approval before every live change:** `hs cms upload`, `hs project upload`, `hs project deploy`, `hs project install-app`, and any edit to live templates, modules, pages or settings. Before each one, say what changes, which pages it affects and how to roll it back. This is the user's live website.
* **Never delete** design manager functions, files or secrets, and never create, change or delete secrets yourself. Give the user the exact `hs secret` commands. Cleanup is the user's call once everything is verified.
* **Never print, log or return secret values.** When you port a function, drop any logging of secrets or environment values. If source code hard-codes a key or token, don't repeat it anywhere. Tell the user which files have it, that anyone with those files can use it, and recommend they regenerate it and store the new value as a secret.
* **Check, then claim.** Use the HubSpot developer MCP server (`search-docs`, then `fetch-doc`) instead of memory, and `get-feature-config-schema` before editing any `*-hsmeta.json`. Call something "confirmed" only with recorded evidence: a response you compared, a page you checked, a schema you read. Otherwise write "unconfirmed" and why. If docs conflict with this prompt or with what you observe, use what you observe and tell the user.
* **Use only the current platform version** from the versioning doc. Never use `2025.1` (sunset) or `2025.2` (no serverless support), even where older CMS pages mention them.
* **Keep `MIGRATION_INVENTORY.md`** in the working folder, updated after every step. It's the source of truth.
* **Stop and wait** at every question and approval this prompt asks for, even when you think you can work out the answer yourself, and when access is missing or you hit a confirmed blocker. Between those points, keep going without interim check-ins.

**Known platform behavior**

These were observed on live accounts. Check each one when it matters; if you see something different, use what you see and record it.

* Query parameters arrive in `context.params`, as arrays of strings, as before. `context.query` is undefined, even though the new reference lists it.
* In `config.endpoint`, the method is a `methods` array, such as `["GET", "POST"]`.
* `context.accountId` is set, even on unauthenticated calls. `context.limits` doesn't exist; the only limit signal is a `429` response. `context.contact` has the same fields as in the design manager (`vid`, `email`, `firstname`, `lastname`, `lastmodifieddate`, `ip_country_code`, `isLoggedIn` and `listMemberships`) for known contacts and logged-in members, and is absent for anonymous visitors. `context.secrets` and `context.endpoint` don't exist. `context.body` is `{}` on requests without a body, where it used to be undefined. The `x-client-ip` header isn't passed; use `true-client-ip`.
* `Set-Cookie` via `multiValueHeaders`, `301` with `Location` and custom headers all work. Custom headers that the design manager dropped are now passed through.
* `hs project upload` deploys the build at once, so new endpoints are public immediately, including ones that write data. Use `--skip-auto-deploy` to stage a build. A first upload asks to create the project; add `--force-create` in a non-interactive shell.
* `PRIVATE_APP_ACCESS_TOKEN` is empty until the app is installed (`hs project install-app`), so calls return `401`. After any scope change, upload and reinstall. `install-app` asks for confirmation; add `--force` in a non-interactive shell.
* `console.log` output from endpoint functions isn't readable from the CLI or MCP; `hs project logs` only prints a link for the user. Diagnose from response comparisons. If you need to inspect something, return key names and types (never values) in a temporary response, on a hard-to-guess path, with approval, and remove it before switching any caller.
* The CLI can't edit page, post or settings HTML, so callers there are user edits. Callers in design manager files (templates, modules, JS) are changed with `hs cms upload`, with approval.
* A form-urlencoded `POST` returns `415`.
* If `package-lock.json` resolves from a private registry, the build fails. Regenerate it against `https://registry.npmjs.org` in a folder without `node_modules`.

**Phase 1: Discover**

1. Preflight: `hs --version` (8.4.0 or later) and `hs accounts list`. Ask the user which account to migrate before you run any command against an account, then pass `--account` on every command and run nothing against other accounts. If the MCP server isn't available, stop and send the user to [https://developers.hubspot.com/docs/developer-tooling/local-development/developer-mcp/setup](https://developers.hubspot.com/docs/developer-tooling/local-development/developer-mcp/setup.md).
2. Before any discovery, ask these and wait for the answers: are they a developer comfortable editing code? Do they want functions only (safest, and the only option for non-developers) or functions plus specific modules (front and back end deploy together, but every page using a module must swap to the new one; a full theme isn't recommended)? Does the account have Content Hub Enterprise? (If not, there's nothing to migrate, so stop.) If you don't already know the user's name and can't find it (for example with `git config user.name`), ask what name should go in the migration notes. Then list domains with `hs api /cms/v3/domains` for them to confirm.
3. `hs cms function list --json`: record every endpoint, method and secret. Every function is in scope unless the user excludes it.
4. Back up into `./dm-backup` (tell the user it's their backup): `hs cms list /`, then `hs cms fetch <folder> ./dm-backup/<folder>` for every folder except `@hubspot`, since callers can be in any of them. Don't fetch `/`: it includes `@hubspot`, takes far longer, and often fails partway. Refetch any folder whose output says "Not all files ... were successfully fetched".
5. For each `.functions` folder, record per endpoint the path, methods, file, secrets, environment values and runtime. In the code, record the context fields used (`context.secrets.NAME` becomes `process.env.NAME`), `sendResponse`, imported packages (`axios` was preloaded at 0.19, and `request` is deprecated), cookies, redirects, headers, any webpack bundle, hard-coded keys or tokens, retry or polling loops, and every HubSpot API call with its version.
6. Find every caller. Search the backup for `_hcms/api` and for each endpoint path's last segment on its own, since paths are joined at runtime, and for `portalid` query parameters. A caller may not look like a URL: it can be a prefix variable, a meta tag, a data attribute, a module field, a theme setting or a config value. Then check `headHtml` and `footerHtml` in `hs api "/cms/v3/pages/site-pages?limit=100"` and `landing-pages` (follow `paging.next.after`). Ask the user to check site, blog and post header and footer HTML, rich text and HTML modules, and external apps. Search each domain's live pages (from `/sitemap.xml`) and the JavaScript they load for `_hcms/api`. Other `/_hcms/` requests come from HubSpot.
7. For each caller's template or module, list the pages that use it from the pages data (`templatePath`, `currentState`), including drafts. Mark which are published. These are the test pages.
8. Check usage (`hs cms function logs <endpoint> --account <id>`), and call each read-only endpoint once. Note functions that already fail, including ones that return success while their work fails; any function that authenticates with an API key (`hapikey`) has been failing since the November 30, 2022 sunset ([https://developers.hubspot.com/changelog/upcoming-api-key-sunset](https://developers.hubspot.com/changelog/upcoming-api-key-sunset)).
9. Write the inventory as a numbered table: #, old endpoint, methods, source, secrets, environment values, context fields, HubSpot APIs (version), usage and current behavior, new path, project, callers (where, how it's set, user edit or not), pages to test (published or draft), risk notes, status (Discovered, Ported, Endpoint tested, Switched, Verified, Rolled back, Blocked).

**Exit check (stop and wait):** show the user the inventory. For each broken or unused function, ask whether to fix, retire or skip it, and why; don't recreate broken behavior. Propose how to group the functions into projects, by what they're for, starting from the old `.functions` folders. A project deploys and rolls back as one unit, and its app's scopes apply to every function in it. Use one project for a few related functions, and split when functions serve unrelated features or need very different scopes. Get the user's approval for the grouping, then approval before Phase 2. For each function retired or skipped, add a migration note (see Phase 2) to the top of its design manager file, with the decision and reason, and upload it with approval. A comment-only upload still redeploys the function, so call it once afterwards. If the upload fails because the folder's `serverless.json` uses `nodejs12.x`, don't change the runtime, since that changes the fallback function; record the note in `MIGRATION_INVENTORY.md` instead.

**Phase 2: Migrate**

1. Create one project per group, with a name that says what it's for, using the MCP `create-project` tool (or `hs project create`): app base, static auth, private distribution, feature `app-function-endpoint`. OAuth apps can't use serverless functions. Remove the template's example function, its scopes (keep `oauth` in `requiredScopes`) and `@hubspot/api-client` if unused. Confirm `platformVersion` matches the current version. If the user already has a project with functions on `2026.03`, it runs Node.js 24 and doesn't need migrating now: tell them, and ask whether to add the functions there or to a new project on the current version. If it's on a version older than `2026.03`, follow the migrate-an-app docs and run `hs project migrate` instead. If they're moving modules, fetch the "Migrate a HubL theme to a project" doc first: HubL modules go in a `hubl-modules` folder with `.hubl` file extensions, and each becomes a new module that pages must swap to.
2. For each function, add an `app-function-endpoint` feature and validate it against the schema:
   * Keep the same path after the prefix. Paths are global across the account's projects, and the last deploy wins, so check for clashes.
   * Start the file with a migration note comment, so someone opening it later knows where it came from: migrated from (old endpoint and file), date, the user's name and that an AI agent did the port, each change made and why in a line (such as "auth moved to the built-in token"), and the guide link: [https://developers.hubspot.com/docs/cms/reference/serverless-functions/migrate-design-manager-serverless-to-projects](https://developers.hubspot.com/docs/cms/reference/serverless-functions/migrate-design-manager-serverless-to-projects.md). Never put secret values in it.
   * Convert to `async (context)` with `return` and try/catch. Await all work before returning; anything still running after the return may never finish. Give every retry or polling loop a limit, since a loop that keeps going on errors such as `401` or `403` only ends at the 15-second timeout. Preserve status codes, body shape and headers.
   * Keep HubSpot API calls on their current versions until Phase 4, including in functions you're fixing: change only what's needed to make them work (usually authentication). Old versions keep working with the built-in token.
   * Check `hs secret list` first: existing secrets are reused as-is. Move environment values that aren't secrets yet to secrets by giving the user `hs secret add NAME` commands (they enter the values; don't repeat values in chat or files). List every secret in `secretKeys`.
   * For a token stored as a secret, recommend `process.env.PRIVATE_APP_ACCESS_TOKEN` (asking first; keeping the secret is fine). For API-key functions, use the built-in token. Take scopes for each HubSpot API call from that endpoint's API reference page, not from scope names, since similar names cover different operations. Retired scopes fail the deploy with "has been replaced by more specific scopes"; use the ones it names.
   * Add to `package.json` only what each function imports, at current stable versions. Review axios's breaking changes since 0.19, and replace `request`.
3. Add `src/README.md` to each project, since only `hsproject.json` and `src/` are uploaded. It should say why the project exists (moved from the design manager for the sunset, date, the user's name, guide link), map each old endpoint to its new one, list retired or skipped functions and why, name the secrets and scopes the functions need (no values), and say how to roll back. Set the app's `description` to one line that points to it.
4. Validate, then ask for approval to upload. Record the build number, then ask for approval to install the app if the token is used.

**Exit check:** the build succeeded and every new endpoint responds.

**Phase 3: Test and switch, one function at a time**

1. Call the old and new URLs with the same method, query, body and headers the front end sends. Compare the status, body and key headers, and record both. For write endpoints, ask the user how to test safely first; every test write counts, including invalid ones, and so do side effects such as publishing.
2. If modules moved into the project, first deploy a build where the new function exists but the front end still calls the old URL. Record it as the ROLLBACK BUILD.
3. Save the exact original text of this function's callers in `./rollback/`, including the text of UI-only callers. Make the edit in a copy under `./staged/` (never edit `./dm-backup` or `./rollback`), and show the user the `diff` against the backup when you ask for approval. Change only this function's lines, even when the file serves other functions. After the upload, fetch the file back and compare it with the staged copy. Pages update in about a minute; check the served HTML or JS before testing.
4. Verify each page in "Pages to test" in a browser (feature used, network requests, console), or give the user a short checklist and wait. Mark it Verified only when all pages pass. If every page using a caller is a draft, there's no served page to check: verify the fetched-back file, call the new endpoint on each domain the page could publish to, offer the user an optional preview check, and say in the inventory that no published page was tested. Then, with approval, add a note to the top of the old design manager file (migrated to the new path, date, name, guide link) and upload it. For a `nodejs12.x` folder, record the note in the inventory instead, as in the Phase 1 exit check.
5. After the first switch, practice a rollback: restore that caller from `./rollback/`, confirm the old endpoint is served and works, then re-apply.
6. If anything fails, roll back right away (saved originals, the user pasting back saved UI text, or the ROLLBACK BUILD), mark the row Rolled back, diagnose, fix and repeat. Mark it Blocked only when the next step needs the user or HubSpot.
7. When all of a project's functions are Verified, record its deployed build as that project's KNOWN-GOOD BUILD, and add it to the project's README.

**Phase 4: Follow-up (ask first; recommended)**

Update dependencies to current stable versions, then move each HubSpot API call to the latest GA date-based version where a documented equivalent exists. Copy paths exactly from the API reference, put the version in one constant per function, and expect response shapes to change. Show the user a table of planned changes, get approval, and do it as a separate build. Repeat the Phase 3 tests, and redeploy the KNOWN-GOOD BUILD if anything fails.

**Docs (open with `fetch-doc` when needed)**

* Old functions reference: [https://developers.hubspot.com/docs/cms/reference/serverless-functions/serverless-functions](https://developers.hubspot.com/docs/cms/reference/serverless-functions/serverless-functions.md)
* New functions reference: [https://developers.hubspot.com/docs/apps/developer-platform/add-features/serverless-functions/reference](https://developers.hubspot.com/docs/apps/developer-platform/add-features/serverless-functions/reference.md)
* Platform versions: [https://developers.hubspot.com/docs/developer-tooling/platform/versioning](https://developers.hubspot.com/docs/developer-tooling/platform/versioning.md)
* Existing projects on older versions: [https://developers.hubspot.com/docs/apps/developer-platform/build-apps/migrate-an-app/overview](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/migrate-an-app/overview.md)
* Phase 4: [https://developers.hubspot.com/blog/date-based-api-versioning-migration-playbook](https://developers.hubspot.com/blog/date-based-api-versioning-migration-playbook)

**Final report**

Give the user:

* the final inventory, with what was tested and how;
* anything unconfirmed or Blocked, and what's needed;
* every rollback point: saved originals, UI text to paste back, and the rollback and known-good builds (`hs project deploy --build <number> --force --account <id>`);
* any hard-coded keys or tokens you found, and that the user should regenerate them;
* optional cleanup for them to do: old `.functions` folders, unused secrets, and test data you created. Deleting a `.functions` folder also deletes its migration notes, so keep `MIGRATION_INVENTORY.md` and each project's README as the record.

## Migrate manually

If you prefer to migrate by hand, the rest of this page is for you: the key differences between the two systems, the three phases, the follow-up, and troubleshooting.

### Key differences

The table below lists only what changes. Behavior that works the same in both systems, such as reading a JSON request body from `context.body`, reading `context.contact`, and reading secrets with `process.env.NAME`, is omitted.

| # | Area | Design manager (old) | Developer projects (new) |
| - | - | - | - |
| 1 | Public URL | `https://<domain>/_hcms/api/<endpoint>` | `https://<domain>/hs/serverless/<path>`, where `<path>` is `config.endpoint.path`. Paths can include slashes, such as `events/update`. |
| 2 | Configuration file | One `serverless.json` with an `endpoints` object | One `*-hsmeta.json` file per function, with `"type": "app-function"` and a `config.endpoint` object |
| 3 | Runtime | `runtime` and `version` fields in `serverless.json` (for example, `nodejs20.x`, or `nodejs12.x` in older functions) | Fields removed. The platform version sets the runtime: `2026.03` and later run Node.js 24. Functions run on HubSpot's servers, not your machine. |
| 4 | Environment variables | `environment` object in `serverless.json` | No `environment` object. Use [secrets](https://developers.hubspot.com/docs/apps/developer-platform/add-features/serverless-functions/reference.md#managing-and-referencing-secrets) for values your code reads at runtime. |
| 5 | Secrets configuration | `secrets` array, read with `process.env.NAME` or the older `context.secrets.NAME` | `config.secretKeys` array, read with `process.env.NAME`. Existing account secrets are reused. |
| 6 | HubSpot API authentication | A private app token or [service key](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys.md) stored as a secret | Built-in `process.env.PRIVATE_APP_ACCESS_TOKEN`, scoped by the app's configured scopes. You can keep using a service key stored as a secret, but moving to the app token is recommended. See [move HubSpot API authentication](#move-hubspot-api-authentication). |
| 7 | Function signature | `exports.main = (context, sendResponse) => ...` | `exports.main = async (context) => ...` with `return` |
| 8 | Query string | `context.params.name` (array of strings) | Unchanged: `context.params.name` (array of strings). Don't use `context.query`, which is undefined in public functions. |
| 9 | Other context fields | `context.secrets` and `context.endpoint` | Not available. Read secrets with `process.env.NAME`. |
| 10 | Rate limit info | `context.limits` (`executionsRemaining`, `timeRemaining`) | Not available. A function that exceeds the limits returns a `429` response. See [check rate limits and contact data](#check-rate-limits-and-contact-data). |
| 11 | Dependencies | A preloaded set (`axios` 0.19, `request`, `@hubspot/api-client`), or bundle with webpack | Any public npm package, declared in `package.json`. Nothing is preloaded. |
| 12 | Execution timeout | 10 seconds | 15 seconds |
| 13 | Deploys | `hs cms upload` goes live immediately | `hs project upload` creates a numbered build and deploys it immediately. Add `--skip-auto-deploy` to upload without deploying. You can redeploy any previous build. |
| 14 | Logs | `hs cms function logs <endpoint>` | The app's **Logs** tab in HubSpot. `hs project logs --function <uid>` prints a link to it, but doesn't show log lines for public functions. |
| 15 | Endpoint scope | Account | Global across all projects in the account. If two projects use the same path, the most recently deployed one wins. |
| 16 | Response headers | Some custom headers, such as `X-` headers, are dropped from the response | Custom headers are passed through. Cookies set with `multiValueHeaders` and `301` redirects with a `Location` header work as before. |
| 17 | Empty request body | `context.body` is undefined when the request has no body, such as a `GET` | `context.body` is `{}`. Check for the fields you need instead of `if (context.body)`. |
| 18 | Client IP header | `x-client-ip` and `true-client-ip` | `true-client-ip` only. Use it, or `x-forwarded-for`, instead of `x-client-ip`. |

#### Code changes at a glance

The following examples show how each part of a design manager function translates to a developer project. They aren't a copy-and-paste replacement for your own function. Use them to understand which parts change and why.

##### Function configuration

The following example shows a `serverless.json` file with one endpoint, and the equivalent `*-hsmeta.json` file in a developer project.

```json serverless.json (old) theme={null}
{
  "runtime": "nodejs20.x",
  "version": "1.0",
  "environment": { "API_BASE": "https://api.example.com" },
  "secrets": ["EXAMPLE_API_KEY"],
  "endpoints": {
    "events/update": { "method": "POST", "file": "update.js" }
  }
}
```

```json events-update-hsmeta.json (new) theme={null}
{
  "uid": "events_update",
  "type": "app-function",
  "config": {
    "entrypoint": "/app/functions/update.js",
    "secretKeys": ["EXAMPLE_API_KEY", "API_BASE"],
    "endpoint": { "path": "events/update", "methods": ["POST"] }
  }
}
```

What changed:

* **Runtime fields:** `runtime` and `version` are gone. The platform version in `hsproject.json` determines the runtime.
* **One file per function:** the `endpoints` object is replaced by a separate `*-hsmeta.json` file for each function. The endpoint's path and method move into `config.endpoint`, where the method becomes a `methods` array, such as `["POST"]` or `["GET", "POST"]`. The `file` value becomes `config.entrypoint`.
* **Unique identifier:** each function needs a `uid`, which must be unique across all functions in the project.
* **Environment variables:** `API_BASE` had no equivalent in the new configuration, so this example moves it to a secret and lists it in `secretKeys` alongside `EXAMPLE_API_KEY`.

##### Function code

The following example shows a function that reads a query parameter, calls an external API, and returns the result.

```js update.js (old) theme={null}
exports.main = (context, sendResponse) => {
  const id = context.params.id[0];
  axios
    .get(`${process.env.API_BASE}/events/${id}`)
    .then((res) => sendResponse({ statusCode: 200, body: res.data }))
    .catch((err) => sendResponse({ statusCode: 500, body: { error: err.message } }));
};
```

```js update.js (new) theme={null}
const axios = require("axios");

exports.main = async (context) => {
  try {
    const id = context.params.id[0];
    const res = await axios.get(`${process.env.API_BASE}/events/${id}`);
    return {
      statusCode: 200,
      body: res.data,
      headers: { "Content-Type": "application/json" },
    };
  } catch (err) {
    console.error(err.message);
    return { statusCode: 500, body: { error: "Request failed" } };
  }
};
```

What changed:

* **Response handling:** the `sendResponse` callback is replaced by an `async` function that `return`s the response object. Promise chains become `await` inside `try`/`catch`. Await every request before you return, including follow-up calls whose result the caller doesn't need: work that's still running when the function returns may never finish.
* **Query parameters:** unchanged. `context.params` still holds each query parameter as an array of strings, so `context.params.id[0]` works as before.
* **Dependencies:** the old function used `axios` without importing it because HubSpot preloaded it. In a project, you must `require` the package and add it to `src/app/functions/package.json`.
* **Error responses:** the new example logs the error and returns a generic message instead of passing `err.message` to the caller. Catch every error: if a function throws, it returns a `500` response whose body includes the raw error message, which anyone who calls the endpoint can see.
* **Logging:** don't carry over log lines that print secrets or environment values. Anyone with access to the app can read its logs.

##### Calling the function from a module

In your front-end code, change only the URL prefix, for example from `/_hcms/api/events/update` to `/hs/serverless/events/update`. Because the function path stays the same, the request method, headers, and body don't change.

### Phase 1: Discover

Build an inventory before you change anything. It's your migration checklist and, later, your test plan.

**1. List deployed functions**

Run `hs cms function list`. Record each endpoint path, method, and secret.

**2. Fetch and back up your design manager files**

In an empty folder, run `hs cms fetch / ./dm-backup`. Keep this folder; it's your backup, and the source for rolling back each caller.

On a large account, this can take 20 minutes or more. If the output ends with "Not all files ... were successfully fetched," fetch the missing folders again, at least every `.functions` folder and every folder that holds templates or modules.

**3. Find each function's source**

Search the backup for folders ending in `.functions`. In each `serverless.json`, note the environment variables, secrets, and the file each endpoint uses.

**4. Find every caller in your code**

Search the backup for `_hcms/api`, and for the last segment of each endpoint path on its own (for example, `update` for `events/update`), since paths are often built from variables or joined strings. Check templates, partials, modules (`module.html`, `module.js`, `fields.json`), and standalone JavaScript files.

A caller doesn't always look like a URL. It can be a prefix that's joined to a path at runtime, or a value stored in a meta tag, a data attribute, a module field, a theme setting, or a configuration object. Record where each one lives and how it's set, since that's also how you'll change it and roll it back.

**5. Check code added outside the design manager**

Endpoints are sometimes called from code added in settings or editors depending on what they do:

* **Settings** > **Content** > **Pages** > **Templates**: site header HTML and site footer HTML, for every domain.
* **Settings** > **Content** > **Blog** > **Templates**: header and footer HTML, for every blog.
* Each page's and post's head and footer HTML, in the page's **Settings** > **Advanced**.
* Rich text and custom HTML modules on pages.
* External sites or apps that call your HubSpot domain directly.

To check every website and landing page's head and footer HTML at once, use the CMS pages API, for example `hs api "/cms/v3/pages/site-pages?limit=100"` and `hs api "/cms/v3/pages/landing-pages?limit=100"`, and search each page's `headHtml` and `footerHtml`. Check blog posts and the settings above in HubSpot. Your domains are listed in **Settings** > **Content** > **Domains & URLs**.

**6. Confirm on the live site**

Open your key pages, then in your browser's developer tools **Network** tab, filter on `_hcms/api`. Every request that matches is a dependency that belongs on your list. Other `/_hcms/` requests, such as the cookie banner and live chat, come from HubSpot and aren't your functions.

**7. Record affected pages**

For each template or module that calls a function, list the pages that use it. These are the pages you'll test. The pages API responses from the previous step include each page's `templatePath` and `currentState`, so you can find pages that use a template, including unpublished drafts. Before you decide a change won't affect visitors, confirm from this data that no published page uses the template or module.

**8. Check usage and current behavior**

For each function, run `hs cms function logs <endpoint>` to see recent calls, and call each endpoint that only reads data. A function with no recent calls and no callers may not need migrating. A function that already returns errors, or returns success while its work fails (for example, because it authenticates with an expired API key), needs fixing or retiring, not matching. If you retire or skip a function, add a comment to the top of its design manager file with the decision, the reason, the date, who decided, and a link to this guide, then upload it with `hs cms upload`. That keeps the decision with the code for whoever finds it later. If the folder's `serverless.json` uses `nodejs12.x`, the upload fails with "Serverless runtime 'nodejs12.x' is no longer supported". Don't change the runtime to get it through, since that changes the function you're keeping as a fallback; record the decision in your inventory instead.

Your inventory should have one row per function:

| # | Old endpoint | Method | Source file | Secrets | Environment variables | New path | Project | Callers | Pages to test | Status |
| - | - | - | - | - | - | - | - | - | - | - |
| 1 | `/_hcms/api/events/update` | POST | `my.functions/update.js` | `EXAMPLE_API_KEY` | `API_BASE` | `/hs/serverless/events/update` | `events-functions` | `events.module/module.js` | `/events`, `/events/register` | Discovered |

### Phase 2: Migrate

**1. Create a project on the latest platform version**

If you're grouping functions into several projects, repeat this phase for each one, and give each project a name that says what it's for.

Run `hs project create`, and create an app with **static** auth, **private** distribution, and the **App Function (endpoint)** feature. Apps that use OAuth can't use serverless functions. To skip the prompts, run:

```shell theme={null}
hs project create --name <name> --dest <folder> --project-base app --distribution private --auth static --platform-version 2026.09 --features app-function-endpoint
```

Confirm that `platformVersion` in `hsproject.json` is the current version listed in [developer platform versioning](https://developers.hubspot.com/docs/developer-tooling/platform/versioning.md) (`2026.09` at the time of writing).

> [!WARNING]
>
> Don't build on `2025.1` or `2025.2`. Version `2025.1` is sunset, and `2025.2` doesn't support serverless functions. If you already have a project on a version older than `2026.03`, [migrate it to the latest version](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/migrate-an-app/overview.md) first. Projects already on `2026.03` run Node.js 24 and don't need to be migrated at this time, but use the latest version for new projects.

**2. Port each function**

Create one `*-hsmeta.json` and `.js` pair per endpoint, using the [key differences](#key-differences). Change only what the new platform requires, and keep HubSpot API calls on the versions they use today; you'll update those in the [follow-up](#follow-up-update-dependencies-and-hubspot-api-versions).

* Remove what the template added that you don't use: the example function and its `*-hsmeta.json` file, the example scopes in `app-hsmeta.json` (keep `oauth`), and the `@hubspot/api-client` dependency.
* Keep the same path after the prefix (`events/update` stays `events/update`), so the front-end change is only a prefix swap.
* Start each new function file with a migration note comment: the old endpoint and file it came from, the date, who did the migration (and whether an AI agent did the port), each change made and why, and a link to this guide. Someone who opens the file later, including you, can then tell where it came from. Never put secret values in it.
* Convert callbacks to `async` functions that `return` the response. Give every retry or polling loop a limit: a function stops after 15 seconds, so a loop that keeps retrying on an error such as `401` only times out.
* Move environment variables to secrets with `hs secret add NAME`, then list them in `secretKeys`. If a function hard-codes an API key or token, move it to a secret too, and regenerate the key, since anyone who has the file can use it.
* Add only the packages each function imports to `src/app/functions/package.json`. HubSpot preloaded a set of packages for every design manager function, but that doesn't mean every function used them. See [handle dependencies](#handle-dependencies).
* If a function authenticates to HubSpot APIs with a private app token, service key, or API key, see [move HubSpot API authentication](#move-hubspot-api-authentication).

> [!WARNING]
>
> **Please note:** if a function uses `context.limits`, follow [check rate limits and contact data](#check-rate-limits-and-contact-data) before you switch any traffic to it.

**3. Explain the project in a README**

Add a `src/README.md` file, so anyone who finds the project later knows why it exists. Put it in `src/`, because `hs project upload` sends only `hsproject.json` and the `src/` folder; files at the project root don't reach HubSpot. Include:

* that the functions moved from the design manager because of the sunset, the date, who migrated them, and a link to this guide;
* each old endpoint and its new one, and any functions you retired or skipped, and why;
* the secrets and scopes the functions need (names only, never values);
* how to roll back, and your known-good build once you have one.

Also set the app's `description` in `app-hsmeta.json` to one line that points to the README, since that's what appears in HubSpot.

**4. Upload the project**

Run `hs project validate`, then `hs project upload`. Note the build number. If your functions use the app's built-in token, install the app after the first upload with `hs project install-app`.

> [!NOTE]
>
> Your design manager functions keep running during this phase, so nothing changes for site visitors yet. Your new endpoints are public as soon as you upload, though, because `hs project upload` deploys the build immediately. Don't call endpoints that write data until you're ready to test them.

#### Move HubSpot API authentication

Functions in the design manager typically authenticated to HubSpot APIs with a token stored as a secret. Today that's most likely a [service key](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys.md), or a legacy private app token.

You have two options:

* **Keep the existing secret:** the secret is reused as long as you list its name in `secretKeys`. This is the fastest path, but the key stays outside your project.
* **Use the app's built-in token (recommended):** every function in a project can read `process.env.PRIVATE_APP_ACCESS_TOKEN`, which is scoped to the app in your project. Add the same scopes your service key or private app has to the app's `requiredScopes` in `app-hsmeta.json`, then replace the secret in your code with the built-in token.

To keep behavior identical, copy the exact scopes from the service key's details page in the HubSpot **Development** area. Add only the scopes your functions use. To check which scopes a call needs, open that endpoint's page in the [API reference](https://developers.hubspot.com/docs/api-reference/latest/overview.md) and use the scopes it lists. Don't go by scope names alone: similar-sounding scopes can cover different operations, such as reading a table versus reading its rows. Some older scopes have been retired and replaced by more granular ones. `hs project validate` doesn't check this, so if the deploy fails with a message that a scope "has been replaced by more specific scopes," use the scopes the message names.

The built-in token is available only after the app is installed in the account. After your first upload, run `hs project install-app` from the project folder. Until the app is installed, HubSpot API calls that use the token return `401`. If you change the app's scopes later, upload the project and run `hs project install-app` again, so the token gets the new scopes.

If a function authenticates with an API key (a `hapikey` query parameter, often read from `context.secrets`), it's already failing: [API keys were sunset on November 30, 2022](https://developers.hubspot.com/changelog/upcoming-api-key-sunset), and reusing the secret won't help. Switch it to the built-in token. API keys had no scopes, so look up the scopes for each API the function calls in the API reference, as described above.

#### Check rate limits and contact data

Design manager functions receive `context.limits`, which reports how many executions per minute and how much execution time remain. Functions in developer projects don't receive `context.limits`, and there's no replacement field. If your function used it to slow down before reaching the account limits, remove that logic and handle `429` responses instead, for example by retrying after a delay in the front-end code that calls the function.

Functions in developer projects receive the same `context.contact` as design manager functions, with the fields `vid`, `email`, `firstname`, `lastname`, `lastmodifieddate`, `ip_country_code`, `isLoggedIn` and `listMemberships`. As before, it's present for visitors who have a HubSpot cookie and are known contacts, including logged-in members, and absent for anonymous visitors.

If your function sets `Set-Cookie` headers or depends on `context.contact.isLoggedIn` or `listMemberships`, test those cases on a live page, not only with direct requests.

#### Handle dependencies

Dependencies are a common source of migration issues, so plan for them before you upload.

* **Add only what each function uses:** search your function files for `require` and `import`, and install the current stable version of each package in `src/app/functions/` with `npm install`. A package being preloaded in the design manager doesn't mean a function used it.
* **Check `axios` and `request`:** design manager functions had `axios` 0.19 preloaded, so review the [axios release notes](https://github.com/axios/axios/releases) for changes to error handling and response objects. The `request` package is deprecated; replace it with `axios` or the built-in `fetch`.

### Phase 3: Test and switch over

Switch one function at a time.

**1. Test the new endpoint directly**

Call `https://<domain>/hs/serverless/<path>` with the same method, query, and body your front end sends, and compare the response to the old endpoint. Status code, body shape, and headers should match. Check the app's **Logs** tab for errors.

**2. Switch the front end**

Change this function's callers from `/_hcms/api/` to `/hs/serverless/`. Upload changed design manager files with `hs cms upload`, and publish any page or setting you edited in HubSpot. Callers in page, post, or settings HTML can only be edited in HubSpot, not with the CLI. If one file calls several functions, for example through a shared prefix variable, change only this function's call, so you test and roll back one function at a time.

Edit copies of the files, not the ones in your `dm-backup` folder, so the backup stays a copy of what's live today.

Before you edit a caller in HubSpot, copy its current text somewhere safe. Your `dm-backup` folder only covers design manager files, so this copy is how you'll restore that caller.

Live pages can take about a minute to show the change, so wait, then hard-refresh before you test.

**3. Verify the live pages**

On each page in your inventory, use the feature the way a visitor would. Watch the browser console and **Network** tab for failed requests. If a template or module is used only on draft pages, preview the page in HubSpot instead, and call the new endpoint on each domain the page could be published to.

**4. Roll back if anything fails, then fix**

* **Functions only:** restore the old `/_hcms/api/` URL in every caller you changed: re-upload the original design manager files from your `dm-backup` folder, and paste back the text you saved for any caller you edited in HubSpot. Restoring a file doesn't undo edits made in HubSpot. The old function is still running.
* **Front end in the project:** redeploy an earlier build from the project's **Builds** tab, or with `hs project deploy`.

Then read the logs, fix the function, upload a new build, and repeat from the first step.

> [!TIP]
>
> Practice once before you need it: after your first switch, roll that caller back, confirm the page calls the old endpoint and works, then switch it again.

> [!TIP]
>
> If you moved front-end code into the project, first deploy one build where the new function exists but the front end still calls the old URL. That build is a known-good rollback point that keeps visitors unaffected while you fix newer builds.

**5. Mark the function verified**

Update its status in your inventory once every page passes. Add a comment to the top of the old design manager file noting the new path, the date and who migrated it, and upload it, so anyone who opens the old file knows it's been replaced. A `nodejs12.x` folder won't accept the upload, so record the note in your inventory instead. Note the build number once every function is verified; it's your known-good build for the follow-up.

**6. Clean up**

Only after every function is verified, remove the old `.functions` folders from the design manager and delete secrets that nothing uses. Deleting a folder also deletes its migration and retirement comments, so keep your inventory and each project's README as the record.

### Follow-up: Update dependencies and HubSpot API versions

Once every function is verified, make two further updates while the code is fresh. Do them as separate changes, so a problem with a dependency or an API version can't be confused with a problem in the migration.

**1. Update dependencies**

Update the packages in `src/app/functions/package.json` to their latest stable versions, and review each package's release notes for breaking changes. Upload a new build and repeat the Phase 3 tests for each function that uses an updated package.

**2. Inventory HubSpot API calls**

If your functions call HubSpot APIs, update them to the latest [date-based API version](https://developers.hubspot.com/docs/developer-tooling/platform/versioning.md#api-versioning). Search your function code for `api.hubapi.com`, `api.hubspot.com`, versioned paths such as `/v3/` or `/v4/`, and `@hubspot/api-client`. For each call, record the endpoint, method, current version, and whether a date-based equivalent exists.

**3. Choose a target version**

Use the latest GA date-based version (for example, `2026-09`). In the [API reference](https://developers.hubspot.com/docs/api-reference/latest/overview.md), copy each endpoint path exactly as documented. Endpoints without a date-based equivalent stay on their current version for now.

**4. Update the code**

Define the API version in one constant rather than in every URL. A new API version can change request and response shapes, not only paths, so compare each function's response before and after the change. If you use `@hubspot/api-client`, confirm that the SDK release you install supports the date-based endpoints you need.

**5. Upload, test, and roll back if needed**

Upload a new build and repeat the Phase 3 tests for each function. If anything fails, redeploy your known-good build, then fix and try again.

For a full walkthrough, see the [date-based API versioning migration playbook](https://developers.hubspot.com/blog/date-based-api-versioning-migration-playbook).

## Troubleshooting

| # | Symptom | Likely cause | Fix |
| - | - | - | - |
| 1 | `404` from `/hs/serverless/<path>` | Build not deployed, or path mismatch | Check the build status and `endpoint.path` in the `*-hsmeta.json` file. |
| 2 | Function returns a `500` or different data | Code reads `context.query`, which is undefined, or another context field that isn't available | Read query parameters from `context.params` (arrays of strings), and compare old and new responses. |
| 3 | Config values are `undefined` | Environment variables aren't injected into functions | Move them to secrets and add them to `secretKeys`. |
| 4 | CORS error in the browser | Page and endpoint use different domains or protocols | Call the endpoint from the same domain and protocol as the page, or use a relative URL. |
| 5 | A different function responds on your path | Another project uses the same path | Rename one path. Paths are global across projects. |
| 6 | Build fails with invalid configuration | Schema error | Run `hs project validate`, and confirm `uid` values are unique. |
| 7 | Function times out | Execution exceeds 15 seconds | Reduce external calls, or cache results. |
| 8 | `415` response to a form `POST` | The function received an `application/x-www-form-urlencoded` body | Send the body as JSON with a `Content-Type: application/json` header. |
| 9 | Build fails because `package-lock.json` references npm registries that can't be reached | The lockfile was generated against a private npm registry | In `src/app/functions/`, delete `package-lock.json` and the `node_modules` folder, then run `npm install --registry https://registry.npmjs.org`. An existing `node_modules` folder can bring the private registry back into the new lockfile. |

## Related resources

* [Serverless functions reference (design manager)](https://developers.hubspot.com/docs/cms/reference/serverless-functions/serverless-functions.md)
* [Serverless functions reference (developer platform)](https://developers.hubspot.com/docs/apps/developer-platform/add-features/serverless-functions/reference.md)
* [Serverless functions for the CMS](https://developers.hubspot.com/docs/cms/start-building/features/serverless-functions/overview.md)
* [Developer platform versioning](https://developers.hubspot.com/docs/developer-tooling/platform/versioning.md)
* [Determine your app migration path](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/migrate-an-app/overview.md)
* [Migrate an app to the latest platform version](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/migrate-an-app/migrate-to-the-latest-platform-version.md)
* [Date-based API versioning migration playbook](https://developers.hubspot.com/blog/date-based-api-versioning-migration-playbook)
* [Developer MCP server tools](https://developers.hubspot.com/docs/developer-tooling/local-development/developer-mcp/tools.md)
* [CMS CLI commands](https://developers.hubspot.com/docs/developer-tooling/local-development/hubspot-cli/commands/cms-commands.md)
