# Backup and import endpoints

These REST routes export and restore backups and import data from other link plugins. Each one is a job: you start it, call a progress route until it finishes, and can cancel it.
Page: https://docs.linkmanagerpro.com/developers/rest-api/backup-migration
Use when someone asks: link manager pro backup api; backup export rest api; import pretty links api; replace shortcodes api
Intent: Reference

Sign-in, the response envelope and errors are in the [REST API overview](https://docs.linkmanagerpro.com/developers/rest-api/).

Every route here needs an administrator (`manage_options`, [Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions)). All paths are relative to `/wp-json/link-manager-pro/v1`. The plugin's own screens call these routes, so build on them with care.

A backup file is JSON. [Backup file format](https://docs.linkmanagerpro.com/developers/data/backup-format) describes what it holds. The same jobs run from the command line: [WP-CLI for developers](https://docs.linkmanagerpro.com/developers/cli).

## Export a backup

An export is a job that runs in batches. Call `POST /settings/backup/export/start` with the items you want. Then call `POST /settings/backup/export/progress` with the returned `job_id`. Each call writes one batch of up to 500 rows. Repeat until `status` is `done`. The last answer holds a `download_url`, and you fetch the file from it.

The job payload that the start and progress routes return:

| Field | Type | What it holds |
|---|---|---|
| `job_id` | string | The job ID (a UUID). Send it to the progress route. |
| `type` | string | `export` for a backup, `import` for a restore. |
| `status` | string | `running`, `done`, or `failed`. A restore can also be `cancelled`. |
| `total` | integer | Rows to write, summed over all items. |
| `processed` | integer | Rows written so far. |
| `percent` | integer | `processed` as a percentage of `total`, 0 to 100. A job with no rows reads 100 once `done`. |
| `current_item` | string or null | The item being written now. `null` when none is left. |
| `items` | array of strings | The items in the job, in backup order. |
| `items_total` | object | Rows per item, keyed by item. |
| `items_processed` | object | Rows written per item, keyed by item. |
| `error` | string or null | The failure message, or `null`. |
| `download_url` | string | Only when `status` is `done`. See [Download the finished file](#download-the-finished-file). |
| `file_name` | string | Only when `status` is `done`. The file name, `link-manager-pro-backup-YYYYMMDD-HHMMSS.json` (UTC). |

The item keys are `categories`, `tags`, `links`, `link_rules`, `clicks`, `affiliates`, `campaigns`, `link_health`, `redirects`, `settings`, `webhooks` and `posts`. The plugin lists a job's items in this order whatever order you send. Choosing `links` adds the item `link_meta` to the job.

### Start an export

`POST /settings/backup/export/start`

Creates an export job for the items you choose. It writes nothing yet.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `items` | array of strings | Yes | none | The items to export. Keys that are not in the list above are dropped. A value that is not an array counts as empty. |

**Response:** `200`. `data` is the [job payload](#export-a-backup) with `type` set to `export` and `status` set to `running`.

```bash
curl -X POST \
  -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"items":["links","categories","tags"]}' \
  "https://example.com/wp-json/link-manager-pro/v1/settings/backup/export/start"
```

```json
{
  "success": true,
  "data": {
    "job_id": "5f1c2a9e-3b7d-4e2a-9c1f-0a8d6b4e7f21",
    "type": "export",
    "status": "running",
    "total": 1234,
    "processed": 0,
    "percent": 0,
    "current_item": "categories",
    "items": ["categories", "tags", "links", "link_meta"],
    "items_total": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "items_processed": { "categories": 0, "tags": 0, "links": 0, "link_meta": 0 },
    "error": null
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | None of the items you sent is a valid item. The code is `backup_items_required`. |
| `409` | A restore is unfinished. Finish or cancel it first. The code is `backup_import_running`. |

### Run an export batch

`POST /settings/backup/export/progress`

Writes the next batch of the job and returns the new progress. Call it until `status` is `done`.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The `job_id` from the start route. |

**Response:** `200`. `data` is the [job payload](#export-a-backup). A job that is already `done` returns its payload again with a fresh `download_url`.

```json
{
  "success": true,
  "data": {
    "job_id": "5f1c2a9e-3b7d-4e2a-9c1f-0a8d6b4e7f21",
    "type": "export",
    "status": "done",
    "total": 1234,
    "processed": 1234,
    "percent": 100,
    "current_item": null,
    "items": ["categories", "tags", "links", "link_meta"],
    "items_total": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "items_processed": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "error": null,
    "download_url": "https://example.com/wp-admin/admin-ajax.php?action=link_manager_pro_download_backup&job_id=5f1c2a9e-3b7d-4e2a-9c1f-0a8d6b4e7f21&_wpnonce=1a2b3c4d5e",
    "file_name": "link-manager-pro-backup-20261023-101500.json"
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty. The code is `backup_job_required`. |
| `404` | No job has that ID. The code is `backup_job_not_found`. |
| `500` | The job failed. The message says why, for example `backup_export_fetch_failed` (rows could not be read) or `backup_job_failed`. |
| `507` | The server ran out of disk space while writing. The code is `backup_disk_full`. |

### Download the finished file

`GET /wp-admin/admin-ajax.php?action=link_manager_pro_download_backup&job_id={job_id}&_wpnonce={nonce}`

Sends the finished backup file. This is an admin-ajax request, not a REST route, so it needs a logged-in browser session (the login cookie). Use the `download_url` from the `done` payload as it is.

**Permission:** `manage_options`.

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `action` | string | Yes | none | Always `link_manager_pro_download_backup`. |
| `job_id` | string | Yes | none | The export job ID. |
| `_wpnonce` | string | Yes | none | A nonce for the action `lmp_backup_download_{job_id}`. The `download_url` already carries it. |

**Response:** `200` with the file as the body. The headers are `Content-Type: application/json`, `Content-Disposition: attachment; filename="link-manager-pro-backup-....json"`, `Content-Length` and `Cache-Control: no-cache, no-store, must-revalidate`. The body is the backup file ([Backup file format](https://docs.linkmanagerpro.com/developers/data/backup-format)).

**Errors:**

| Status | When |
|---|---|
| `403` | The user is not an administrator, or the nonce is missing, wrong or expired. |
| `404` | The job does not exist, is not a finished export, or its file is gone. |

The server keeps the file for one day after the job's last activity. After that the daily clean-up deletes it, and you export again.

### Export the links as CSV

`GET /settings/backup/export/csv`

Streams every short link as a CSV file. This is a spreadsheet list, not a restorable backup.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:** None.

**Response:** `200` with a CSV body, not the JSON envelope. The headers are `Content-Type: text/csv; charset=utf-8`, `Content-Disposition: attachment; filename="link-manager-pro-links-YYYY-MM-DD.csv"` (the UTC date) and `Cache-Control: no-cache, no-store, must-revalidate`. Rows run from the highest link ID down. A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets a leading `'` so spreadsheets do not run it as a formula.

| Column | What it holds |
|---|---|
| `ID` | The short link ID. |
| `Slug` | The slug. |
| `Target URL` | The destination. |
| `Redirect Type` | The HTTP status, `301` when the link has none. |
| `Source` | Where the link came from, such as `manual`. |
| `Click Count` | The stored click total, `0` when none. |
| `Autolink` | `yes` or `no`. |
| `Keywords` | The auto-link keywords joined with a comma and a space. |
| `Created At` | The creation date and time, written the way the link model prints it. Empty when none. |

```bash
curl -u USER:APPLICATION_PASSWORD -o links.csv \
  "https://example.com/wp-json/link-manager-pro/v1/settings/backup/export/csv"
```

```text
ID,Slug,"Target URL","Redirect Type",Source,"Click Count",Autolink,Keywords,"Created At"
42,blue-widget,https://example.com/products/blue-widget,301,manual,128,yes,"blue widget, widget",2026-10-01T09:30:00.000
```

## Restore a backup

A restore is also a job. You send the file and the items to restore, then call the progress route until it ends. The plugin keeps a safety copy of each item before it clears it. If a batch fails, or you cancel, it puts the copies back. Only one restore can run at a time, and a restore blocks new exports.

A small file goes to `POST /settings/backup/import/start` as the field `file`. A larger file goes up in parts to `POST /settings/backup/import/chunk` first. The last part returns an `upload_id`. You send that to the start route instead of the file.

The flow:

1. Send the file, or upload its parts and keep the `upload_id`.
2. `POST /settings/backup/import/start` with `items` and `file` or `upload_id`. The plugin checks the file here. A refused file never touches your data.
3. `POST /settings/backup/import/progress` until `status` is `done`. Each call restores one batch of up to 500 rows (10 for `posts`).
4. To stop, call `POST /settings/backup/import/cancel`. A failed batch rolls back on its own and returns `500`.

If a page closes mid-restore, `GET /settings/backup/import/active` returns the running job so you can resume it.

The restore job uses the same fields as the [export job payload](#export-a-backup), with `type` set to `import`. A `done` restore adds these:

| Field | Type | What it holds |
|---|---|---|
| `message` | string | `Import completed successfully.` |
| `notes` | string | Only when something was skipped. A plain sentence, for example about posts that were not restored or a short link prefix that was left out. |

A progress call can also return `busy` set to `true`. That means another request is restoring a batch right now. Wait and call again.

Restore rules you can hit as errors:

- The file must be a `.json` file with JSON content and a `schema_version` the plugin knows.
- `posts` can only be restored on the site the backup came from.
- A backup from another site must include the items that depend on each other. Restoring `links` without `link_rules`, `clicks`, `redirects`, `categories` and `tags` is refused when this site has rows in them.

### Upload one part of a backup

`POST /settings/backup/import/chunk`

Adds one part of a large backup file. Send the parts in order, as `multipart/form-data`.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (multipart form, not JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `upload_id` | string | No | empty | Empty on the first part. On later parts, the `upload_id` the previous answer returned. |
| `index` | integer | Yes | `0` | The part number, counting from 0. It must equal the `next_index` of the last answer. |
| `total` | integer | Yes | `0` | How many parts the upload has. Must be 1 or more, and above `index`. |
| `size` | integer | Yes | `0` | The whole file's size in bytes. Must be above 0 and at most 1073741824 (1 GB). Read on the first part. |
| `name` | string | Yes | empty | The file name. It must end in `.json`. Read on the first part. |
| `part` | file | Yes | none | The bytes of this part. It can be no larger than the server's upload limit (`wp_max_upload_size()`). |

The plugin's own screen sends parts of up to 1 MB, or the server limit minus 64 KB when that is smaller. It halves a part the server refuses, down to 128 KB.

**Response:** `200`. `data` holds:

| Field | Type | What it holds |
|---|---|---|
| `upload_id` | string | The upload ID (a UUID). Send it with every later part and with the start route. |
| `next_index` | integer | The `index` the next part must carry. |
| `bytes` | integer | Bytes stored so far. |
| `size` | integer | The whole file's size in bytes. |
| `complete` | boolean | `true` when `bytes` equals `size`. |

```bash
curl -X POST \
  -u USER:APPLICATION_PASSWORD \
  -F "upload_id=" -F "index=0" -F "total=3" -F "size=2621440" -F "name=backup.json" \
  -F "part=@part-0.bin" \
  "https://example.com/wp-json/link-manager-pro/v1/settings/backup/import/chunk"
```

```json
{
  "success": true,
  "data": {
    "upload_id": "9b2e41c0-7a3d-4f6e-8d15-3c0b7a9e2f64",
    "next_index": 1,
    "bytes": 1048576,
    "size": 2621440,
    "complete": false
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | The part is missing or empty (`backup_upload_part_missing`), the transfer failed (`backup_file_upload_failed`), the name does not end in `.json` (`backup_file_extension`), or the sizes do not add up (`backup_upload_invalid`). |
| `403` | Another user started this upload. The code is `backup_upload_forbidden`. |
| `404` | The `upload_id` is unknown or expired, or `index` is not 0 on a first part. The code is `backup_upload_not_found`. |
| `409` | The part arrived out of order. The code is `backup_upload_part_order`. The error `data` holds `expected_index` and `bytes` so you can resume from there. |
| `413` | The part is larger than the server takes (`backup_upload_part_too_large`, with `limit` in the error `data`), or the file is over 1 GB (`backup_upload_too_large`). |
| `500` | The part could not be stored. The code is `backup_file_store_failed`. |

### Start a restore

`POST /settings/backup/import/start`

Checks the backup file and creates the restore job. It clears nothing yet; each item is cleared when its first batch runs.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (multipart form when you send `file`, JSON otherwise)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `items` | array of strings | Yes | none | The items to restore, from the [item list](#export-a-backup). Send them as `items[]` in a form. Unknown keys are dropped. The file must hold every item you choose. |
| `file` | file | One of `file` or `upload_id` | none | The backup file, sent in one request. |
| `upload_id` | string | One of `file` or `upload_id` | empty | The ID of a [complete chunked upload](#upload-one-part-of-a-backup) made by the same user. When set, `file` is ignored. The plugin deletes the upload after it takes the file. |

**Response:** `200`. `data` is the [job payload](#export-a-backup) with `type` set to `import` and `status` set to `running`.

```bash
curl -X POST \
  -u USER:APPLICATION_PASSWORD \
  -F "file=@link-manager-pro-backup-20261023-101500.json" \
  -F "items[]=links" -F "items[]=categories" -F "items[]=tags" \
  "https://example.com/wp-json/link-manager-pro/v1/settings/backup/import/start"
```

```json
{
  "success": true,
  "data": {
    "job_id": "c3d8f0a2-61b4-4d97-a2e5-7f9014be3a18",
    "type": "import",
    "status": "running",
    "total": 1234,
    "processed": 0,
    "percent": 0,
    "current_item": "categories",
    "items": ["categories", "tags", "links", "link_meta"],
    "items_total": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "items_processed": { "categories": 0, "tags": 0, "links": 0, "link_meta": 0 },
    "error": null
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | No valid item (`backup_items_required`). No file (`backup_file_required`) or a failed upload (`backup_file_upload_failed`, `backup_file_upload_invalid`). Not a `.json` name (`backup_file_extension`) or not JSON content (`backup_file_mime`). A bad `schema_version` (`backup_file_invalid`) or one newer than the plugin knows (`backup_newer_format`). A chosen item is missing or malformed in the file (`backup_item_missing`, `backup_item_invalid`). Posts from another site (`backup_posts_foreign_site`). Missing dependent items (`backup_cross_site_dependants`, with an `items` list in the error `data`). |
| `404` | The `upload_id` is unknown or expired (`backup_upload_not_found`). |
| `403` | The `upload_id` belongs to another user (`backup_upload_forbidden`). |
| `409` | A restore is already running (`backup_import_running`), or the upload is not complete (`backup_upload_incomplete`). |
| `500` | The file could not be stored (`backup_file_store_failed`). |
| `507` | The server has no disk space to prepare the files (`backup_disk_full`). |

### Run a restore batch

`POST /settings/backup/import/progress`

Restores the next batch of rows and returns the new progress. Call it until `status` is `done`.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The `job_id` from the start route. |

**Response:** `200`. `data` is the [job payload](#export-a-backup), plus `busy: true` when another request holds the restore. A job that is `done` or `cancelled` returns its payload again. A `done` job also holds `message` and, when something was skipped, `notes`.

```json
{
  "success": true,
  "data": {
    "job_id": "c3d8f0a2-61b4-4d97-a2e5-7f9014be3a18",
    "type": "import",
    "status": "done",
    "total": 1234,
    "processed": 1234,
    "percent": 100,
    "current_item": null,
    "items": ["categories", "tags", "links", "link_meta"],
    "items_total": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "items_processed": { "categories": 4, "tags": 7, "links": 1200, "link_meta": 23 },
    "error": null,
    "message": "Import completed successfully."
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty. The code is `backup_job_required`. |
| `404` | No job has that ID. The code is `backup_job_not_found`. |
| `500` | The restore failed and was rolled back (`backup_import_failed`). The message reads "Restore failed while restoring {item}: {cause} All data was rolled back; nothing was changed." The item is named as a readable label, such as "link rules", "link details", "Link Health records" or "posts and pages". When the run was past the last item, it names the last one, the job is already `failed` (`backup_job_failed`), or a count did not match (`backup_restore_incomplete`). Your previous data was put back. If the rollback itself could not finish, the message names the safety-copy tables the plugin kept. |

### Get the running restore

`GET /settings/backup/import/active`

Returns the unfinished restore, so a screen that reloads can resume it.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:** None.

**Response:** `200`. `data` is the [job payload](#export-a-backup) of the running restore, or `null` when none is running.

```json
{
  "success": true,
  "data": null
}
```

### Cancel a restore

`POST /settings/backup/import/cancel`

Stops a running restore and puts back every item it already cleared.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The restore job ID. |

**Response:** `200`. `data` is the [job payload](#export-a-backup) with `status` set to `cancelled`. `error` holds the plain sentence `Restore cancelled. All data was rolled back; nothing was changed.`

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty. The code is `backup_job_required`. |
| `404` | No job has that ID. The code is `backup_job_not_found`. |
| `409` | The job is not running (`backup_import_not_running`), or another request is restoring a batch right now (`backup_import_busy`). Try again in a moment. |
| `500` | The rollback could not fully finish. The message names the safety-copy tables the plugin kept. |

## Import from another plugin

Importing from Pretty Links, BetterLinks or ThirstyAffiliates is a job too. The source value is `prettylinks`, `betterlinks` or `thirstyaff`. The flow:

1. `GET /settings/migration/detect` lists the plugins whose data it found, with counts.
2. `POST /settings/migration/start` creates the job from a `source`, the `items` to bring over, a `conflict_strategy` and the affiliate domains to create.
3. `POST /settings/migration/process` with the `job_id`, repeatedly. Each call runs one batch of one phase. Stop when `status` is `done`.
4. `GET /settings/migration/status` reads the job without running a batch.
5. `POST /settings/migration/cancel` stops the job. Rows already imported stay.

Jobs run only through these routes or WP-CLI. There is no background mode.

The job payload that `start`, `status`, `process` and `cancel` return:

| Field | Type | What it holds |
|---|---|---|
| `job_id` | string | The job ID (a UUID). |
| `status` | string | `running`, `done`, `failed` or `cancelled`. |
| `current_phase` | string or null | The phase running now, or `null` when all are done. |
| `phases` | array of strings | The phases of this job, in order. They come from the items you chose. The last one is always `finalize`. |
| `phase_index` | integer | The position of the current phase in `phases`, from 0. |
| `total` | integer | Rows to import, summed over all items. |
| `processed` | integer | Rows migrated, skipped or failed so far. |
| `percent` | integer | `processed` as a percentage of `total`, 0 to 100. |
| `stats` | object | Per item: `total`, `migrated`, `skipped` and `failed`, all integers. |
| `errors` | array | Objects with `item` and `message`. |
| `warnings` | array | Objects with `item` and `message`: what was imported differently or could not be carried over. Up to 200 are kept. |
| `more_warnings` | integer | Warnings dropped after the 200. |
| `link_check` | object or null | The result of the last step, which resolves every imported link. `null` until then. Described below. |

`link_check` holds `checked`, `working`, `protected`, `switched_off` and `scheduled` (all integers), `problems` (objects with `link_id`, `slug` and `message`, up to 100) and `clashes` (objects with `link_id`, `slug`, `path`, `key`, `code`, `goes_to` and `fixable`, up to 100).

The phases by source:

| Source | Items you can choose | Phases |
|---|---|---|
| `prettylinks` | `links`, `groups`, `categories`, `tags`, `clicks`, `settings`, `affiliates` | `groups`, `links`, `terms`, `clicks`, `settings`, `finalize` |
| `betterlinks` | `links`, `categories`, `tags`, `clicks`, `passwords`, `keywords`, `geo_rules`, `settings`, `affiliates` | `terms`, `links`, `keywords`, `passwords`, `geo_rules`, `clicks`, `settings`, `finalize` |
| `thirstyaff` | `links`, `categories`, `clicks`, `keywords`, `rules`, `settings`, `affiliates` | `terms`, `links`, `keywords`, `rules`, `clicks`, `settings`, `finalize` |

A phase appears only when you chose an item for it. `affiliates` has no phase: `finalize` creates the affiliates for the domains you send.

### Detect other plugins

`GET /settings/migration/detect`

Lists the plugins this site has data from. A plugin with no data is left out.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:** None.

**Response:** `200`. `data.plugins` is an array. Each entry holds:

| Field | Type | What it holds |
|---|---|---|
| `detected` | boolean | Always `true` in this list. |
| `source` | string | `prettylinks`, `betterlinks` or `thirstyaff`. |
| `name` | string | The plugin name: `Pretty Links`, `BetterLinks` or `ThirstyAffiliates`. |
| `version` | string | The installed version, or an empty string when it cannot be read. |
| `counts` | object | Rows per item, as integers. Includes `affiliates`, the number of suggested new affiliate domains. |
| `items` | array of strings | The items you can import from this source (the table above). |
| `affiliate_domains` | array | Domains of the destinations, each with `domain`, `links`, `clicks`, `signal` (why it looks like an affiliate, or an empty string), `suggested` and `existing` (both boolean). Suggested domains come first. |
| `link_prefix` | string | `thirstyaff` only. The short-link prefix ThirstyAffiliates used. |
| `affiliate_prefix_suggestion` | string | `thirstyaff` only. An affiliate prefix to offer, or an empty string. It is empty when an affiliate prefix is already set or ThirstyAffiliates' prefix breaks a prefix rule. |

```json
{
  "success": true,
  "data": {
    "plugins": [
      {
        "detected": true,
        "source": "prettylinks",
        "name": "Pretty Links",
        "version": "3.6.0",
        "counts": { "links": 120, "clicks": 4800, "groups": 3, "categories": 2, "tags": 5, "affiliates": 4 },
        "items": ["links", "groups", "categories", "tags", "clicks", "settings", "affiliates"],
        "affiliate_domains": [
          { "domain": "shop.example.net", "links": 12, "clicks": 640, "signal": "sponsored", "suggested": true, "existing": false }
        ]
      }
    ]
  }
}
```

### Dismiss the import prompt

`POST /settings/migration/dismiss`

Remembers that you do not want the import offer for one source.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `source` | string | Yes | none | The source to stop offering, for example `prettylinks`. The plugin does not check the value against the three sources. |

**Response:** `200`. `data` is `null`.

**Errors:**

| Status | When |
|---|---|
| `400` | `source` is empty. The code is `migration_source_required`. |

### Start an import job

`POST /settings/migration/start`

Creates the import job. It brings nothing over until you call `process`.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `source` | string | Yes | none | `prettylinks`, `betterlinks` or `thirstyaff`. |
| `items` | array of strings | Yes | none | The items to import, from the source's list above. Other values are dropped. |
| `conflict_strategy` | string | No | `skip` | A source link with the same slug and destination as a link here is the same link. `skip` leaves that link as it is and only adds the source's clicks and old address to it. `overwrite` replaces its settings but never its click history or its auto-linking settings. A link with the same slug and a different destination is imported as a new link. Another value counts as `skip`. |
| `affiliate_domains` | array of strings | No | `[]` | The domains to create as affiliates. Characters other than letters, digits, dots and hyphens are removed, and the rest is lowercased. Used only when `items` includes `affiliates`. |

**Response:** `200`. `data` is the [import job payload](#import-from-another-plugin) with `status` set to `running`.

```bash
curl -X POST \
  -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"source":"prettylinks","items":["links","categories","clicks","affiliates"],"conflict_strategy":"skip","affiliate_domains":["shop.example.net"]}' \
  "https://example.com/wp-json/link-manager-pro/v1/settings/migration/start"
```

```json
{
  "success": true,
  "data": {
    "job_id": "e7a1b3c5-2d4f-4a68-b9c0-1f2e3d4c5b6a",
    "status": "running",
    "current_phase": "links",
    "phases": ["links", "terms", "clicks", "finalize"],
    "phase_index": 0,
    "total": 4920,
    "processed": 0,
    "percent": 0,
    "stats": {
      "links": { "total": 120, "migrated": 0, "skipped": 0, "failed": 0 },
      "categories": { "total": 2, "migrated": 0, "skipped": 0, "failed": 0 },
      "clicks": { "total": 4800, "migrated": 0, "skipped": 0, "failed": 0 },
      "affiliates": { "total": 1, "migrated": 0, "skipped": 0, "failed": 0 }
    },
    "errors": [],
    "warnings": [],
    "more_warnings": 0,
    "link_check": null
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `400` | `source` is not one of the three (`migration_source_invalid`), `items` is empty or not an array (`migration_items_required`), or no item is valid for the source (`migration_start_failed`). |

### Run an import batch

`POST /settings/migration/process`

Runs the next batch of the job and returns the new progress. Call it until `status` is `done`.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The `job_id` from the start route. |

**Response:** `200`. `data` is the [import job payload](#import-from-another-plugin). Two extras can appear:

| Field | Type | What it holds |
|---|---|---|
| `busy` | boolean | `true` when another request is running a batch of this job. The payload is the current state. Call again. |
| `affiliate_prefix_offer` | string | Only when `status` is `done`. A ThirstyAffiliates import with no affiliate prefix set returns that plugin's prefix here, when it passes the prefix rules. Otherwise an empty string. The prefix is the **Affiliate link prefix** ([General settings](https://docs.linkmanagerpro.com/guides/settings/general#affiliate-link-prefix)). |

A finished job also stops the import prompt for its source.

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty (`migration_job_required`), or the job is not running, for example `cancelled` or `failed` (`migration_process_failed`). |
| `404` | No job has that ID. The code is `migration_job_not_found`. |

A batch that throws an error sets `status` to `failed` and puts the message in `errors`. The route still answers `200`.

### Get an import job

`GET /settings/migration/status`

Reads the job's progress without running a batch.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The import job ID. |

**Response:** `200`. `data` is the [import job payload](#import-from-another-plugin) plus `affiliate_prefix_offer` (a string, empty unless the job is `done` and the offer rules above apply).

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty. The code is `migration_job_required`. |
| `404` | No job has that ID. The code is `migration_job_not_found`. |

### Cancel an import job

`POST /settings/migration/cancel`

Stops the job. Rows it already imported stay in place.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `job_id` | string | Yes | none | The import job ID. |

**Response:** `200`. `data` is the [import job payload](#import-from-another-plugin) with `status` set to `cancelled`.

**Errors:**

| Status | When |
|---|---|
| `400` | `job_id` is empty (`migration_job_required`), or the job could not be stopped (`migration_cancel_failed`). |
| `404` | No job has that ID. The code is `migration_job_not_found`. |

## Replace old shortcodes

After an import, posts can still hold the old plugin's shortcodes: `[prettylink]`, `[thirstylink]` and the `<ta>` tag. These three routes find them and swap each one for a normal link to the imported short link. The flow is summary, then scan, then replace. Scan and replace work through the posts in pages by post ID. Pass the `next` value of one answer as `after` in the next call, until `next` is `null`.

A shortcode whose link was not imported is left as it is and listed as unresolved. Shortcodes stored in page-builder data are only counted, and you replace them by hand.

### Count the old shortcodes

`GET /settings/migration/shortcodes`

Counts the posts that still hold an imported plugin's shortcodes.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:** None.

**Response:** `200`. `data` holds:

| Field | Type | What it holds |
|---|---|---|
| `posts` | integer | Posts with a shortcode in their content. |
| `builder_posts` | integer | Posts with a shortcode in page-builder data. |
| `imported` | boolean | `true` when any short link was imported from one of the three plugins. |

The two counts are cached for one hour.

```json
{
  "success": true,
  "data": { "posts": 14, "builder_posts": 2, "imported": true }
}
```

### Review the shortcode changes

`GET /settings/migration/shortcodes/scan`

Lists the next 100 posts that would change, and what would change in each. It saves nothing.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `after` | integer | No | `0` | Scan posts with an ID above this. Use the `next` of the last answer. |

**Response:** `200`. `data` holds:

| Field | Type | What it holds |
|---|---|---|
| `posts` | array | One object per post that would change or holds an unresolved shortcode. Fields below. |
| `next` | integer or null | The post ID to pass as `after`, or `null` at the end. |
| `builder` | object | Only when `after` is `0`. Posts with shortcodes in page-builder data: `posts` (up to 20, each with the post fields `id`, `title`, `type`, `status`, `edit_url`, `view_url`), `more` (boolean) and `total` (integer). |

Each entry in `posts` holds the post fields `id` (integer), `title`, `type` (the post type's name), `status` (the status label), `edit_url` and `view_url` (strings), and:

| Field | Type | What it holds |
|---|---|---|
| `replaceable` | integer | Shortcodes in this post that would be replaced. |
| `blocked` | string | A reason a save would be skipped (no permission, markup WordPress would strip, a scheduled post that is due). An empty string when it can be saved. |
| `unresolved` | array of strings | Up to 10 shortcodes whose link was not imported, cut to 160 characters. |
| `example` | object or null | `before` and `after`, the first replacement found, each cut to 160 characters. |
| `sources` | array of strings | The sources found, `prettylinks` or `thirstyaff`. |

### Replace the shortcodes

`POST /settings/migration/shortcodes/replace`

Replaces the shortcodes in the next 25 posts and saves them. Each post gets a revision first.

**Permission:** `manage_options` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions))

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `after` | integer | No | `0` | Process posts with an ID above this. Use the `next` of the last answer. |

**Response:** `200`. `data` holds:

| Field | Type | What it holds |
|---|---|---|
| `replaced` | integer | Shortcodes replaced in saved posts. |
| `posts_changed` | integer | Posts saved. |
| `unresolved` | integer | Shortcodes left because their link was not imported. |
| `skipped` | array | Posts that were not saved. Each holds the post fields (`id`, `title`, `type`, `status`, `edit_url`, `view_url`), `reason` (a sentence) and `shortcodes` (integer). |
| `next` | integer or null | The post ID to pass as `after`, or `null` at the end. |

```json
{
  "success": true,
  "data": {
    "replaced": 31,
    "posts_changed": 12,
    "unresolved": 0,
    "skipped": [],
    "next": null
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `409` | A replacement is already running in another window. The code is `shortcode_replace_busy`. The lock lasts up to one minute. |

## Related

- [Backup file format](https://docs.linkmanagerpro.com/developers/data/backup-format)
- [WP-CLI for developers](https://docs.linkmanagerpro.com/developers/cli)
- [REST API overview](https://docs.linkmanagerpro.com/developers/rest-api/)
- [All endpoints](https://docs.linkmanagerpro.com/developers/rest-api/endpoints)
