---
title: "Cancel an import; what it already created is kept"
method: POST
path: "/migrations/{migrationId}/cancel"
tags: ["Migrations"]
---

# Cancel an import; what it already created is kept

`POST /migrations/{migrationId}/cancel`

## Request

- Base URL: `https://hostingsurge.com/api/v1`
- URL: `https://hostingsurge.com/api/v1/migrations/{migrationId}/cancel`
- Auth: one of:
  - HTTP bearer
  - API key in cookie `__Host-hs_session`

## Path parameters

- `migrationId` string, uuid, required

## Response `200`

Canceled, or cancellation requested while a step finishes

- object
  - `id` string, uuid, required
  - `organizationId` string, uuid, required
  - `kind` 'git' | 'vercel' | 'netlify' | 'heroku' | 'wordpress' | 'database' | 'static-snapshot' | 'cpanel' | 'sftp' | 'php' | 'email', required
  - `state` 'draft' | 'analyzing' | 'review' | 'importing' | 'verifying' | 'preview_ready' | 'cutover' | 'live' | 'failed' | 'canceled', required
  - `name` string, required
  - `source` object, required: non-secret source summary
  - `analysis` object, nullable, required
    - `summary` string, required
    - `framework` 'nextjs' | 'vite' | 'astro' | 'remix' | 'nuxt' | 'sveltekit' | 'gatsby' | 'create-react-app' | 'angular' | 'vue' | 'svelte' | 'hugo' | 'docusaurus' | 'static' | 'node' | 'express' | 'nestjs' | 'fastify' | 'hono' | 'python' | 'php' | 'docker' | 'wordpress' | 'other' | 'null', nullable, required
    - `findings` object[], required: what we found in the source
      - `label` string, required
      - `value` string, required
    - `creates` object[], required: what the import will create on Hostingsurge
      - `type` 'project' | 'wordpress' | 'database' | 'env' | 'domain' | 'files' | 'cron' | 'redirects', required
      - `name` string, required
      - `detail` string, nullable, required
    - `warnings` object[], required
      - `code` string, required
      - `message` string, required
    - `envToProvide` string[], required: variables whose values could not be imported; pass them to start
    - `envMissing` object[]: exactly which values are missing: pass each in StartMigrationInput.env with these targets (and gitBranch) so the values the import has for the other environments stay
      - `key` string, required
      - `targets` string[], required
      - `gitBranch` string, nullable, required
    - `variables` object: the Variables report: every variable of the old host and where it goes, or why not
      - `total` integer, required: variables of the old host (its own system variables not counted; ones the token cannot see counted)
      - `moved` integer, required: review: variables that will move with their value; after the start: variables that moved
      - `missing` integer, required
      - `notVisible` integer, required: production variables the token cannot see at all (Vercel hiddenProductionEnvCount): counted as missing
      - `summary` string, required: e.g. "24 of 24 variables moved"
      - `platform` object, nullable, required: the old host's own variables (VERCEL_ENV, VERCEL_URL, Netlify's URL, …) Hostingsurge fills with its values, in one line
        - `keys` string[], required
        - `message` string, required
      - `items` object[], required
        - `key` string, required
        - `from` object, required
          - `targets` string[], required: its environments on the old host
          - `gitBranch` string, nullable, required: a preview value for one Git branch
          - `environment` string, nullable, required: a Vercel custom environment (slug)
          - `type` string, nullable, required: plain, encrypted, sensitive, secret or system
        - `to` object[], required: where it goes here (empty: nowhere)
          - `targets` string[], required
          - `gitBranch` string, nullable, required
        - `status` 'ready' | 'moved' | 'missing' | 'later' | 'platform', required: ready: moves with its value when the import starts; moved: it did; missing: it has no value (red); later: left for later on purpose (red until set); platform: the old host's own variable, handled by Hostingsurge (not counted)
        - `reason` 'hidden' | 'hidden_development' | 'hidden_no_build' | 'fetch_failed' | 'branch_unverified' | 'too_large' | 'invalid_name' | 'reserved_name' | 'no_environment' | 'branch_deploys' | 'left_for_later' | 'expected' | 'old_server' | 'computed' | 'not_in_backup' | 'shadowed' | 'unsupported_value' | 'overridden' | 'unreadable_line' | 'system_mapped' | 'system_unsupported' | 'null', nullable, required
        - `note` 'custom_branch' | 'custom_preview' | 'custom_named_branch' | 'null', nullable, required: how a Vercel custom environment's value was placed: the branch the environment tracks, the preview value, or a branch named after it
        - `message` string, nullable, required: English: exactly why it did not move
        - `action` string, nullable, required: English: the one thing to do (missing and later rows)
        - `place` 'env' | 'wordpress' | 'php': where it goes: env (default) the project's environment variables; wordpress the WordPress site's configuration (wp-config.php: its security keys and salts, and its other settings); php the app's PHP settings (php.ini)
        - `file` string, nullable: the file of the old host it was read from (.env, wp-config.php, .user.ini …), when it came from one
        - `source` 'shared': vercel: a team-level Shared Environment Variable linked to the project; absent: the project's own variable
        - `detail` string, nullable: English: how it changed on the way, or why it is not needed here (a path pointed at the new site, a database setting pointed at the new database, a setting of the old server)
      - `files` object[]: files that held variables or settings (.env, wp-config.php, php.ini …), redacted
        - `path` string, required: its path on the old host, relative to the site (e.g. .env, ../.env, wp-config.php, .user.ini)
        - `kind` 'env' | 'wp-config' | 'php-ini' | 'htaccess' | 'example', required: example: .env.example, env.d.ts and the like (names only, no values)
        - `count` integer, required: variables or settings it holds
        - `note` string, nullable, required: English: what became of it, e.g. "values moved into the project's variables; the file itself is not copied"
    - `envExpected` object[]: git: variables the app's code expects that have no value yet. Optional: give them in StartMigrationInput.env (or paste the .env file in the dashboard); the import starts without them
      - `key` string, required
      - `files` string[], required: where the code names it: .env.example, env.d.ts, next.config.js …
    - `deployKey` object, nullable, required
      - `publicKey` string, required: OpenSSH public key to add as a read-only deploy key
      - `fingerprint` string, required: SHA256:… fingerprint
      - `addUrl` string, nullable, required: where the host lets you add deploy keys, when known
    - `ownership` object, nullable, required
      - `verified` boolean, required
      - `method` 'dns' | 'well-known' | 'null', nullable, required
      - `dnsRecord` object, required
        - `type` 'TXT', required
        - `name` string, required
        - `value` string, required
      - `wellKnownUrl` string, required
      - `wellKnownContent` string, required
    - `ready` boolean, required: start is possible (e.g. ownership proven, engine known)
    - `crons` object[]: cron jobs found on the old host and what they become (see StartMigrationInput.crons)
      - `key` string, required: pass it in StartMigrationInput.crons
      - `origin` 'cpanel' | 'vercel' | 'wordpress' | 'netlify', required
      - `schedule` string, required: as found on the old host
      - `timezone` string, nullable, required: time zone the old host ran it in; null = the old server's own time zone, which is unknown
      - `command` string, required: as found (Vercel: the path it calls); passwords and tokens are masked
      - `convertible` boolean, required
      - `reason` string, nullable, required: why it cannot be converted
      - `converted` object, nullable, required
        - `schedule` string, required: in UTC, the time zone Hostingsurge runs cron jobs in
        - `summary` string, required: what runs, e.g. "php wp-cron.php (WordPress cron)" or "GET /api/cron on the imported site"
      - `notes` string[], required
    - `sites` object[]: cpanel/sftp connections: the sites found, with their databases
      - `domain` string, required: pass it in StartMigrationInput.sites
      - `kind` 'wordpress' | 'php' | 'static' | 'empty' | 'unknown', required
      - `detail` string, required
      - `database` object, nullable, required
        - `source` 'wp-config' | 'env' | 'php-config', required: where its settings were found (php-config: config.php or another PHP configuration file)
        - `name` string, nullable, required
        - `user` string, nullable, required
        - `host` string, nullable, required: as configured on the old host, e.g. localhost
        - `port` integer, nullable, required
        - `reachability` 'public' | 'local' | 'private' | 'unknown', required: public: reachable from the internet as configured; local: only on the old server itself (localhost or a socket); private: a private network address; unknown: could not be resolved
        - `pullHost` string, nullable, required: where a pull connects by default: the configured host when it is public, otherwise the connection's own host, where remote MySQL access must be allowed first
        - `passwordFound` boolean, required: the password could be read from the file; otherwise pass it when starting
        - `dumpUploaded` boolean, required: a dump was uploaded to the connection; it is used unless you choose a pull
      - `crons` integer, required: cron jobs of the old host that belong to this site; reviewed on the site's own import
    - `mail` object: cpanel: the account's mailboxes, forwarders and default addresses, to move with POST /migrations/{id}/mail
      - `source` 'backup' | 'cpanel', required
      - `messages` 'backup' | 'imap', required: backup: the messages are copied from the backup's mail folders; imap: from the old server over IMAP, with each mailbox's current password (Email → mailbox → Import from another server)
      - `passwords` 'carried' | 'new', required: carried: the old passwords move with the mailboxes where their format allows (a backup); new: each mailbox gets a new password (shown once)
      - `backupTakenAt` string, nullable: a backup: when it was made. Mail that reached the old server later is not in it (it is copied from the old server after the move: POST /migrations/{id}/mail/sync)
      - `totals` object, required
        - `domains` integer, required
        - `mailboxes` integer, required
        - `forwarders` integer, required
        - `bytes` integer, required
      - `domains` object[], required
        - `domain` string, required
        - `mailboxes` object[], required
          - `address` string, required
          - `quotaMb` integer, nullable, required: null: unlimited on the old host
          - `usedMb` integer, nullable, required
          - `messages` boolean, required: the backup holds its messages
        - `forwarders` object[], required
          - `address` string, required
          - `destinations` string[], required
          - `unsupported` string[], required: destinations that cannot move: pipe, file, autoresponder, :fail:, :blackhole:
        - `defaultAddress` string, nullable, required: where mail to unknown addresses went: an address (catch-all), "fail" or "blackhole"
        - `filterRules` integer, required: filter rules on the old host (not moved)
      - `truncated` boolean, required
    - `accounts` object[]: cpanel with mode "whm": the cPanel accounts of the server (StartMigrationInput.accounts)
      - `user` string, required: cPanel user name; pass it in StartMigrationInput.accounts
      - `domain` string, required: main domain
      - `domains` string[], required: every domain of the account WHM listed (main first)
      - `suspended` boolean, required
      - `diskUsedMb` number, nullable, required
    - `databases` object[]: vercel/heroku: external databases (Heroku: its add-ons' databases), each to move or keep (StartMigrationInput.databases)
      - `key` string, required: pass it in StartMigrationInput.databases
      - `service` 'vercel-postgres' | 'neon' | 'supabase' | 'planetscale' | 'mysql' | 'postgres' | 'mongodb' | 'upstash' | 'redis' | 'prisma-postgres' | 'cockroachdb' | 'netlify-db' | 'unknown', required
      - `engine` 'postgresql' | 'mysql' | 'mariadb' | 'redis' | 'mongodb' | 'null', nullable, required: null: hidden value and nothing in the variable names tells; the pasted connection string decides
      - `label` string, required
      - `host` string, nullable, required: server, without credentials; null when Vercel did not reveal the value
      - `envKeys` object[], required: every variable that points at it; all but the REST ones are rewritten when it is moved
        - `key` string, required
        - `role` 'url' | 'host' | 'port' | 'user' | 'password' | 'database' | 'rest', required
      - `valueKnown` boolean, required: the connection string could be read; otherwise pass it (url) to move the database
      - `movable` boolean, required: false: it cannot run on a standard database server and stays where it is
      - `handledAs` 'kv_rest': kv_rest: a Vercel KV / Upstash store the review shows as a service (analysis.services); its services choice decides its move
      - `recommendation` 'move' | 'keep', required
      - `reason` string, required: why, in plain words
      - `notes` string[], required
    - `blobStores` object[]: vercel: Vercel Blob stores the project uses (moved or kept by their services row, StartMigrationInput.services; copyBlob for analyses without services)
      - `envKey` string, required
      - `valueKnown` boolean, required: false: Vercel hides the token; enter it (envToProvide) so the app keeps working
      - `storeId` string, nullable, required
      - `objects` integer, nullable, required: files in the store (null: not counted)
      - `bytes` integer, nullable, required
      - `truncated` boolean, required: the count stopped early: the store has more
    - `routing` object: vercel: vercel.json redirects, headers and rewrites, and what becomes of them
      - `source` 'repository' | 'deployment' | 'unavailable' | 'none', required: where vercel.json was read from; unavailable: it could not be read (e.g. a private repository); none: the project has none
      - `detail` string, nullable, required
      - `applied` object[], required
        - `kind` 'redirect' | 'header' | 'rewrite', required
        - `from` string, required
        - `to` string, required
        - `note` string, nullable, required
      - `unmapped` object[], required
        - `kind` 'redirect' | 'rewrite' | 'header' | 'setting', required
        - `rule` string, required
        - `reason` string, required
    - `databaseAccess` object: cpanel (live): the result of the database access test (Remote MySQL opened, the MySQL port tried, closed again); the findings and warnings say the same in words
      - `state` 'ok' | 'blocked' | 'unavailable' | 'untested', required: ok: the MySQL port of the old server answered Hostingsurge; blocked: it did not (firewall); unavailable: Remote MySQL could not be opened for the copy; untested
      - `address` string, required: the Hostingsurge address that must be allowed to connect
      - `host` string, required: the old server
      - `port` integer, required
      - `reason` 'off' | 'permission' | 'signin' | 'other': unavailable: why Remote MySQL could not be opened
      - `cleanupFailed` boolean: the test could not remove the address from Remote MySQL again: it must be removed by hand
    - `stays` object[]: vercel/heroku: only what technically cannot move here, whatever you choose, each with the reason (e.g. Supabase Auth); Vercel's own services are rows of services instead
      - `code` string, required
      - `what` string, required
      - `why` string, required
    - `protection` object: vercel: the deployment protection set up again on Hostingsurge (StartMigrationInput.protection)
      - `action` 'move' | 'none', required
      - `password` object, required
        - `wanted` boolean, required
        - `scope` 'all' | 'preview' | 'production', required
        - `needsPassword` boolean, required: ask for StartMigrationInput.protectionPassword: Vercel never shows the old one
      - `hostScope` 'all' | 'all_except_custom_domains' | 'default_and_previews', required
      - `previews` boolean, required
      - `ipAllowlist` object, required
        - `count` integer, required
        - `mode` 'additional' | 'exclusive' | 'null', nullable, required
      - `bypassCount` integer, required: automation bypass secrets (x-vercel-protection-bypass) that keep working
      - `optionsPaths` string[], required
      - `notes` string[], required
      - `unmapped` object[], required
        - `what` string, required
        - `why` string, required
    - `aiGateway` object: vercel: the app uses the Vercel AI Gateway without its own key, which only works on Vercel
      - `action` 'create_key' | 'guide', required
      - `reason` string, nullable, required
      - `consent` string, required: what to tell the customer before creating a key with their token
    - `services` object[]: vercel: every Vercel service the project uses (KV, Blob, Edge Config, protection, …), each to move or keep (StartMigrationInput.services)
      - `code` 'kv_rest' | 'blob' | 'global_config' | 'protection' | 'analytics_endpoints' | 'system_env' | 'ai_gateway' | 'flags' | 'queues' | 'oidc_federation' | 'firewall' | 'rewrites' | 'libsql' | 'xata' | 'qstash' | 'upstash_vector' | 'upstash_search' | 'netlify_rules' | 'netlify_functions' | 'netlify_scheduled' | 'netlify_edge_functions' | 'netlify_forms' | 'netlify_blobs' | 'netlify_identity' | 'netlify_protection' | 'netlify_snippets' | 'netlify_previews' | 'netlify_split_testing' | 'netlify_large_media' | 'netlify_build_hooks' | 'netlify_build_plugins' | 'netlify_db', required
      - `key` string, required: which one: a KV store's analysis.databases key, a Blob store's token variable, a config's id, or the code itself. Pass it with the code in StartMigrationInput.services
      - `label` string, required
      - `found` object, required
        - `keys` integer, nullable: kv_rest: keys in the store
        - `files` integer, nullable: blob: files in the store
        - `bytes` integer, nullable: kv_rest: memory used; blob: total file size; global_config: size of the items
        - `items` integer, nullable: global_config: items; firewall/rewrites/protection: rules, ranges or secrets
        - `truncated` boolean: the count stopped early: there are more
        - `forms` integer, nullable: netlify_forms: forms
        - `submissions` integer, nullable: netlify_forms: submissions stored at Netlify
        - `functions` integer, nullable: netlify_functions / netlify_scheduled / netlify_edge_functions: functions
        - `stores` integer, nullable: netlify_blobs: stores
        - `rules` integer, nullable: netlify_rules: redirect and header rules
        - `runtime` boolean: netlify_*: Hostingsurge's runtime serves the site (imported from its deploy files); false: the repository builds as it is (rules become edge rules, the runtime's services cannot move)
        - `scope` 'all' | 'preview' | 'null', nullable: netlify_protection: who Netlify asks for the site password: every visitor (all, "All deploys") or deploy previews only (preview, "Non-production deploys only": the production site is public); null: Hostingsurge could not tell, so moving it needs input.scope
        - `variables` string[]: the variables it uses (names only, never values)
      - `recommendation` 'move' | 'keep', required: move whenever it is movable and nothing blocks it
      - `movable` boolean, required: false: it can only stay where it is (blockers say why)
      - `keepable` boolean: false: Move only, it always moves and Keep is not possible (its notes say why: a Netlify site served by Hostingsurge's runtime keeps the rules and functions of its files whatever is chosen); omitted: Keep is possible
      - `blockers` object[], required: why it cannot move, or why moving is not recommended
        - `code` string, required
        - `message` string, required
        - `params` object
      - `notes` object[], required
        - `code` string, required
        - `message` string, required
        - `params` object
      - `whatHappens` object, required: one sentence each, English
        - `move` string, required
        - `keep` string, required
      - `estimateSeconds` integer, nullable, required: about how long the move takes
      - `needsInput` 'password' | 'blob_token' | 'null', nullable, required: password: the protection password (Vercel never shows the old one; optional, a random one otherwise. netlify_protection: required, Netlify does not show the site password); blob_token: the Blob store's read-write token, which Vercel hides
      - `stillAtVercelWhenKept` boolean, required: keeping it keeps the app depending on the Vercel account (a Netlify import: on the Netlify site)
      - `projectScoped` boolean, required: it belongs to the Vercel project or the Netlify site (deleting it removes it); false: team-level (Blob, KV and Edge Config stores outlive the project)
    - `domains` object[]: vercel, netlify, cpanel/sftp sites: the domains the old host served, with their redirects (a site's aliases and www twins included; cutover all: true connects them together)
      - `hostname` string, required
      - `redirectTo` string, nullable, required
      - `redirectStatus` integer, nullable, required
      - `role` 'primary' | 'alias' | 'www': cpanel/sftp sites: the primary domain, an alias (parked or addon domain on the same site), or the www twin of one of them
    - `oldDns` object: every DNS record of the old zone that could be read, before the switch (keep, restore or recreate them)
      - `takenAt` string, required: ISO-8601 timestamp
      - `source` 'backup' | 'cpanel' | 'public', required: backup/cpanel: the old host's own zone was read (merged with public DNS); public: public DNS only
      - `serverAddresses` string[]: the old server's addresses, as the old host gave them (mail on them is "on the old server")
      - `domains` object[], required
        - `domain` string, required
        - `records` object[], required
          - `name` string, required
          - `type` string, required
          - `ttl` integer, required
          - `value` string, required
          - `priority` integer, nullable, required: MX preference or SRV priority
          - `source` 'zone' | 'public', required: zone: the old host's own zone (backup or its API); public: what public DNS answered
        - `truncated` boolean, required: more records than kept here (the full zone is in the backup or at the old host)
        - `partial` boolean, required: some lookups failed
        - `zoneServed` boolean, nullable: whether public DNS serves the old host's zone: false when the domain's name servers are elsewhere (its zone-only records are kept here to restore or recreate, but the mail check judges what public DNS answers); null: unknown
    - `mailCheck` object: what the domains' mail runs on and what the switch would do to it, as checked when the import was analyzed (POST /migrations/{id}/mail-check checks again)
      - `checkedAt` string, required: ISO-8601 timestamp
      - `blocking` boolean, required: a risk needs the customer's confirmation before the switch (CutoverMigrationInput.confirmMail)
      - `confirmed` boolean: the customer confirmed the risks when the domains were connected
      - `domains` object[], required
        - `domain` string, required
        - `status` 'ok' | 'at_risk' | 'old_host' | 'no_mail' | 'unknown', required: ok: mail runs elsewhere and is not touched · at_risk: the switch would break it unless the fixes are made · old_host: it keeps working, but runs on the old server and stops when the old hosting is cancelled · no_mail: no MX record and no SPF · unknown: the lookups failed
        - `provider` string, nullable, required: a known mail provider by its MX hosts (Google Workspace, Microsoft 365, Hostingsurge …)
        - `mx` object[], required
          - `priority` integer, required
          - `exchange` string, required
          - `addresses` string[], required
          - `via` string[], required
          - `moves` boolean, required
          - `oldServer` boolean, required
        - `spf` string, nullable, required
        - `dmarc` string, nullable, required
        - `dkim` object[], required
          - `selector` string, required
          - `source` 'zone' | 'public' | 'old_zone', required: zone: the old host's zone (served by public DNS) · public: public DNS · old_zone: only in the old host's zone, which public DNS no longer serves (for information: not in use now)
        - `hosts` object[], required: mail app hosts and SRV records (mail, webmail, autodiscover, autoconfig, _imaps._tcp …)
          - `name` string, required
          - `type` string, required
          - `value` string, required
          - `moves` boolean, required
        - `nameservers` string[], required
        - `nameserversAtOldHost` boolean, required
        - `risks` object[], required
          - `code` 'mx_moves' | 'spf_follows_web' | 'mail_host_moves' | 'mail_on_old_host' | 'no_mx' | 'dns_at_old_host' | 'lookup_failed', required
          - `blocking` boolean, required: the switch waits for the customer's confirmation (CutoverMigrationInput.confirmMail)
          - `message` string, required: English
          - `names` string[], required: the record names concerned, relative to the domain
          - `addresses` string[], required: the old mail server's addresses, where the fix needs them
        - `records` object[], required: exactly what to keep, and the fixes to make before the switch (add/change)
          - `type` 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'CAA' | 'NS' | 'SRV', required
          - `name` string, required
          - `value` string, required
          - `action` 'add' | 'change' | 'remove' | 'keep', required
          - `current` string, nullable, required: value observed in public DNS now
          - `reason` string, required
    - `envBridge` object: vercel: fetching the values Vercel hides through a temporary private build in the project (envBridge)
      - `state` 'done' | 'failed' | 'off', required: done: some or all hidden values were fetched; failed: none could be (see message); off: not asked for
      - `filled` string[], required: hidden variables whose values were fetched automatically (they are imported like the others)
      - `missing` string[], required: hidden variables still without a value (also in envToProvide)
      - `message` string, nullable, required: one line for the customer: what happened and the one thing to do
    - `build` object: vercel: the build Vercel would run, applied to the new project (install without a frozen lockfile, as Vercel does)
      - `packageManager` 'npm' | 'pnpm' | 'yarn' | 'bun' | 'null', nullable, required
      - `lockfile` string, nullable, required
      - `installCommand` string, nullable, required
      - `buildCommand` string, nullable, required
      - `startCommand` string, nullable, required
      - `rootDirectory` string, required: the directory the build runs in on Hostingsurge (the workspace root of a monorepo)
      - `appDirectory` string, required: the project's Root Directory on Vercel
      - `nodeVersion` string, nullable, required
      - `notes` string[], required
    - `sourceLinks` object: vercel/netlify: dashboard pages where the values of hidden variables are shown
      - `env` string, nullable, required: the project's Environment Variables page on the old host
      - `storage` string, nullable, required: vercel: the team's Storage tab (Postgres, KV, Blob and Edge Config secrets)
      - `project` string, nullable, required
    - `unsupportedFunctions` object: functions of the app that cannot run on Hostingsurge; the import starts only with StartMigrationInput.acceptUnsupportedFunctions
      - `count` integer, required: how many (0 when only their presence is known, e.g. Netlify Functions)
      - `paths` string[], required: up to 20 of them
      - `reason` 'runtime' | 'not_served' | 'netlify' | 'netlify_rules', required: runtime: Python, Go or Ruby; not_served: api/ files the framework build does not serve; netlify: Netlify Functions; netlify_rules: a Netlify site whose redirects, headers and Basic-Auth could not be read (paths names the rules files): imported without them only on an explicit acceptUnsupportedFunctions
      - `rules` 'all' | 'deploy' | 'repository' | 'toml' | 'files': a Netlify site whose rules are unknown (also with reason netlify, then said at the end of message): all, neither the repository nor the published deploy could be read; deploy, the published deploy's _redirects and _headers; repository, the repository (netlify.toml); toml, netlify.toml is outside the TOML Hostingsurge reads (any site); files, the deploy files' _redirects and _headers, with a repository chosen at start instead of them. Private paths could be open: the start waits for acceptUnsupportedFunctions
      - `message` string, required: what does not run and what to do instead (English)
    - `persistence` object: the app keeps data on its own disk (SQLite, uploads): a volume is created with the project unless StartMigrationInput.volume is false
      - `signals` object[], required: up to 10
        - `kind` 'sqlite_dependency' | 'sqlite_file' | 'sqlite_url' | 'upload_dir' | 'data_dir', required
        - `evidence` string, required: where it was found, e.g. "package.json: better-sqlite3" or "data/app.db"
      - `mountPath` string, required: the suggested volume directory, e.g. /app/data
      - `sizeGb` integer, required
      - `recommended` boolean, required: true (Git imports): created unless StartMigrationInput.volume is false. false (Vercel, Heroku: their disk was already temporary, so the app does not rely on it): created only when StartMigrationInput.volume asks for it
      - `note` string, nullable, required: what else to do, e.g. point the SQLite file into the volume
    - `releasePhase` object: heroku: the Procfile's release command, which Heroku ran before each new release served; not carried over (warning heroku_release_phase)
      - `command` string, nullable, required: e.g. "python manage.py migrate" or "npx prisma migrate deploy". null: longer than 2000 characters, too long to show (never shown cut short): it is in `file`
      - `from` 'procfile' | 'heroku', required: procfile: the Procfile of the repository that deploys here (read from its branch); heroku: the app's current release on Heroku (the repository could not be read, or is chosen at start; a container app)
      - `file` 'Procfile' | 'heroku.yml', required: the file that sets it: the Procfile, or a container app's heroku.yml
      - `fitsRunCommand` boolean, required: Run Command (MCP run_command) takes it as it is: one line of at most 255 characters (contracts.ts RunCommandInput; fitsRunCommand). Otherwise it goes in a script in the repository, and that script is run
      - `message` string, required: that it is not carried over, what to run by hand, and that the first preview may fail until it has run (English)
    - `files` object: cpanel (live), sftp, php, wordpress and static-snapshot imports: every file of the old site, what did not arrive and why
      - `source` 'sftp' | 'cpanel' | 'backup' | 'snapshot', required
      - `found` integer, required: files of the site found on the old host (what is skipped on purpose not counted); snapshot: pages and files fetched or tried
      - `foundBytes` integer, required
      - `copied` integer, required
      - `copiedBytes` integer, required
      - `notCopied` object[], required: a sample: the first ones of each reason (at most 25 per reason and 100 in all, fewer for very long paths; paths cut at 300 characters). notCopiedCount and notCopiedByReason count them all, the import log names every one, and fullList says whether the whole list can be downloaded (GET /migrations/{id}/files-report)
        - `path` string, required: relative to the site (a folder ends with /; ../ for the code a live PHP site loads from above its folder); snapshot: the address
        - `reason` 'too_large' | 'unreadable' | 'folder_unreadable' | 'changed' | 'api_limit' | 'link' | 'special' | 'not_fetched', required: too_large: over 95 MB, more than a site's repository takes per file; unreadable / folder_unreadable: the old server refused to read it or list it; changed: it changed or vanished while it was copied; api_limit: over 1 MB, which cPanel's API cannot return, and FTPS could not be used; link: a symbolic link (not followed); special: not a regular file; not_fetched: a snapshot address that could not be fetched
        - `size` integer, nullable, required
        - `detail` string: more about why (English), e.g. the HTTP status of a snapshot address
      - `notCopiedCount` integer, required
      - `notCopiedByReason` object: how many did not arrive for each reason (all of them, not only the sample)
        - `too_large` integer
        - `unreadable` integer
        - `folder_unreadable` integer
        - `changed` integer
        - `api_limit` integer
        - `link` integer
        - `special` integer
        - `not_fetched` integer
      - `skipped` object[], required: never part of a site, left out on purpose (a sample: at most 25 per reason and 50 in all; skippedCount has them all; the import log names every one)
        - `path` string, required
        - `reason` 'vcs' | 'cache' | 'private' | 'server_backup' | 'server_log' | 'junk', required
      - `skippedCount` integer, required
      - `fullList` object, nullable: a live cPanel download: the whole list of the files that did not arrive and those left out on purpose, with why, is kept as a CSV file with the import's other files (as long as they are kept): GET /migrations/{id}/files-report
        - `rows` integer, required: lines in it (files not copied and left out)
      - `over` object, nullable, required: the site has more files or bytes than where it goes can hold: the import cannot start (remove caches or old files on the old server and analyze again)
        - `files` integer, required
        - `bytes` integer, required
        - `maxFiles` integer, required
        - `maxBytes` integer, required
        - `largest` object[], required
          - `path` string, required
          - `files` integer, required
          - `bytes` integer, required
      - `acceptRequired` boolean, required: files of the site did not arrive: start with acceptMissingFiles: true to import without them
      - `accepted` boolean, required: the customer chose to start without them already (on the connection that copied the files)
      - `howTo` string, nullable, required: how to bring what did not arrive (English)
    - `unclaimedDumps` object[]: php: database dumps of the backup (or uploaded) that no file of the site names (warning database_dump_unclaimed); not imported unless chosen in StartMigrationInput.siteDumps
      - `name` string, required: the database the dump was made from; pass it in StartMigrationInput.siteDumps
      - `bytes` integer, required
      - `uploaded` boolean, required: uploaded on its own (true) or part of the uploaded backup (false)
  - `steps` object[], required
    - `name` 'fetch_source' | 'analyze' | 'create_project' | 'create_database' | 'import_env' | 'import_database' | 'restore_files' | 'rewrite_urls' | 'deploy' | 'verify' | 'attach_domain' | 'sync_mail', required
    - `phase` 'analyze' | 'import' | 'cutover', required
    - `state` 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'canceled', required
    - `position` integer, required
    - `attempts` integer, required
    - `detail` string, nullable, required: short outcome, e.g. "Deployment ready in 42 s"
    - `error` string, nullable, required
    - `startedAt` string, nullable, required: ISO-8601 timestamp
    - `finishedAt` string, nullable, required: ISO-8601 timestamp
  - `artifacts` object[], required
    - `id` string, uuid, required
    - `name` string, required
    - `role` 'backup' | 'database' | 'wp-content' | 'files' | 'other', required
    - `status` 'uploading' | 'complete' | 'failed' | 'deleted', required
    - `size` integer, required
    - `receivedBytes` integer, required
    - `chunkSize` integer, required
    - `totalChunks` integer, required
    - `missingChunks` integer[], required: chunk indexes still to upload (max 1000 listed)
    - `sha256` string, nullable, required: verified checksum once complete
    - `createdAt` string, required: ISO-8601 timestamp
    - `expiresAt` string, required: ISO-8601 timestamp
  - `projectId` string, uuid, nullable, required
  - `databaseId` string, uuid, nullable, required
  - `previewUrl` string, nullable, required
  - `cutover` object, nullable, required
    - `hostname` string, required
    - `dnsProvider` string, nullable, required: detected from the NS records
    - `nameservers` string[], required
    - `records` object[], required: exactly which records change and which to keep
      - `type` 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'CAA' | 'NS' | 'SRV', required
      - `name` string, required
      - `value` string, required
      - `action` 'add' | 'change' | 'remove' | 'keep', required
      - `current` string, nullable, required: value observed in public DNS now
      - `reason` string, required
    - `domainStatus` 'valid' | 'pending' | 'invalid', required
    - `ssl` 'active' | 'pending' | 'error' | 'none', required
    - `warnings` string[], required: things to check before changing DNS, e.g. mail on the same name, a proxied record, low TTLs
    - `checkedAt` string, nullable, required: ISO-8601 timestamp
    - `phase` 'waiting_dns' | 'dns_points_here' | 'issuing_https' | 'live': the same step as the domains page: waiting_dns, dns_points_here, issuing_https, live
    - `issues` object[]: what is in the way, in the same codes as Domain.issues (old host, AAAA, Cloudflare proxy, CAA, propagating)
      - `code` 'no_record' | 'points_elsewhere' | 'extra_records' | 'conflicting_aaaa' | 'cloudflare_proxy' | 'caa_blocks' | 'propagating' | 'lookup_failed' | 'nameservers_not_asked' | 'txt_needed' | 'not_deployed' | 'https_error', required: no_record: nothing answers for the name yet; points_elsewhere: the records point at another server (values: where, usually the old host); extra_records: other A records next to ours (values); conflicting_aaaa: AAAA records send IPv6 visitors and Let’s Encrypt elsewhere (values); cloudflare_proxy: the Cloudflare proxy (orange cloud) is on; caa_blocks: a CAA record forbids Let’s Encrypt (values: the records, name: where); propagating: the DNS provider already answers with our record, public resolvers may keep the old one for ttlSeconds; lookup_failed: DNS did not answer in time; nameservers_not_asked: the domain’s own nameservers resolve only to addresses the platform never queries (private, loopback, link-local…), so only the public resolvers’ answer is known (values: the nameservers); txt_needed: the TXT record is needed too (values: [txtReason]); not_deployed: goes live with the first deployment; https_error: HTTPS does not work yet
      - `values` string[], required
      - `name` string
      - `ttlSeconds` integer, nullable
    - `ownershipConfirmation` boolean: true: the DNS records and the ownership TXT record are correct and only a member's confirmation is missing because the domain moves from another team — POST /migrations/{id}/cutover again (domains:write; "Check now" / "Confirm ownership" in the dashboard) confirms that you own the domain; the certificate, the WordPress switch and `live` then follow on their own. Otherwise the background re-check confirms ownership by itself.
    - `wordpressSwitch` 'retrying' | 'stopped' | 'null', nullable: WordPress imports whose domain is live with HTTPS but whose home/siteurl could not be switched to it yet: retrying = retried automatically with a backoff; stopped = automatic retries stopped after repeated failures (see the logs): fix the cause, then POST /migrations/{id}/cutover again
    - `mail` object: the mail check of the connected domains: the risks as found when they were connected; each fix row's current value is read again from public DNS on every "Check now" and background check (records to keep and fixes are also in records)
      - `checkedAt` string, required: ISO-8601 timestamp
      - `blocking` boolean, required: a risk needs the customer's confirmation before the switch (CutoverMigrationInput.confirmMail)
      - `confirmed` boolean: the customer confirmed the risks when the domains were connected
      - `domains` object[], required
        - `domain` string, required
        - `status` 'ok' | 'at_risk' | 'old_host' | 'no_mail' | 'unknown', required: ok: mail runs elsewhere and is not touched · at_risk: the switch would break it unless the fixes are made · old_host: it keeps working, but runs on the old server and stops when the old hosting is cancelled · no_mail: no MX record and no SPF · unknown: the lookups failed
        - `provider` string, nullable, required: a known mail provider by its MX hosts (Google Workspace, Microsoft 365, Hostingsurge …)
        - `mx` object[], required
          - `priority` integer, required
          - `exchange` string, required
          - `addresses` string[], required
          - `via` string[], required
          - `moves` boolean, required
          - `oldServer` boolean, required
        - `spf` string, nullable, required
        - `dmarc` string, nullable, required
        - `dkim` object[], required
          - `selector` string, required
          - `source` 'zone' | 'public' | 'old_zone', required: zone: the old host's zone (served by public DNS) · public: public DNS · old_zone: only in the old host's zone, which public DNS no longer serves (for information: not in use now)
        - `hosts` object[], required: mail app hosts and SRV records (mail, webmail, autodiscover, autoconfig, _imaps._tcp …)
          - `name` string, required
          - `type` string, required
          - `value` string, required
          - `moves` boolean, required
        - `nameservers` string[], required
        - `nameserversAtOldHost` boolean, required
        - `risks` object[], required
          - `code` 'mx_moves' | 'spf_follows_web' | 'mail_host_moves' | 'mail_on_old_host' | 'no_mx' | 'dns_at_old_host' | 'lookup_failed', required
          - `blocking` boolean, required: the switch waits for the customer's confirmation (CutoverMigrationInput.confirmMail)
          - `message` string, required: English
          - `names` string[], required: the record names concerned, relative to the domain
          - `addresses` string[], required: the old mail server's addresses, where the fix needs them
        - `records` object[], required: exactly what to keep, and the fixes to make before the switch (add/change)
          - `type` 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'CAA' | 'NS' | 'SRV', required
          - `name` string, required
          - `value` string, required
          - `action` 'add' | 'change' | 'remove' | 'keep', required
          - `current` string, nullable, required: value observed in public DNS now
          - `reason` string, required
    - `domains` object[]: several domains cut over in one action: each one's status and records (the fields above are the primary one's). The import is live when every one of them is
      - `hostname` string, required
      - `redirectTo` string, nullable, required
      - `domainStatus` 'valid' | 'pending' | 'invalid', required
      - `ssl` 'active' | 'pending' | 'error' | 'none', required
      - `records` object[], required
        - `type` 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'CAA' | 'NS' | 'SRV', required
        - `name` string, required
        - `value` string, required
        - `action` 'add' | 'change' | 'remove' | 'keep', required
        - `current` string, nullable, required: value observed in public DNS now
        - `reason` string, required
      - `warnings` string[], required
      - `ownershipConfirmation` boolean, required
      - `dnsProvider` string, nullable, required
      - `phase` 'waiting_dns' | 'dns_points_here' | 'issuing_https' | 'live': waiting_dns: the records do not point here yet; dns_points_here: they do (ownership or the first deployment may still be missing); issuing_https: attached, the certificate is being issued; live: served over HTTPS
      - `issues` object[]
        - `code` 'no_record' | 'points_elsewhere' | 'extra_records' | 'conflicting_aaaa' | 'cloudflare_proxy' | 'caa_blocks' | 'propagating' | 'lookup_failed' | 'nameservers_not_asked' | 'txt_needed' | 'not_deployed' | 'https_error', required: no_record: nothing answers for the name yet; points_elsewhere: the records point at another server (values: where, usually the old host); extra_records: other A records next to ours (values); conflicting_aaaa: AAAA records send IPv6 visitors and Let’s Encrypt elsewhere (values); cloudflare_proxy: the Cloudflare proxy (orange cloud) is on; caa_blocks: a CAA record forbids Let’s Encrypt (values: the records, name: where); propagating: the DNS provider already answers with our record, public resolvers may keep the old one for ttlSeconds; lookup_failed: DNS did not answer in time; nameservers_not_asked: the domain’s own nameservers resolve only to addresses the platform never queries (private, loopback, link-local…), so only the public resolvers’ answer is known (values: the nameservers); txt_needed: the TXT record is needed too (values: [txtReason]); not_deployed: goes live with the first deployment; https_error: HTTPS does not work yet
        - `values` string[], required
        - `name` string
        - `ttlSeconds` integer, nullable
  - `previewCheck` object, nullable: the automatic check of the preview: broken links and files, mixed content, old site vs preview, WordPress login and old addresses
    - `status` 'passed' | 'warnings' | 'problems' | 'failed', required: passed: nothing found · warnings: worth a look (broken images or files, mixed content, pages that differ) · problems: fix before connecting the domain (broken pages, server errors, WordPress login or old addresses) · failed: the preview could not be checked
    - `summary` string, required: one plain sentence (English)
    - `checkedAt` string, required: ISO-8601 timestamp
    - `durationMs` integer, required
    - `previewUrl` string, required
    - `pages` object, required
      - `checked` integer, required
      - `truncated` boolean, required: more pages or files than the check reads (50 pages, 150 files, 5000 links per page) or its time ran out
    - `assets` object, required
      - `checked` integer, required
    - `broken` object[], required: pages and files of the preview that answer 4xx/5xx, do not answer, or redirect to another site (at most 25)
      - `url` string, required
      - `foundOn` string, nullable, required
      - `status` integer, nullable, required: HTTP status; null when the address did not answer
      - `kind` 'page' | 'asset', required
      - `location` string, nullable, required: a page that sends visitors to another site: where to
    - `brokenCount` integer, required
    - `mixedContent` object, required: images, scripts, styles, frames and forms loaded over http:// on the https preview (browsers block or warn about them)
      - `count` integer, required
      - `samples` object[], required: at most 10
        - `url` string, required
        - `foundOn` string, nullable, required
    - `comparison` object, nullable, required
      - `oldUrl` string, required
      - `source` 'import' | 'customer', required: import: the address the import came from; customer: entered on the check
      - `pages` object[], required: the home page and up to 4 other pages
        - `path` string, required
        - `old` object, required
          - `status` integer, nullable, required
          - `title` string, nullable, required
          - `ms` integer, nullable, required
          - `error` string, nullable, required: why the old page could not be read (timeout, blocked, …)
        - `new` object, required
          - `status` integer, nullable, required
          - `title` string, nullable, required
          - `ms` integer, nullable, required
        - `similarity` integer, nullable, required: how much of the visible text is the same (100 = identical); null when one side had no text
        - `missing` string[], required: text lines of the old page that the preview does not have (at most 5)
        - `added` string[], required: text lines of the preview that the old page does not have (at most 5)
    - `comparisonNote` 'no_old_url' | 'old_unreachable' | 'null', nullable, required: why there is no comparison, or why the old site could not be read
    - `wordpress` object, nullable, required
      - `login` 'ok' | 'no_form' | 'redirects' | 'error', required: ok: /wp-login.php shows the login form on the preview
      - `loginStatus` integer, nullable, required
      - `loginLocation` string, nullable, required: redirects: where the login page sends visitors
      - `oldAddresses` object, required: links to the old site's address left in the preview's HTML (the address rewrite missed them)
        - `hosts` string[], required
        - `count` integer, required
        - `pages` integer, required
        - `samples` object[], required
          - `url` string, required
          - `foundOn` string, nullable, required
    - `error` string, nullable, required
  - `importedCrons` object, nullable: cron jobs of the old host created on the project when the import finished (paused ones: POST /migrations/{id}/crons/enable, or the project's cron jobs)
    - `created` integer, required
    - `enabled` integer, required
    - `failed` integer, required
    - `atCutover` integer: paused jobs that turn on by themselves once the domain has moved here
  - `resources` object, nullable: vercel: what happened to the external databases, Vercel Blob and vercel.json routing
    - `databases` object[], required
      - `key` string, required
      - `label` string, required
      - `action` 'move' | 'keep', required
      - `state` 'pending' | 'copying' | 'done' | 'failed' | 'kept', required: failed: the copy failed and the variables still point at the old database
      - `databaseId` string, uuid, nullable, required
      - `migrationId` string, uuid, nullable, required: the database import that copies it
      - `detail` string, nullable, required
    - `blob` object, nullable, required
      - `action` 'keep' | 'copy', required
      - `state` 'pending' | 'copying' | 'done' | 'failed' | 'kept', required
      - `bucketId` string, uuid, nullable, required
      - `objects` integer, required
      - `bytes` integer, required
      - `failed` integer, required
      - `detail` string, nullable, required
    - `edgeRules` object, nullable, required
      - `redirects` integer, required
      - `headers` integer, required
      - `applied` boolean, required: false: saved, applied with the next deploy or domain change
    - `report` object, nullable: every Vercel service: moved, kept or failed, and what still needs the Vercel account
      - `services` object[], required
        - `code` 'kv_rest' | 'blob' | 'global_config' | 'protection' | 'analytics_endpoints' | 'system_env' | 'ai_gateway' | 'flags' | 'queues' | 'oidc_federation' | 'firewall' | 'rewrites' | 'libsql' | 'xata' | 'qstash' | 'upstash_vector' | 'upstash_search' | 'netlify_rules' | 'netlify_functions' | 'netlify_scheduled' | 'netlify_edge_functions' | 'netlify_forms' | 'netlify_blobs' | 'netlify_identity' | 'netlify_protection' | 'netlify_snippets' | 'netlify_previews' | 'netlify_split_testing' | 'netlify_large_media' | 'netlify_build_hooks' | 'netlify_build_plugins' | 'netlify_db', required
        - `key` string, required
        - `label` string, required
        - `outcome` 'pending' | 'moved' | 'kept' | 'partial' | 'failed', required: pending: still running · moved · kept: stays where it is (chosen, or it cannot move) · partial: moved with parts left out (see detail) · failed: the move failed and the app keeps using the Vercel service (its old values were restored)
        - `numbers` object, required
          - `keys` integer, nullable
          - `files` integer, nullable
          - `bytes` integer, nullable
          - `items` integer, nullable
          - `variables` integer, nullable
          - `failed` integer, nullable
        - `durationMs` integer, nullable, required
        - `stillAtVercel` boolean, required: the app still needs the Vercel account for it
        - `action` string, nullable, required: what you still need to do, in plain words (English)
        - `actionCode` string, nullable, required: the same as a code, for the dashboard's languages
        - `detail` string, nullable, required
      - `vercelNeeded` object[], required: what still needs the Vercel account, and why; empty: nothing stays at Vercel
        - `code` string, required: a service code, or database for a kept Vercel Postgres/KV database
        - `key` string, required
        - `what` string, required
        - `reason` string, required
        - `reasonCode` string, required
        - `scope` 'project' | 'team', required: project: deleting the Vercel project breaks it; team: it needs the Vercel team (account) only
      - `safeToDeleteProject` boolean, required: the Vercel project can be deleted: nothing kept belongs to it (stores are team-level)
      - `safeToDeleteTeam` boolean, required: the whole Vercel account (team) can go: nothing is needed there any more
      - `finals` object, nullable, required: the last copy at the switch to your domain (KV final sync, new Blob files, a changed config): waiting until the domain is live
        - `state` 'waiting' | 'running' | 'done' | 'skipped' | 'failed', required
        - `detail` string, nullable, required
  - `finalSync` object, nullable: vercel/heroku with moved databases: their final copy right before the domain switches
    - `state` 'needed' | 'running' | 'done' | 'failed' | 'skipped', required: needed: not run yet (the first domain is refused until it is done, or skipFinalSync) · running · done · failed (run it again) · skipped (the domain was connected with skipFinalSync)
    - `allowed` boolean, required: a final copy can start now: the preview is ready, no domain is connected to the import yet (its DNS could change during the copy) and none runs
    - `startedAt` string, nullable, required: ISO-8601 timestamp
    - `finishedAt` string, nullable, required: ISO-8601 timestamp
    - `dataAsOf` string, nullable, required: the copies on Hostingsurge hold the old databases' data up to about this time; later writes there are not copied
    - `oldAppPaused` boolean, required: the last final copy ran with the user's confirmation that the old app is paused until the domain is live (FinalSyncMigrationInput.oldAppPaused)
    - `databases` object[], required
      - `key` string, required
      - `label` string, required
      - `engine` 'postgresql' | 'mysql' | 'mariadb' | 'redis' | 'mongodb', required
      - `kv` boolean, required: a Vercel KV store: merged again at the switch (keys it lacks only), so a failed final copy of it may be skipped; any other database whose replacing copy failed must be copied again before a domain is connected
      - `state` 'waiting' | 'running' | 'done' | 'failed', required
      - `access` 'kept' | 'needed', required: kept: Hostingsurge still holds the access to read it; needed: pass its connection string (FinalSyncMigrationInput.databases)
      - `migrationId` string, uuid, nullable, required: the database import that copies it (its progress and log: GET /migrations/{migrationId})
      - `detail` string, nullable, required
      - `restoreErrors` integer, nullable, required: done: the objects (statements, documents, keys) the copy could not restore on Hostingsurge (see its log; 0: none). Check the preview, and compare before the old database is deleted (oldHost lists it)
  - `mailMove` object, nullable: cpanel: the account's mail moved to Hostingsurge's mail service (POST /migrations/{id}/mail)
    - `state` 'waiting_ownership' | 'copying' | 'done' | 'partial' | 'failed', required: waiting_ownership: a domain waits for its ownership TXT record (POST …/mail again once it is published) · copying: messages are being copied · done: everything moved · partial: something did not move yet (see each mailbox and the notes), including messages still on the old server (sync needed or failed) · failed: a copy from the backup failed for a mailbox no copy from the old server was started for (POST …/mail again copies what is missing)
    - `startedAt` string, required: ISO-8601 timestamp
    - `updatedAt` string, required: ISO-8601 timestamp
    - `backupTakenAt` string, nullable: a backup: when it was made (its newest file). Mail that reached the old server after it is not in the backup: it stays there until the final copy (POST …/mail/sync) — or it is lost when the old hosting is cancelled
    - `syncHost` object, nullable: the old mail server the copy over IMAP signs in to by default
      - `host` string, required
      - `port` integer, required
    - `running` object, nullable: a move or copy request is running now; another answers 409 (mail_move_running) meanwhile
      - `startedAt` string, required: ISO-8601 timestamp
      - `until` string, required: ISO-8601 timestamp
    - `copyMigrationId` string, uuid, nullable, required: the newest import copying messages (its progress and log: GET /migrations/{copyMigrationId})
    - `copyMigrationIds` string[]: every copy the move started, oldest first (each mailbox shows its newest)
    - `domains` object[], required
      - `domain` string, required
      - `emailDomainId` string, uuid, nullable, required: the domain in Email (GET /email/domains/{id}: its DNS records to switch the mail)
      - `state` 'waiting_ownership' | 'ready' | 'failed', required
      - `ownership` object, nullable, required: waiting_ownership: the TXT record that proves you own the domain; publish it, then move the mail again
        - `type` 'TXT', required
        - `name` string, required
        - `value` string, required
      - `mailboxes` object[], required
        - `address` string, required
        - `mailboxId` string, uuid, nullable, required
        - `state` 'created' | 'existed' | 'failed' | 'skipped', required: created by the move · existed: already in Email before the move (left as it is, password included, never appended to from the backup): its mail on the old server still comes with the copy over IMAP (sync)
        - `password` 'carried' | 'new' | 'set_by_owner' | 'pending' | 'unchanged' | 'unknown', required: carried: the old password works · new: a new one (in the answer of the move, once) · set_by_owner: it could not move (see reason): set one under Email · pending: set by the copy · unchanged: the mailbox existed before the move and was left as it is · unknown: created by a move that was interrupted: set one under Email
        - `reason` string, nullable, required: why, as a code: existed · backup_expired (the backup had expired when the mailbox was made: upload it again, analyze the import again, then POST …/mail copies from it, unless a copy from the old server was started for it) · backup_changed · interrupted · copy_failed (the backup copy stopped before it set the old password: pending while POST …/mail can copy it again, set_by_owner once a copy from the old server was started) · a password reason
        - `copy` 'pending' | 'running' | 'done' | 'failed' | 'imap' | 'none', required: the copy from the backup · imap: the messages are not in the backup or could not be copied from it: they come over IMAP (sync) · none: nothing to copy
        - `messages` integer, required: messages copied from the backup
        - `failed` integer, required: messages that could not be copied (see the copy's log)
        - `sync` object, nullable: the copy over IMAP from the old server (null: not needed)
          - `state` 'needed' | 'running' | 'done' | 'failed' | 'skipped', required: needed: messages are still on the old server (newer than the backup, or not in it): copy them (POST …/mail/sync with the mailbox's password there) or skip them · running: the copy runs · done: copied (it repeats every 20 minutes until stopped under Email → mailbox → Import) · failed: see message (a wrong password: send the right one again); its repeated copy was stopped in the mail server (no new copies start; a copy already under way may still finish, skipping the messages already copied, so nothing is copied twice), except with message stop_pending · skipped: left on the old server by the user (an earlier job of it is removed: no new copies start, one already under way may still finish). Once it is anything but needed, the mailbox is never copied from the backup again (POST …/mail): it continues only with POST …/mail/sync
          - `syncJobId` string, uuid, nullable, required: the import job under Email → mailbox → Import (null once a refused copy request removed it: message removed)
          - `message` string, nullable, required: failed: wrong_password · cannot_connect · removed (its job is gone: deleted under Email, with its mailbox, or by a copy request the mail server then refused) · not_started · stopped (its copy was canceled or ended before it finished, and the mail server confirmed the stop: no new copies start, a copy already under way may still finish; copy it again to finish) · mailbox_missing · not_created · or the mail server's exit status · stop_pending (running: the copy failed; failed: its copy was canceled or ended): the mail server has not confirmed the stop of its job yet, so it may still repeat every 20 minutes; the stop is retried every minute until it holds
          - `at` string, nullable, required: when it started, finished or was skipped
      - `aliases` object[], required
        - `address` string, required
        - `destinations` string[], required
        - `state` 'created' | 'existed' | 'failed' | 'skipped', required
        - `reason` string, nullable, required
      - `notes` object[], required
        - `code` string, required
        - `message` string, required
  - `oldHost` object, nullable: preview_ready, cutover and live: whether the old hosting can be cancelled, and what still lives there
    - `verdict` 'can_cancel' | 'keep' | 'check', required
    - `stillThere` object[], required
      - `code` string, required: database, kv_rest, blob, email, stays, heroku_addon, dns …
      - `what` string, required
      - `reasonCode` string, required: the reason as a code, for the dashboard's languages
      - `reason` string, required: why it still needs the old host, in plain words (English)
  - `pushDeploy` 'github_app' | 'off': Git-linked imports (git, and vercel/netlify/heroku from a repository): github_app: every push to the branch deploys through the organization's GitHub App installation; off: pushes do not deploy by themselves until the App covers the repository or the project's push-to-deploy webhook is added to it (audit F10)
  - `envLater` string[]: variables left without a value when the import started (StartMigrationInput.envLater)
  - `envLaterScopes` object[]: exactly which values of envLater were left out, per environment (the name's other values moved with the import)
    - `key` string, required
    - `targets` string[], required
    - `gitBranch` string, nullable, required
  - `variables` object: after the start: every variable of the old host and where it went, or exactly why not
    - `total` integer, required: variables of the old host (its own system variables not counted; ones the token cannot see counted)
    - `moved` integer, required: review: variables that will move with their value; after the start: variables that moved
    - `missing` integer, required
    - `notVisible` integer, required: production variables the token cannot see at all (Vercel hiddenProductionEnvCount): counted as missing
    - `summary` string, required: e.g. "24 of 24 variables moved"
    - `platform` object, nullable, required: the old host's own variables (VERCEL_ENV, VERCEL_URL, Netlify's URL, …) Hostingsurge fills with its values, in one line
      - `keys` string[], required
      - `message` string, required
    - `items` object[], required
      - `key` string, required
      - `from` object, required
        - `targets` string[], required: its environments on the old host
        - `gitBranch` string, nullable, required: a preview value for one Git branch
        - `environment` string, nullable, required: a Vercel custom environment (slug)
        - `type` string, nullable, required: plain, encrypted, sensitive, secret or system
      - `to` object[], required: where it goes here (empty: nowhere)
        - `targets` string[], required
        - `gitBranch` string, nullable, required
      - `status` 'ready' | 'moved' | 'missing' | 'later' | 'platform', required: ready: moves with its value when the import starts; moved: it did; missing: it has no value (red); later: left for later on purpose (red until set); platform: the old host's own variable, handled by Hostingsurge (not counted)
      - `reason` 'hidden' | 'hidden_development' | 'hidden_no_build' | 'fetch_failed' | 'branch_unverified' | 'too_large' | 'invalid_name' | 'reserved_name' | 'no_environment' | 'branch_deploys' | 'left_for_later' | 'expected' | 'old_server' | 'computed' | 'not_in_backup' | 'shadowed' | 'unsupported_value' | 'overridden' | 'unreadable_line' | 'system_mapped' | 'system_unsupported' | 'null', nullable, required
      - `note` 'custom_branch' | 'custom_preview' | 'custom_named_branch' | 'null', nullable, required: how a Vercel custom environment's value was placed: the branch the environment tracks, the preview value, or a branch named after it
      - `message` string, nullable, required: English: exactly why it did not move
      - `action` string, nullable, required: English: the one thing to do (missing and later rows)
      - `place` 'env' | 'wordpress' | 'php': where it goes: env (default) the project's environment variables; wordpress the WordPress site's configuration (wp-config.php: its security keys and salts, and its other settings); php the app's PHP settings (php.ini)
      - `file` string, nullable: the file of the old host it was read from (.env, wp-config.php, .user.ini …), when it came from one
      - `source` 'shared': vercel: a team-level Shared Environment Variable linked to the project; absent: the project's own variable
      - `detail` string, nullable: English: how it changed on the way, or why it is not needed here (a path pointed at the new site, a database setting pointed at the new database, a setting of the old server)
    - `files` object[]: files that held variables or settings (.env, wp-config.php, php.ini …), redacted
      - `path` string, required: its path on the old host, relative to the site (e.g. .env, ../.env, wp-config.php, .user.ini)
      - `kind` 'env' | 'wp-config' | 'php-ini' | 'htaccess' | 'example', required: example: .env.example, env.d.ts and the like (names only, no values)
      - `count` integer, required: variables or settings it holds
      - `note` string, nullable, required: English: what became of it, e.g. "values moved into the project's variables; the file itself is not copied"
  - `envPending` string[]: GET /migrations/{id}: the envLater variables the project still does not have; set them under its environment variables, then redeploy
  - `connectedDatabases` object[]: vercel: databases whose connection string was read with POST /migrations/{id}/databases/{key}/connect (never the password)
    - `key` string, required
    - `provider` 'neon', required
    - `host` string, required
    - `database` string, required
    - `role` string, required
    - `project` string, required
  - `credentials` object, required: never the values themselves
    - `provided` boolean, required
    - `expiresAt` string, nullable, required: ISO-8601 timestamp
  - `valuesExpired` boolean: review: the variable values read from the old host were kept for 24 hours and are gone; analyze again (with a new token) before starting
  - `resume` object, nullable: failed: the import can continue from the failed step (POST /migrations/{id}/retry) once the cause is fixed
    - `step` 'fetch_source' | 'analyze' | 'create_project' | 'create_database' | 'import_env' | 'import_database' | 'restore_files' | 'rewrite_urls' | 'deploy' | 'verify' | 'attach_domain' | 'sync_mail', required: where the import continues
    - `until` string, required: after this, start over instead
    - `needsToken` boolean, required: pass a new provider token to POST /migrations/{id}/retry
    - `acceptPreview` boolean, required: the preview check failed: retry with acceptPreview true continues anyway
  - `discardable` object, nullable: failed or canceled: what POST /migrations/{id}/discard deletes before you start over
    - `projectId` string, uuid, nullable, required
    - `databaseIds` string[], required
    - `storage` object[]: the storage a Vercel import created for Vercel Blob and Edge Config (a bucket, a Blob store, a Global Config), with the files copied into it
      - `kind` 'bucket' | 'blob_store' | 'global_config', required
      - `id` string, required
  - `error` object, nullable, required
    - `code` string, required
    - `message` string, required
  - `cancelRequested` boolean, required
  - `queuedBehind` object, nullable: the import is queued, not running: one import runs at a time per organization, and this one starts by itself once the one running now is done or waits for you. Null while it runs or waits for you
    - `id` string, uuid, nullable, required: the import running now (null when it is not yours to see: an import of a project you have no access to)
    - `name` string, nullable, required
  - `createdAt` string, required: ISO-8601 timestamp
  - `updatedAt` string, required: ISO-8601 timestamp
  - `finishedAt` string, nullable, required: ISO-8601 timestamp
  - `expiresAt` string, required: ISO-8601 timestamp

## Other responses

- `400`: Invalid request (code invalid_request)
- `401`: Missing or invalid session/token (code unauthorized)
- `403`: Authenticated but not permitted (code forbidden)
- `404`: Not found or not visible to the caller (code not_found)
- `409`: Conflicting state (code conflict)
- `429`: Too many requests (code rate_limited); see Retry-After
- `501`: Not available on this server (code not_configured)
- `503`: Hosting engine unavailable (code provider_unavailable)

---

[API](https://skmtc.dev/hostingsurge/apis/hostingsurge-platform-api.md) · [All operations](https://skmtc.dev/hostingsurge/apis/hostingsurge-platform-api/llms.txt) · [OpenAPI document](https://skmtc.dev/hostingsurge/apis/hostingsurge-platform-api/revisions/4d08337cdff1?raw)
