---
title: "Check the preview again: broken pages and files, mixed content, the old site next to the preview (status, title, text), and for WordPress the login page and links left to the old address. Runs by itself when the preview is ready; changes nothing, at most every 30 s"
method: POST
path: "/migrations/{migrationId}/preview-check"
tags: ["Migrations"]
---

# Check the preview again: broken pages and files, mixed content, the old site next to the preview (status, title, text), and for WordPress the login page and links left to the old address. Runs by itself when the preview is ready; changes nothing, at most every 30 s

`POST /migrations/{migrationId}/preview-check`

## Request

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

## Path parameters

- `migrationId` string, uuid, required

## Request body

- object
  - `oldUrl` string: Where the old site still runs (e.g. https://www.example.com), when the import does not know it (Git imports) or knows another address. Only a few pages of it are read (status, title, text), to compare them with the preview

## Response `200`

The import with its new previewCheck

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