# Link Health, Audit and Equity endpoints

These REST routes list and fix broken links, audit internal links, report link equity and mask links in content. Each entry lists the parameters, the response fields and the errors.
Page: https://docs.linkmanagerpro.com/developers/rest-api/link-health
Use when someone asks: link manager pro link health api; broken links api; link audit api; link equity api
Intent: Reference

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

All paths are relative to `/wp-json/link-manager-pro/v1`. Examples use `https://example.com` and `USER:APPLICATION_PASSWORD`. Inside WordPress, send the `X-WP-Nonce` header instead.

A validation failure is a `422` with `message` and an `errors` object that lists messages per field. A missing record is a `404`. The scans run in batches, so a caller repeats one route until the response says it is finished.

## The Link Health item

One Link Health item is one broken address. A content item is a broken link found in a post. A traffic item is a visitor 404 on your own site.

The list and the single-item routes use these fields. Dates are in UTC.

| Field | Type | What it holds |
|---|---|---|
| `id` | integer | The item ID. |
| `url` | string | The broken address. |
| `source_post_id` | integer or null | The post the link was found in. `null` for most traffic items. |
| `health_type` | string | `content` or `traffic`. |
| `status_code` | integer or null | The HTTP status of the last check, or `null` when there was no answer. |
| `referrer` | string or null | The page a visitor came from. Set on traffic items. |
| `hits` | integer | How many times the problem was seen. Starts at `1`. |
| `last_detected` | string | When it was last seen, for example `2026-10-20T08:30:00.000`. |
| `status` | string | `active`, `fixed` or `ignored`. |
| `created_at` | string | When the item was first stored. |
| `updated_at` | string | When the item last changed. |
| `post_title` | string | The title of the source post. Empty when there is none. |
| `post_url` | string | The permalink of the source post. Empty when there is none. |
| `post_edit_url` | string | The editor link of the source post. Empty when there is none. |
| `referrer_host` | string | The host of `referrer` without `www.`. Empty when there is no referrer. |
| `via_links` | array | Up to 5 of your short links whose destination is this address. Each has `id`, `slug` and `short_path`, the address under the prefix that link uses (the affiliate prefix for a link to an affiliate's domain, when it is set). Content items only. |
| `redirect_link_id` | integer | The short link a redirect for this address leads to. Only present when a redirect exists. |
| `redirect_status` | string | The redirect's status. Only present when a redirect exists. |
| `redirect_slug` | string | The slug of that short link. Only present when a redirect exists. |
| `redirect_url` | string | The full short address. Only present when a redirect exists. |
| `redirect_path` | string | The short address without the host. Only present when a redirect exists. |
| `redirect_target_url` | string | Where that short link leads. Only present when a redirect exists. |

Items of unpublished or trashed posts, and items of content types you did not choose for scanning, are hidden from every list, count and clear. They come back when the post is published again.

## Link Health

Everything the Link Health screen does is a route here. The scan checks published posts one batch at a time.

**How a scan runs.** Call `POST /link-health/scan` with `offset` `0`. The response carries a `scanId` and a `nextOffset`. Call the route again with that `nextOffset` as `offset` and the same `scanId` as `scan_id`. Repeat until `nextOffset` is `null`. The last batch records the finished scan, which then shows as `last_scan` in the list. To stop, do not ask for the next batch.

Each batch checks up to 25 posts, within a 15 second budget. A batch can scan fewer posts than the limit when time runs out, so always continue from `nextOffset`. A rerun within an hour of a scan that left links unchecked reuses the links that scan already checked.

### List Link Health items

`GET /link-health`

Returns one page of broken-link items, newest detection first, with the counts that drive the filters.

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

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `type` | string | No | all | `content` or `traffic`. Any other value, or `all`, returns both. |
| `status` | string | No | `active` | `active`, `fixed`, `ignored` or `all`. Any other value counts as `active`. |
| `search` | string | No | none | Matches the address, the referrer, or the title of the source post. |
| `page` | integer | No | `1` | The page number. |
| `per_page` | integer | No | `15` | Items per page. |

**Response:** `200`. `data` is a page object ([Pagination](https://docs.linkmanagerpro.com/developers/rest-api/#pagination)) whose `data.data` rows are [Link Health items](#the-link-health-item). It carries four more fields:

| Field | Type | What it holds |
|---|---|---|
| `counts` | object | Counts across all pages, fields below. |
| `last_scan` | object or null | The last finished content scan, or `null` before the first. |
| `scan_types` | array | The content types a scan checks, each `{ "name": "post", "label": "Posts" }`. |
| `sitemap` | object | How visitor 404s are filtered, fields below. |

`counts` holds these integers:

| Field | What it counts |
|---|---|
| `needs_attention` | Active items of both types. |
| `content` | Active content items. |
| `traffic` | Active traffic items. |
| `fixed` | Fixed items of both types. |
| `ignored` | Ignored items of both types. |
| `all` | Every item, whatever its status. |
| `scope` | An object `{ "type": "", "all": 12 }`: every item in the tab chosen by `type`, any status, search not applied. This is what [Clear Link Health items](#clear-link-health-items) removes. |
| `status` | An object `{ "active": 9, "fixed": 2, "ignored": 1 }`: items in the tab, honoring `search`. |

`last_scan` holds `posts` (integer, posts in the scan), `finished_at` (string, UTC `Y-m-d H:i:s`), `checked` (integer, links checked), `unchecked` (integer, links left over by a cap or the time limit) and `started_at` (integer, Unix seconds, `0` for a scheduled scan). A scan with any `unchecked` links was not fully checked.

`sitemap` holds `mode` (string, `sitemap` or `all`, from the **Track visitor 404s** setting), `status` (object or null) and `listed` (boolean). `status` has `state` (`ok`, `missing` or `unreadable`), `checked_at` (integer, Unix seconds), and for `ok` also `url` and `count`. `listed` is `false` when no sitemap list is cached.

```bash
curl -u USER:APPLICATION_PASSWORD \
  "https://example.com/wp-json/link-manager-pro/v1/link-health?type=content&status=active"
```

```json
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 17,
        "url": "https://example.com/old-guide",
        "source_post_id": 42,
        "health_type": "content",
        "status_code": 404,
        "referrer": null,
        "hits": 2,
        "last_detected": "2026-10-20T08:30:00.000",
        "status": "active",
        "created_at": "2026-10-18T06:00:00.000",
        "updated_at": "2026-10-20T08:30:00.000",
        "post_title": "Choosing a widget",
        "post_url": "https://example.com/choosing-a-widget/",
        "post_edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit",
        "referrer_host": "",
        "via_links": []
      }
    ],
    "per_page": 15,
    "total": 1,
    "counts": {
      "needs_attention": 1,
      "content": 1,
      "traffic": 0,
      "fixed": 0,
      "ignored": 0,
      "all": 1,
      "scope": { "type": "content", "all": 1 },
      "status": { "active": 1, "fixed": 0, "ignored": 0 }
    },
    "last_scan": {
      "posts": 120,
      "finished_at": "2026-10-20 08:31:12",
      "checked": 640,
      "unchecked": 0,
      "started_at": 1792483800
    },
    "scan_types": [
      { "name": "post", "label": "Posts" },
      { "name": "page", "label": "Pages" }
    ],
    "sitemap": {
      "mode": "sitemap",
      "status": { "state": "ok", "checked_at": 1792483500, "url": "https://example.com/wp-sitemap.xml", "count": 118 },
      "listed": true
    }
  }
}
```

The page object also holds `first_page_url`, `from`, `last_page`, `last_page_url`, `links`, `next_page_url`, `path`, `prev_page_url` and `to`, shortened here.

### Scan posts for broken links

`POST /link-health/scan`

Checks the links in one batch of published posts, logs the broken ones and returns where the next batch starts.

**Permission:** `link_manager_pro_access`

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `offset` | integer | No | `0` | Posts to skip. `0` starts a new scan. A negative number counts as `0`. |
| `limit` | integer | No | `25` | Posts per batch, kept between `1` and `25`. |
| `scan_id` | string | No | none | The `scanId` of the scan this batch belongs to. 8 to 32 lowercase letters and digits. Any other value is ignored. |

The scan checks the content types chosen under **Content types to check** in the Link Intelligence settings (default `post,page`), published posts only, ordered by ID. With `offset` above `0` and a `scan_id` the site no longer knows (expired after a day, or lost from an object cache), the scan still runs. It records no finished scan, because its earlier counts are lost.

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

| Field | Type | What it holds |
|---|---|---|
| `scanId` | string | The scan's ID. Send it back as `scan_id`. A new one is made when `offset` is `0`. |
| `scanned` | integer | Posts scanned in this batch. |
| `found` | integer | Broken links found in this batch. |
| `checked` | integer | Links checked in this batch. |
| `unchecked` | integer | Links left unchecked by a cap or the time limit. |
| `skipped` | integer | Links never requested because they point to a private or internal address. |
| `total` | integer | Published posts of the chosen types. |
| `nextOffset` | integer or null | The `offset` for the next batch, or `null` when the scan is finished. |

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{ "offset": 0 }' \
  https://example.com/wp-json/link-manager-pro/v1/link-health/scan
```

```json
{
  "success": true,
  "data": {
    "scanId": "q3k9x0m2b7a1c8de",
    "scanned": 25,
    "found": 3,
    "checked": 140,
    "unchecked": 0,
    "skipped": 2,
    "total": 120,
    "nextOffset": 25
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | No content type is chosen for scanning. `errors.scan_types` holds the message. |

### Check the sitemap again

`POST /link-health/sitemap`

Reads the site's sitemap again now and returns how that went. It decides which visitor 404s are logged in Smart mode.

**Permission:** `link_manager_pro_access`

**Body parameters:** None.

Only one read runs at a time for the site. A call that arrives while another read runs skips the read and returns the current state.

**Response:** `200`. `data` is the `sitemap` object described under [List Link Health items](#list-link-health-items): `mode`, `status` and `listed`.

```json
{
  "success": true,
  "data": {
    "mode": "sitemap",
    "status": { "state": "ok", "checked_at": 1792483500, "url": "https://example.com/wp-sitemap.xml", "count": 118 },
    "listed": true
  }
}
```

### Change a Link Health item's status

`PUT /link-health/{id}/status`

Marks an item active, fixed or ignored. Marking an active item fixed fires the `link_manager_pro_broken_link_fixed` action.

**Permission:** `link_manager_pro_access`

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The Link Health item ID. |

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `status` | string | No | `active` | `active`, `fixed` or `ignored`. |

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

| Field | Type | What it holds |
|---|---|---|
| `id` | integer | The item ID. |
| `status` | string | The new status. |
| `previous_status` | string | The status before the change, so a client can offer Undo. |

```json
{
  "success": true,
  "data": { "id": 17, "status": "fixed", "previous_status": "active" }
}
```

The action [`link_manager_pro_broken_link_fixed`](https://docs.linkmanagerpro.com/developers/hooks/actions#link-manager-pro-broken-link-fixed) fires once, with `resolution` set to `marked_fixed`, when the item was not `fixed` before and is now. Saving a `fixed` item as `fixed` again does not fire it.

**Errors:**

| Status | When |
|---|---|
| `404` | No item has that ID. |
| `422` | `status` is not `active`, `fixed` or `ignored`. `errors.status` holds the message. |

### Fix a broken link with a redirect

`POST /link-health/{id}/redirect`

Creates a short link and a redirect for the broken address, then marks the item fixed.

**Permission:** `link_manager_pro_access`

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The Link Health item ID. |

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `target_url` | string | Yes | none | Where visitors go instead. A full `http` or `https` URL of 2048 bytes or fewer. It cannot be the broken address. `destination_url` is accepted as an older name. |
| `slug` | string | No | generated | The short link's slug, 64 characters or fewer. It must be unused. Empty lets the plugin generate one. |
| `redirect_type` | integer | No | `301` | `301`, `302` or `307`. |
| `link_attributes` | object | No | none | Keys `nofollow`, `noopener`, `noreferrer` and `sponsored`, each `yes`, `no`, `true` or `false`. Other keys are dropped. |

When the broken address is on this site, the short link is hidden from the short link list and counts, and its `source` is `redirect`. When it is on another site, the short link stays visible with `source` `health`. That short link is the fix to put into the post.

A redirect that already exists for the broken address is pointed at the new short link instead of being duplicated.

**Response:** `200`, with the message "Redirect created successfully." `data` holds:

| Field | Type | What it holds |
|---|---|---|
| `redirect` | object | The redirect row: `id`, `source_url` (the broken address), `source_url_hash` (32-character string), `target_link_id` (integer), `status` (`active`), `created_at`, `updated_at`. |
| `link` | object | The new short link, with the fields shown in [Create a short link](https://docs.linkmanagerpro.com/developers/rest-api/links#create-a-short-link). |

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{ "target_url": "https://example.com/new-guide", "redirect_type": 301 }' \
  https://example.com/wp-json/link-manager-pro/v1/link-health/17/redirect
```

```json
{
  "success": true,
  "data": {
    "redirect": {
      "source_url": "https://example.com/old-guide",
      "source_url_hash": "4b5c31ad44d7dec24d24f4e8ff46c481",
      "target_link_id": 61,
      "status": "active",
      "updated_at": "2026-10-23T09:30:00.000",
      "created_at": "2026-10-23T09:30:00.000",
      "id": 5
    },
    "link": {
      "id": 61,
      "slug": "k7d2p9",
      "target_url": "https://example.com/new-guide",
      "redirect_type": 301,
      "source": "redirect",
      "short_url": "https://example.com/link/k7d2p9"
    }
  },
  "message": "Redirect created successfully."
}
```

The `link` object is shortened here to the main fields.

The action [`link_manager_pro_broken_link_fixed`](https://docs.linkmanagerpro.com/developers/hooks/actions#link-manager-pro-broken-link-fixed) fires once, with `resolution` set to `redirect_created`, unless the item was already `fixed`.

**Errors:**

| Status | When |
|---|---|
| `404` | No item has that ID. |
| `422` | `errors.target_url`: empty, over 2048 characters, not an `http` or `https` URL, or the same as the broken address. |
| `422` | `errors.slug`: over 64 characters, already used by another short link, or not made of letters, numbers or dashes. |
| `422` | `errors.redirect_type`: not `301`, `302` or `307`. |
| `422` | `errors.link_attributes`: not an object, or a value other than `yes`, `no`, `true` or `false`. |

### Delete a Link Health item

`DELETE /link-health/{id}`

Removes one item. If the link is still broken, the next scan or visit logs it again.

**Permission:** `link_manager_pro_access`

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The Link Health item ID. |

**Response:** `200`. `data` is `null` and the message is "Link health issue deleted."

```json
{
  "success": true,
  "data": null,
  "message": "Link health issue deleted."
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | No item has that ID. |

### Clear Link Health items

`DELETE /link-health`

Removes every Link Health item of one type, or of both types when `type` is empty, `all` or left out, whatever its status, including fixed and ignored items.

::: warning
This deletes the items for good. Send `type` to clear only one kind.
:::

**Permission:** `link_manager_pro_access`

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `type` | string | No | all | `content` or `traffic`. Empty or `all` removes both. Leaving it out removes both. |

Search and status filters do not apply. Hidden items of unpublished posts stay. The number in `counts.scope.all` of the list is what this removes.

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

| Field | Type | What it holds |
|---|---|---|
| `removed` | integer | How many items were removed. |
| `type` | string | The tab cleared: `content`, `traffic`, or an empty string for both. |

```json
{
  "success": true,
  "data": { "removed": 12, "type": "content" }
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | `type` is not empty, `all`, `content` or `traffic`. `errors.type` holds the message. |

## Link Audit

Link Audit counts how many links point to each post and from it. Each audited post is one row. A post with no incoming internal link is `orphaned`. A post below the minimum incoming or outgoing links is `weak`. The minimums come from the **Link Intelligence** settings: 3 incoming and 2 outgoing by default. Every other post is `healthy`.

### The Link Audit row

| Field | Type | What it holds |
|---|---|---|
| `id` | integer | The row ID. |
| `post_id` | integer | The audited post. |
| `post_type` | string | The post type name, for example `post`. |
| `post_title` | string | The post title when it was last scanned. |
| `inbound_internal` | integer | Posts that link to this post. |
| `outbound_internal` | integer | Internal links in this post. |
| `outbound_external` | integer | External links in this post. |
| `status` | string | `orphaned`, `weak` or `healthy`. |
| `scanned_at` | string or null | When the post was last scanned, `Y-m-d H:i:s`. |
| `created_at` | string | When the row was stored. |
| `updated_at` | string | When the row last changed. |

The list adds two fields. `post_type_label` is the post type's singular label, and `edit_url` is the editor link, or `null` when the user cannot edit the post.

**How a full scan runs.** Call `POST /link-audit/scan` with `offset` `0`. The server keeps the position of the pass. The pass first reads every audited post, then counts the links to each. Call the route again with the `nextOffset` from the last response until `status` is `completed`. A call with any other offset changes nothing and returns the position the server expects. A call with no pass running, or one idle for over an hour, returns `409`.

### List audited posts

`GET /link-audit`

Returns one page of audited posts, fewest incoming links first.

**Permission:** `link_manager_pro_access`

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `search` | string | No | none | Matches part of the post title. |
| `filter` | string | No | none | `orphaned`, `weak` or `healthy`. Any other value is ignored. |
| `sort` | string | No | `inbound_internal` | One of `post_title`, `inbound_internal`, `outbound_internal`, `outbound_external`, `scanned_at`, `status`. Any other value sorts by `inbound_internal`, ascending. |
| `order` | string | No | `asc` | `asc` or `desc`. |
| `page` | integer | No | `1` | The page number. |
| `per_page` | integer | No | `20` | Rows per page, kept between `1` and `100`. |

Only published posts of the audited types are listed.

**Response:** `200`. `data` is a page object ([Pagination](https://docs.linkmanagerpro.com/developers/rest-api/#pagination)) whose `data.data` rows are [Link Audit rows](#the-link-audit-row).

```json
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 8,
        "post_id": 42,
        "post_type": "post",
        "post_title": "Choosing a widget",
        "inbound_internal": 0,
        "outbound_internal": 3,
        "outbound_external": 1,
        "status": "orphaned",
        "scanned_at": "2026-10-20 08:30:00",
        "created_at": "2026-10-18T06:00:00.000",
        "updated_at": "2026-10-20T08:30:00.000",
        "post_type_label": "Post",
        "edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit"
      }
    ],
    "per_page": 20,
    "total": 1
  }
}
```

The page object also holds the other paging fields, shortened here. When the audit table does not exist yet, the route returns an empty page: `data` `[]`, `total` `0`, `per_page` `20`, `current_page` `1`, `last_page` `1`.

### Get the Link Audit totals

`GET /link-audit/stats`

Returns the audit counts, the thresholds behind them and the coverage score.

**Permission:** `link_manager_pro_access`

**Query parameters:** None.

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

| Field | Type | What it holds |
|---|---|---|
| `total` | integer | Audited posts. |
| `healthy` | integer | Posts that are neither orphaned nor weak. |
| `weak` | integer | Posts with some incoming links, but below a minimum. |
| `orphaned` | integer | Posts with no incoming internal link. |
| `avg_inbound` | number | Average incoming links per post, rounded to one decimal. `0` with no rows. |
| `thresholds` | object | `min_inbound` and `min_outbound`, integers. |
| `coverage_score` | integer | The [coverage score](#get-the-coverage-score), 0 to 100. |
| `coverage_grade` | string | The grade letter for the score. |
| `published` | integer | Published posts a full scan covers. |
| `rescan_needed` | boolean | `true` when stored rows were counted under older rules, or the last full scan stopped before the end, and a full scan is due. |
| `rescan_reason` | string | `"unfinished"` after a stopped full scan, otherwise an empty string. |

```json
{
  "success": true,
  "data": {
    "total": 120,
    "healthy": 80,
    "weak": 25,
    "orphaned": 15,
    "avg_inbound": 3.4,
    "thresholds": { "min_inbound": 3, "min_outbound": 2 },
    "coverage_score": 76,
    "coverage_grade": "B",
    "published": 120,
    "rescan_needed": false,
    "rescan_reason": ""
  }
}
```

### List slugs to review

`GET /link-audit/slugs-to-review`

Lists short links whose slug breaks the current slug rules, for you to rename. It changes nothing.

**Permission:** `link_manager_pro_access`

**Query parameters:** None.

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

| Field | Type | What it holds |
|---|---|---|
| `items` | array | Up to 50 short links. Each has `id` (integer), `slug` (string) and `reason` (string, why the slug is flagged). |
| `total` | integer | All short links that break the rules, even when `items` stops at 50. |

```json
{
  "success": true,
  "data": {
    "items": [
      { "id": 12, "slug": "summer/deals", "reason": "This slug can’t be used in a link. Remove \"/\", line breaks and special control characters." }
    ],
    "total": 1
  }
}
```

### Get the coverage score

`GET /link-audit/coverage-score`

Returns a 0 to 100 score for how well the audited posts link to each other, with its weekly history.

**Permission:** `link_manager_pro_access`

**Query parameters:** None.

The score is `100` minus three penalties, never below `0`. Each penalty is the share of audited posts affected times a cap. Orphaned posts take up to 40 points, weak posts up to 30 (orphaned posts count here too), and posts with an open broken link up to 30. The grade is `A` from 90, `B` from 75, `C` from 60, `D` from 40, and `F` below that.

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

| Field | Type | What it holds |
|---|---|---|
| `score` | integer | The score, 0 to 100. |
| `grade` | string | `A`, `B`, `C`, `D` or `F`. |
| `breakdown` | object | `orphan_penalty`, `weak_penalty` and `broken_penalty`, integers: the points each takes off. |
| `suggestions` | array | What to fix first. Each has `type` (`orphaned`, `weak` or `broken`), `message` (string) and `link` (string, a screen path such as `/link-audit?filter=orphaned`). |
| `counts` | object | `orphaned`, `weak`, `total`, `broken` and `broken_posts`, integers. `broken` is open broken links in content. `broken_posts` is audited posts with at least one. |
| `history` | array | Up to 12 weekly snapshots, oldest first. Each has `week` (string, for example `week_2026_42`), `score` (integer), `grade` (string) and `date` (string, `Y-m-d`). |

```json
{
  "success": true,
  "data": {
    "score": 76,
    "grade": "B",
    "breakdown": { "orphan_penalty": 5, "weak_penalty": 13, "broken_penalty": 6 },
    "suggestions": [
      { "type": "orphaned", "message": "Fix 15 orphaned posts to improve your score by up to 12 points.", "link": "/link-audit?filter=orphaned" }
    ],
    "counts": { "orphaned": 15, "weak": 25, "total": 120, "broken": 9, "broken_posts": 7 },
    "history": [
      { "week": "week_2026_42", "score": 74, "grade": "C", "date": "2026-10-19" }
    ]
  }
}
```

### Run a full Link Audit scan

`POST /link-audit/scan`

Reads one batch of a full audit pass and returns where the next batch starts. See how a full scan runs under [Link Audit](#link-audit).

**Permission:** `link_manager_pro_access`

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `offset` | integer | No | `0` | `0` starts a new pass. Later calls send the `nextOffset` of the last response. |
| `limit` | integer | No | `10` | Posts read per batch, kept between `1` and `20`. It does not apply to the counting phase, which handles 500 rows per batch. |

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

| Field | Type | What it holds |
|---|---|---|
| `status` | string | `in_progress` or `completed`. |
| `scanned` | integer | Posts read, or rows counted, in this batch. |
| `offset` | integer | The step the pass is at. Same as `nextOffset`. |
| `nextOffset` | integer | The `offset` to send next. |
| `total` | integer | Total steps: published posts plus audited rows. |
| `has_more` | boolean | `true` until the pass is completed. |
| `hasMore` | boolean | The same value as `has_more`. |
| `time_limited` | boolean | `true` when the 20 second budget stopped the batch early. Keep going from `nextOffset`. |
| `phase` | string | `reading` while posts are read, then `counting`. |
| `phase_done` | integer | Steps done in the current phase. |
| `phase_total` | integer | Steps in the current phase. |

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{ "offset": 0, "limit": 10 }' \
  https://example.com/wp-json/link-manager-pro/v1/link-audit/scan
```

```json
{
  "success": true,
  "data": {
    "status": "in_progress",
    "scanned": 10,
    "offset": 10,
    "nextOffset": 10,
    "total": 240,
    "has_more": true,
    "hasMore": true,
    "time_limited": false,
    "phase": "reading",
    "phase_done": 10,
    "phase_total": 120
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `409` | No pass is running, or the last one was idle for over an hour. The code is `link_audit_scan_expired`. Start again with `offset` `0`. |

### Scan one post for Link Audit

`POST /link-audit/scan/{id}`

Rescans one post and returns its audit row. A post that is no longer published or audited loses its row.

**Permission:** `link_manager_pro_access`

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The post ID. |

**Body parameters:** None.

**Response:** `200`, with the message "Post scanned successfully." `data.audit` is the [Link Audit row](#the-link-audit-row), without the two list-only fields.

```json
{
  "success": true,
  "data": {
    "audit": {
      "id": 8,
      "post_id": 42,
      "post_type": "post",
      "post_title": "Choosing a widget",
      "inbound_internal": 2,
      "outbound_internal": 3,
      "outbound_external": 1,
      "status": "weak",
      "scanned_at": "2026-10-23 09:40:00",
      "created_at": "2026-10-18T06:00:00.000",
      "updated_at": "2026-10-23T09:40:00.000"
    }
  },
  "message": "Post scanned successfully."
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | The post does not exist, is not published, or is not a type Link Audit covers. The code is `post_not_found`. |

### Get link suggestions for a post

`GET /link-audit/{id}/suggestions`

Suggests other posts that could link to this post, each with the text to link.

**Permission:** `link_manager_pro_access`

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The post that should receive the links. |

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `limit` | integer | No | `10` | Suggestions to return, kept between `1` and `20`. |

The route matches words from this post's title against the title and text of up to 300 published posts that contain a title keyword. Words match as whole words. A missing post, or a title with no usable words, returns an empty array.

**Response:** `200`. `data` is an array, best match first. Each item holds:

| Field | Type | What it holds |
|---|---|---|
| `source_post_id` | integer | The post that would carry the link. |
| `source_title` | string | Its title. |
| `post_type` | string | Its post type. |
| `score` | integer | The raw match score: for each shared title word, its title hits times 3 plus its text hits (at most 3), added up. |
| `anchor_text` | string | Text found in that post that can become the link. |
| `relevance` | integer | The score as a percentage of the best score, 0 to 100. |

```json
{
  "success": true,
  "data": [
    {
      "source_post_id": 15,
      "source_title": "Widget buying guide",
      "post_type": "post",
      "score": 7,
      "anchor_text": "choosing a widget",
      "relevance": 100
    }
  ]
}
```

### Insert a link into a post

`POST /link-audit/insert-link`

Turns the first plain mention of the anchor text in one post into a link to another post.

**Permission:** `link_manager_pro_access`. The signed-in user also needs `edit_post` on the source post.

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `source_post_id` | integer | Yes | none | The post to edit. |
| `target_post_id` | integer | Yes | none | The post to link to. The link uses its permalink. |
| `anchor_text` | string | Yes | none | Text that appears in the source post and is not yet a link. Trimmed. |

The change touches only that one link. Both posts are rescanned for Link Audit afterward.

**Response:** `200`, with the message "Link inserted successfully." `data` is `null`.

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{ "source_post_id": 15, "target_post_id": 42, "anchor_text": "choosing a widget" }' \
  https://example.com/wp-json/link-manager-pro/v1/link-audit/insert-link
```

```json
{
  "success": true,
  "data": null,
  "message": "Link inserted successfully."
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | `anchor_text` is empty, or the text is not in the post any more, or is already a link. `errors.anchor_text` holds the message. |
| `422` | `source_post_id` or `target_post_id` is missing. The error is keyed to the missing field. |
| `422` | Saving would also change more than the content, or WordPress would strip content on save, or the post changed during the save. `errors.anchor_text` says which. |
| `404` | A post does not exist. The code is `lmp_post_not_found`. |
| `403` | The user cannot edit the source post. The code is `lmp_forbidden`. |
| `500` | WordPress could not save the post. The code is `lmp_save_failed`. |

### Get the editor optimization for a post

`GET /editor/optimization/{id}`

Counts a post's internal links and compares them with a recommended number, for the editor panel.

**Permission:** `edit_posts` (or the capability the `link_manager_pro_picker_capability` filter returns) or `link_manager_pro_access`. The user must also be able to edit this post (`edit_post`).

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `id` | integer | The post ID. |

**Query parameters:** None.

The recommendation is one internal link per N characters of text, where N is the `optimization_chars_per_link` setting (default `500`, at least `1`). The `optimization_delta` setting (default `1`) is the allowed difference. Your own short links count by where they lead.

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

| Field | Type | What it holds |
|---|---|---|
| `text_length` | integer | Characters of text in the post, without tags. |
| `internal_links` | integer | Internal links in the post. |
| `recommended` | integer | The text length divided by `chars_per_link`, rounded down, and at least `1` when the post has text. `0` when it has none. |
| `delta` | integer | The allowed difference. |
| `chars_per_link` | integer | The characters per link used. |
| `status` | string | `low` below `recommended - delta`, `high` above `recommended + delta` (never for a post with no text), else `optimal`. |

```json
{
  "success": true,
  "data": {
    "text_length": 2400,
    "internal_links": 2,
    "recommended": 4,
    "delta": 1,
    "chars_per_link": 500,
    "status": "low"
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `403` | The user cannot edit this post. The code is `lmp_forbidden`. |
| `404` | The post does not exist. The code is `lmp_post_not_found`. |

## Link Equity

Link Equity spreads a fixed budget of equity over the outbound links of each published post, with earlier links getting more. It adds up the result per address and per domain. The report is stored when a scan finishes. The list routes read the stored report and never recompute it.

**How a scan runs.** Call `POST /link-equity/scan` with `offset` `0`, which clears any scan in progress. Call it again with the `nextOffset` of each response until `status` is `completed`. Send the batches in order. The stored report is replaced only when the last batch completes.

### List Link Equity rows

`GET /link-equity`

Returns one page of the stored report, highest equity first.

**Permission:** `link_manager_pro_access`

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `basis` | string | No | `url` | `url` or `domain`. Any other value counts as `url`. |
| `scope` | string | No | `all` | `all`, `internal`, `external` or `affiliate`. Any other value counts as `all`. |
| `search` | string | No | none | Matches part of the address, or of the domain when `basis` is `domain`. |
| `page` | integer | No | `1` | The page number. |
| `per_page` | integer | No | `10` | Rows per page, kept between `1` and `50`. |

The report keeps the 500 addresses with the most equity.

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

| Field | Type | What it holds |
|---|---|---|
| `basis` | string | The basis used. |
| `scope` | string | The scope used. |
| `summary` | object | The totals, fields under [Get the Link Equity totals](#get-the-link-equity-totals). |
| `rows` | array | The rows of this page, fields below. |
| `total` | integer | Rows after filtering, across all pages. |
| `page` | integer | The page returned. |
| `per_page` | integer | Rows per page. |
| `max_equity` | number | The highest equity in the filtered list, rounded to one decimal. Use it as the scale for a bar. |

Each row holds:

| Field | Type | What it holds |
|---|---|---|
| `label` | string | The address, or the domain. Internal addresses show as a path such as `/pricing`. |
| `count` | integer | How many links point to it. |
| `equity` | number | The equity it receives, rounded to one decimal. |
| `internal` | boolean | `true` when it leads to this site. |
| `clicks` | integer | Recorded clicks on short links that lead there. |
| `affiliate` | boolean | `true` when the host matches one of your affiliates. |
| `type` | string | `affiliate`, `internal` or `external`. An affiliate row is never `internal` or `external`. |

```json
{
  "success": true,
  "data": {
    "basis": "url",
    "scope": "all",
    "summary": {
      "generated_at": "2026-10-23 08:00:00",
      "posts_scanned": 120,
      "total_links": 940,
      "internal_links": 610,
      "external_links": 330,
      "distinct_urls": 410,
      "distinct_domains": 38,
      "urls_listed": 410
    },
    "rows": [
      { "label": "/pricing", "count": 42, "equity": 380.5, "internal": true, "clicks": 0, "affiliate": false, "type": "internal" }
    ],
    "total": 410,
    "page": 1,
    "per_page": 10,
    "max_equity": 380.5
  }
}
```

### Get the Link Equity totals

`GET /link-equity/stats`

Returns the totals of the stored report.

**Permission:** `link_manager_pro_access`

**Query parameters:** None.

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

| Field | Type | What it holds |
|---|---|---|
| `generated_at` | string or null | When the report was made, UTC `Y-m-d H:i:s`. `null` before the first scan. |
| `posts_scanned` | integer | Posts in the report. |
| `total_links` | integer | Links counted. |
| `internal_links` | integer | Links that lead to this site. |
| `external_links` | integer | Links that lead elsewhere. |
| `distinct_urls` | integer | Different addresses, before the cut to 500. |
| `distinct_domains` | integer | Different domains. |
| `urls_listed` | integer | Addresses kept in the By URL list, at most 500. |

A report made under older counting rules is not shown. Every field is then `0`, and `generated_at` is `null`.

```json
{
  "success": true,
  "data": {
    "generated_at": "2026-10-23 08:00:00",
    "posts_scanned": 120,
    "total_links": 940,
    "internal_links": 610,
    "external_links": 330,
    "distinct_urls": 410,
    "distinct_domains": 38,
    "urls_listed": 410
  }
}
```

### Run a Link Equity scan

`POST /link-equity/scan`

Adds one batch of published posts to the report and returns where the next batch starts.

**Permission:** `link_manager_pro_access`

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `offset` | integer | No | `0` | Posts to skip. `0` starts a new scan. |
| `limit` | integer | No | `50` | Posts per batch, kept between `1` and `100`. |

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

| Field | Type | What it holds |
|---|---|---|
| `status` | string | `in_progress` or `completed`. |
| `scanned` | integer | Posts scanned in this batch. |
| `offset` | integer | Where the next batch starts. Same as `nextOffset`. |
| `nextOffset` | integer | The `offset` to send next. |
| `total` | integer | Published posts of the scanned types. |
| `has_more` | boolean | `true` while posts remain. |
| `hasMore` | boolean | The same value as `has_more`. |
| `time_limited` | boolean | `true` when the 20 second budget stopped the batch early. Keep going from `nextOffset`. |

```json
{
  "success": true,
  "data": {
    "status": "in_progress",
    "scanned": 50,
    "offset": 50,
    "nextOffset": 50,
    "total": 120,
    "has_more": true,
    "hasMore": true,
    "time_limited": false
  }
}
```

## Content scan

The content scan is the **Scan** that masks external links. It rewrites links in saved posts into short links, so it edits your content. It covers published, scheduled and private posts of the configured post types, minus the excluded types. Drafts are left alone. A rewritten link to an affiliate's domain gets its short address under the **Affiliate link prefix** when that setting is set. Other links use the **Short link prefix**, or the internal prefix for your own site. See [General settings](https://docs.linkmanagerpro.com/guides/settings/general#affiliate-link-prefix).

**How a scan runs.** Call `POST /scan` with `offset` `0`. Call it again with the `nextOffset` of each response until `status` is `completed`. A batch can end early when its 20 second budget runs out, so always continue from `nextOffset`. Call `GET /scan/preview` first to show how many posts the scan will check.

### Start or continue a content scan

`POST /scan`

Masks the external links of one batch of posts and returns the tallies and where the next batch starts.

**Permission:** `link_manager_pro_access`. A post the signed-in user cannot edit is skipped and counted in `postsSkipped`.

**Body parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `offset` | integer | No | `0` | Posts to skip. A negative number counts as `0`. |
| `limit` | integer | No | `20` | Posts per batch, at most `100`. |

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

| Field | Type | What it holds |
|---|---|---|
| `status` | string | `in_progress` or `completed`. |
| `processed` | integer | Posts passed so far in the whole scan. Same as `nextOffset`. |
| `nextOffset` | integer | The `offset` to send next. |
| `total` | integer | Posts the scan checks. |
| `hasMore` | boolean | `true` while posts remain. |
| `time_limited` | boolean | `true` when the 20 second budget stopped this batch early. |
| `linksCreated` | integer | New short links made in this batch, including ones restored for an existing post. |
| `linksConverted` | integer | Links rewritten into short links in this batch, new or reused. |
| `postsConverted` | integer | Posts where at least one link was converted. |
| `postsUpdated` | integer | Posts saved. |
| `postsSkipped` | integer | Posts left alone: the user cannot edit them, or WordPress would strip their content. |
| `postsFailed` | integer | Posts that could not be masked or saved. |
| `linksKeptForBlocks` | integer | Links left as written because their block keeps the address. |

The tallies cover the batch you just ran, not the whole scan. Add them across batches if you need totals.

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{ "offset": 0, "limit": 20 }' \
  https://example.com/wp-json/link-manager-pro/v1/scan
```

```json
{
  "success": true,
  "data": {
    "status": "in_progress",
    "processed": 20,
    "nextOffset": 20,
    "total": 120,
    "hasMore": true,
    "time_limited": false,
    "linksCreated": 14,
    "linksConverted": 18,
    "postsConverted": 9,
    "postsUpdated": 9,
    "postsSkipped": 1,
    "postsFailed": 0,
    "linksKeptForBlocks": 2
  }
}
```

### Preview a content scan

`GET /scan/preview`

Returns how many posts a content scan will check, by status. It reads only.

**Permission:** `link_manager_pro_access`

**Query parameters:** None.

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

| Field | Type | What it holds |
|---|---|---|
| `total` | integer | Posts the scan checks. |
| `by_status` | object | `publish`, `future` (scheduled) and `private`, integers. |

```json
{
  "success": true,
  "data": {
    "total": 124,
    "by_status": { "publish": 120, "future": 1, "private": 3 }
  }
}
```

## Related

- [REST API overview](https://docs.linkmanagerpro.com/developers/rest-api/)
- [Short links endpoints](https://docs.linkmanagerpro.com/developers/rest-api/links)
- [Action hooks](https://docs.linkmanagerpro.com/developers/hooks/actions)
- [Events, webhooks and AutomatorWP](https://docs.linkmanagerpro.com/developers/hooks/events)
- [Link Health](https://docs.linkmanagerpro.com/features/health-monitoring)
