# Migrate a Classic app

Move an Expo Push (Classic) app to the current push service. Installed apps keep working and nobody has to update.

## Before you start

You must be the owner or an admin of the account. A Classic app has a **Migrate** page in [Native Notify](https://dashboard.nativenotify.com), under **Setup & docs**. The installed app keeps calling the Classic SDK; Native Notify copies each registration to the current service.

## What the page shows

Migrate lists five steps: **Check your app**, **Copy devices**, **Credentials and a test**, **Your sends** and **Finish**. Each one shows **Done** (a green check), **Running…** (a spinner), **Your turn** or **Not yet**, and a line at the top says what is happening and what to press next. The page updates by itself, so a copy you start with the API or an agent shows up without a reload.

## 1. Copy the devices in Native Notify

Open your app, then **Setup & docs → Migrate** and press **Start copying**. While the copy runs the step shows **Running…** with a progress bar, usually for under a minute, and there is nothing to press; when it finishes the step gets a green check and what was copied. Copying sends no push and changes nothing for your users. Devices keep their last-open dates, so the `lapsed`, `active` and `new` groups stay accurate, and Classic topic groups become [groups](/docs/push/groups). Every later registration from the installed app is copied too. **Stop copying** backs out until you finish.

## 2. Add push credentials and send a test

Each platform with devices that opened the app in the last 90 days needs working [push credentials](/docs/push/credentials). In **Migrate → Credentials and a test**, pick your own phone from the devices that just opened the app, then add the Apple key or the Firebase service account. Native Notify sends one real push to that phone with the new credentials and saves them only if Apple or Google accepts it. Credentials that are already saved need a test push instead.

> **Saving a key changes how a Classic app sends:**
>
> Once a Classic app has push credentials, its single and mass sends go straight to Apple and Google instead of through Expo, and group, topic and follow pushes also keep the Expo copy for a device that hasn't reported both its tokens yet, so that device can get two notifications until it next opens the app. Migrate tests a key before it saves it, so a wrong one can't break the sends that work today.

## 3. Finish

Press **Finish migration**. Native Notify checks every requirement again, then the app becomes a **Current** app and leaves the Classic view. Installed copies keep registering and keep getting pushes. Nothing in your Classic data is deleted.

From then on every send goes straight to Apple and Google, group, topic and follow pushes included, with sound and high priority. Expo is used only for a phone that has no Apple or Google token, or while the saved credentials fail their check.

## With the API

Every call takes a Native Notify dashboard session or an MCP token. An app token is refused with `403`.

| Request                                           | What it does                                                                                                                                                                                                                                                                                                                                   |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/apps/:appId/migrate`                    | The picture: `state` (`classic`, `bridging`, `ready`, `migrated`, `current`), `stateText` (the state in plain words), `steps` (each with `n`, `title` and a `phase`: `done`, `running`, `your_turn`, `waiting` or `blocked`), `blockers`, `warnings`, `credentials`, `tests` and `canFinish`. `?check=1` also runs the live credential checks. |
| `POST /api/apps/:appId/migrate/start`             | Copy the devices and mirror every later registration. Optional `inboxWriteThrough` (boolean) also writes the Classic inbox the installed app reads.                                                                                                                                                                                            |
| `GET /api/apps/:appId/migrate/devices?minutes=15` | Copied devices that opened the app lately, with the last six characters of each token.                                                                                                                                                                                                                                                         |
| `POST /api/apps/:appId/migrate/credentials`       | `{ platform, deviceId, … }` with the credential fields from [Push credentials](/docs/push/credentials). Tests, then saves. A rejected test answers `200` with `ok: false` and `saved: false`.                                                                                                                                                  |
| `POST /api/apps/:appId/migrate/inbox`             | `{ enabled }` turns the Classic inbox write-through on or off.                                                                                                                                                                                                                                                                                 |
| `POST /api/apps/:appId/migrate/finish`            | `{ confirm: true, acknowledge? }` where `acknowledge` lists the blocker codes you accept.                                                                                                                                                                                                                                                      |
| `POST /api/apps/:appId/migrate/cancel`            | Stop copying before you finish.                                                                                                                                                                                                                                                                                                                |

A test push to a copied device goes through [Verify delivery](/docs/push/verification) with its `deviceId`. A migrating app never picks a device for you.

## Blockers

Finish answers `409 not_ready` with the `blockers` that stand in the way.

| Code                                                                    | Clears when                                                                                                                   |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `bridge_not_started`, `bridge_copying`, `bridge_failed`, `bridge_stale` | The copy has run and refreshed.                                                                                               |
| `credentials_missing_ios`, `credentials_missing_android`                | You save credentials for that platform.                                                                                       |
| `credentials_invalid_ios`, `credentials_invalid_android`                | The saved key passes Apple's or Google's live check.                                                                          |
| `test_needed_ios`, `test_needed_android`                                | A test push to a copied device is accepted, or you pass its code in `acknowledge` (credentials that were already saved only). |
| `topics_not_imported`                                                   | The copy has imported the topic groups.                                                                                       |
| `trial_expired`                                                         | The account has an active plan.                                                                                               |
| `multi_subscriber`                                                      | You pass its code in `acknowledge`.                                                                                           |
| `multi_subscriber_blocked`                                              | It doesn't clear. The app stays Classic.                                                                                      |
| `admin_only`                                                            | An owner or admin does it.                                                                                                    |

## Not carried over

- **Several ids on one device.** A device on the current service belongs to one subscriber id, the one it registered under last. A few devices under two ids can be acknowledged. An app that reaches many devices through one id, as a group, can't move; it stays Classic. Use [groups](/docs/push/groups) instead.
- **Follow masters** (a user following a user) aren't copied. Follow pushes keep working through the Classic API.
- **Scheduled Classic pushes** still send. Recreate them under **Push notifications → Scheduling** to keep managing them.

## Errors

| Status | Code                               | Meaning                                            |
| ------ | ---------------------------------- | -------------------------------------------------- |
| `404`  | `migration_unavailable`            | Migrate isn't open to this account yet.            |
| `404`  | `app_not_found`                    | The app isn't on this account or team.             |
| `403`  | `admin_only`                       | A developer-role teammate can read but not change. |
| `409`  | `not_classic`                      | The app already runs on the current service.       |
| `409`  | `not_ready`                        | Finish found `blockers`.                           |
| `409`  | `not_started`, `nothing_to_cancel` | Start the copy first; nothing to stop.             |
