Appearance
Editor, AI and setup endpoints
These routes serve the editor sidebar, the setup wizard and the support drawer. They also include the public internal click route and the two WordPress search types that list short links and affiliates.
Sign-in, the envelope and errors are in the REST API overview.
Paths are relative to /wp-json/link-manager-pro/v1, except the search routes, which belong to WordPress core. These routes serve the plugin's own screens, so their fields can change.
Every short URL these routes return follows the prefix rules. A link to one of your affiliates' domains gets the Affiliate link prefix when one is set, for example https://example.com/recommends/amazon-deal. A link to your own site follows Mask internal links and the Internal link prefix. All others use the Short link prefix. See General settings.
AI Link Assistant
The four /ai routes serve the sidebar that finds plain URLs in a post and suggests internal links. They use the permission level that allows edit_posts (the capability the link_manager_pro_picker_capability filter returns) or link_manager_pro_access (Permissions).
detect-urls, dismiss-url and analyze also check edit_post on the post you name. A user who cannot edit that post gets 403 with the code lmp_ai_post_forbidden. A missing or zero post_id skips that check, so an unsaved post works.
Find plain URLs in post content
POST /ai/detect-urls
Lists the web addresses in the content that are not yet short links, so the sidebar can offer to convert them.
Permission: edit_posts or link_manager_pro_access, and edit_post on post_id when it is set (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
content | string | No | empty | The post content, as HTML or text. The route reads href links and bare http and https addresses. |
post_id | integer | No | 0 | The post being edited. Addresses you dismissed for this post are left out. |
The route skips addresses of this site's own short links, core/embed blocks themselves (nested ones included), so the same address as a plain link elsewhere in the post is still returned, and addresses under its /wp-admin/, /wp-content/, /wp-includes/ and /wp-json/ paths. It returns at most 50 addresses, in the order found.
Response: 200. data holds:
| Field | Type | What it holds |
|---|---|---|
enabled | boolean | false when the setting ai.enable_untracked_detection is off. It is on by default. Then urls is empty. |
urls | array | One object per address, fields below. |
Each object in urls:
| Field | Type | What it holds |
|---|---|---|
url | string | The address. |
is_internal | boolean | true for an address on this site (the www. prefix is ignored), false for any other host. |
bash
curl -X POST \
-u USER:APPLICATION_PASSWORD \
-H "Content-Type: application/json" \
-d '{"post_id":123,"content":"<p>See <a href=\"https://example.net/deal\">this deal</a>.</p>"}' \
"https://example.com/wp-json/link-manager-pro/v1/ai/detect-urls"json
{
"success": true,
"data": {
"enabled": true,
"urls": [
{ "url": "https://example.net/deal", "is_internal": false }
]
}
}Errors:
| Status | When |
|---|---|
403 | The user cannot edit the post in post_id. The code is lmp_ai_post_forbidden. |
Convert a URL to a short link
POST /ai/convert-url
Creates a short link for one address, or returns the one that already exists for it.
Permission: edit_posts or link_manager_pro_access (Permissions). This route has no per-post check. To create a new short link, a caller without full access also needs the create right: publish_posts by default, or the capability the link_manager_pro_picker_create_capability filter returns.
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
url | string | Yes | none | The destination. It must be a valid http or https address, at most 2048 characters. |
The new link is stored with the source ai. When a short link for the address exists, the route returns that link and creates nothing. Without the create right and with no existing link, the route returns 403 with the code lmp_cannot_create_links and the message "Your role can insert existing short links but can’t create new ones. Ask an editor or administrator."
Response: 200. data holds only the fields an editor may see:
| Field | Type | What it holds |
|---|---|---|
link | object | The link: id (integer) and slug (string). |
masked_url | string | The public short URL to put in the post, under the prefix that fits the destination. |
json
{
"success": true,
"data": {
"link": { "id": 42, "slug": "blue-widget" },
"masked_url": "https://example.com/link/blue-widget"
}
}Errors:
| Status | When |
|---|---|
422 | url is missing, not a valid address, over 2048 characters, or not http or https (the message is That is not a valid http or https URL.). The link could not be created. The body lists the field url. |
Dismiss a URL for a post
POST /ai/dismiss-url
Remembers that you do not want one address offered again for one post.
Permission: edit_posts or link_manager_pro_access, and edit_post on post_id (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
url | string | Yes | none | The address to hide. Case, a trailing slash and a default port do not matter. |
post_id | integer | Yes | 0 | The post. It must be above 0. |
The plugin keeps the last 200 dismissed addresses per post in the post meta key _lmp_ai_dismissed_urls.
Response: 200. data holds:
| Field | Type | What it holds |
|---|---|---|
dismissed | boolean | Always true. |
Errors:
| Status | When |
|---|---|
422 | url is empty, or post_id is 0 or less. The message is A URL and post ID are required. |
403 | The user cannot edit the post. The code is lmp_ai_post_forbidden. |
Suggest internal links
POST /ai/analyze
Asks the configured AI provider which phrases in the content should link to which of your short links.
Permission: edit_posts or link_manager_pro_access, and edit_post on post_id when it is set (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
content | string | Yes | empty | The post content. Tags are removed and the text is cut to 8000 characters. Content that is empty once tags are removed returns empty, described below. |
post_id | integer | No | 0 | The post. Used for the permission check only. |
selection | string | No | empty | Text the user selected. Suggestions that match it rank first. Leave it empty to analyze the whole post. |
The route works in this order. Each early stop is a 200 answer with a plain reason, not an error status:
- The setting
ai.enable_suggestionsis off (the default):datais{ "enabled": false }. - The AI Link Assistant is not available on this site:
{ "available": false, "reason": "entitlement" }. - No AI provider is set up:
{ "available": false, "reason": "provider" }. - The content is empty once tags are removed:
{ "available": true, "enabled": true, "suggestions": [], "empty": true }. Nothing is cached and no rate-limit slot is used. - The same content, selection, token budget, provider and model was analyzed in the last hour: the cached result, with
cachedset totrue. - No short link matches the content: the answer has no suggestions and
total_candidatesis0. The plugin makes no provider call and uses no rate-limit slot. It caches this answer for one hour. - Otherwise the plugin checks the rate limit, calls the provider, and caches the result for one hour.
The candidates are live, public short links that match the main words of the content. The plugin sends up to 50 of them to the provider and returns at most 8 suggestions, best first.
Response: 200. A successful analysis holds:
| Field | Type | What it holds |
|---|---|---|
available | boolean | true. |
enabled | boolean | true. |
suggestions | array | Up to 8 objects, fields below. |
total_candidates | integer | How many short links matched and were offered to the provider. |
cached | boolean | true when served from the one-hour cache. |
empty | boolean | Only on the empty-content answer, where it is true and suggestions is empty. |
Each suggestion:
| Field | Type | What it holds |
|---|---|---|
link_id | integer | The short link ID. |
anchor | string | A phrase copied from the content. The route keeps only phrases that appear in it. |
slug | string | The link's slug. |
target_url | string | The link's destination. |
masked_url | string | The public short URL. |
click_count | integer | The link's click total. Left out for users without link_manager_pro_access. |
best_match | boolean | true for the first suggestion only. |
When the provider fails or its answer cannot be read, data is { "available": true, "error": "..." } with a plain message and no suggestions.
json
{
"success": true,
"data": {
"available": true,
"enabled": true,
"suggestions": [
{
"link_id": 42,
"anchor": "blue widget",
"slug": "blue-widget",
"target_url": "https://example.com/products/blue-widget",
"masked_url": "https://example.com/link/blue-widget",
"click_count": 128,
"best_match": true
}
],
"total_candidates": 6,
"cached": false
}
}Errors:
| Status | When |
|---|---|
403 | The user cannot edit the post in post_id (lmp_ai_post_forbidden). |
429 | The user made too many uncached requests. The code is lmp_ai_rate_limited. |
A cached answer does not count toward the limit, and neither does a call with no matching short link. The limit is per user: by default 30 requests in 300 seconds, set by the settings ai.rate_limit_max (0 to 100000) and ai.rate_limit_window (1 to 86400 seconds). The link_manager_pro_ai_rate_max filter can change the limit. A limit of 0 turns it off.
Setup
The setup wizard keeps one state object in the option link_manager_pro_onboarding. Four steps make it up: welcome, settings, privacy and scan. Each is pending or complete. When all four are complete, status becomes complete.
The state object that these routes return:
| Field | Type | What it holds |
|---|---|---|
status | string | pending, complete or skipped. |
step | integer | The current step, 0 to 3. |
steps | object | welcome, settings, privacy and scan, each pending or complete. |
updated_at | string | When the state was last saved, as an ISO 8601 date in UTC. |
completed_at | string | Only after POST /onboarding/complete. |
skipped_at | string | Only after POST /onboarding/skip. |
The first four routes below need link_manager_pro_access. settings, complete and skip need an administrator (manage_options).
Get the setup state
GET /onboarding
Returns the setup state and the settings the wizard shows.
Permission: link_manager_pro_access (Permissions)
Query parameters: None.
Response: 200. data is the state object above plus:
| Field | Type | What it holds |
|---|---|---|
settings | object | The groups general, tracking and redirection. An administrator gets every key of each group. Other users get only general.link_prefix, redirection.redirect_type, and tracking.track_clicks, tracking.enable_bot_detection and tracking.anonymize_ips. |
json
{
"success": true,
"data": {
"status": "pending",
"step": 0,
"steps": { "welcome": "pending", "settings": "pending", "privacy": "pending", "scan": "pending" },
"updated_at": "2026-10-23T09:00:00+00:00",
"settings": {
"general": { "link_prefix": "link" },
"tracking": { "track_clicks": "yes", "enable_bot_detection": "yes", "anonymize_ips": "yes" },
"redirection": { "redirect_type": 301 }
}
}
}The settings values above are examples. Each group holds more keys for an administrator.
Reset the setup
POST /onboarding/status
Starts setup over. It replaces the state with a fresh pending one. Saved settings stay.
Permission: link_manager_pro_access (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
status | string | Yes | empty | Must be pending. |
Response: 200. data is the fresh state object, with status pending, step 0 and every step pending.
Errors:
| Status | When |
|---|---|
422 | status is anything but pending. The body lists the field status. |
Mark a step complete
POST /onboarding/step
Marks one step complete and moves step to it.
Permission: link_manager_pro_access (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
step | integer | Yes | none | The step number, 0 to 3: 0 welcome, 1 settings, 2 privacy, 3 scan. |
state | string | Yes | empty | Must be complete. |
Response: 200. data is the updated state object. When all four steps are complete, status is complete.
Errors:
| Status | When |
|---|---|
422 | step is not a number from 0 to 3 (field step), or state is not complete (field state). |
Scan posts in chunks
POST /onboarding/scan
Turns the external links of one chunk of posts into short links. It saves those posts.
This route changes post content. It edits published, scheduled and private posts, and leaves drafts alone. Call it again with the new offset until hasMore is false. A chunk stops after about 20 seconds of work.
Permission: link_manager_pro_access (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
offset | integer | No | 0 | How many posts to skip. Use nextOffset from the last answer. A negative value counts as 0. |
limit | integer | No | 20 | Posts per chunk. A value above 100 counts as 100. |
Response: 200. data holds:
| Field | Type | What it holds |
|---|---|---|
status | string | in_progress or completed. |
processed | integer | Posts scanned so far, counting from the first post. |
nextOffset | integer | The offset for the next call. |
total | integer | Posts the scan covers. |
hasMore | boolean | true when another call is needed. |
time_limited | boolean | true when the 20 second budget stopped the chunk early. |
linksCreated | integer | New short links created in this chunk. |
linksConverted | integer | Links rewritten into short links in this chunk. |
postsConverted | integer | Posts where at least one link was converted. |
postsUpdated | integer | Posts saved. |
postsSkipped | integer | Posts left as they were, for example when WordPress would not allow the save. |
postsFailed | integer | Posts that could not be processed. |
linksKeptForBlocks | integer | Links left as written because their block keeps the address. |
When hasMore is false, the scan step is marked complete. When all four steps are complete, status of the setup becomes complete.
json
{
"success": true,
"data": {
"status": "in_progress",
"processed": 20,
"nextOffset": 20,
"total": 140,
"hasMore": true,
"time_limited": false,
"linksCreated": 12,
"linksConverted": 15,
"postsConverted": 6,
"postsUpdated": 6,
"postsSkipped": 0,
"postsFailed": 0,
"linksKeptForBlocks": 1
}
}Save the setup settings
POST /onboarding/settings
Saves the General, Tracking and Redirection settings from the wizard.
Permission: manage_options (Permissions)
Body parameters: (JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
general | object | No | none | Keys of the general settings group, such as link_prefix. A group sent empty or left out is not saved. |
tracking | object | No | none | Keys of the tracking group. |
redirection | object | No | none | Keys of the redirection group. |
Each group follows the same schema as its settings screen. Values are sanitized, and keys the group does not define are dropped. affiliate_prefix is optional and empty by default. The prefix rules for link_prefix, internal_prefix and affiliate_prefix run first. A broken prefix rejects the whole request and saves nothing. The wizard pre-fills affiliate_prefix from affiliate_prefix_suggestion of Detect other plugins.
Saving general or redirection marks the settings step complete. Saving tracking marks privacy complete.
Response: 200. data holds:
| Field | Type | What it holds |
|---|---|---|
state | object | The updated state object. |
warnings | object | Prefix warnings keyed general.<key>, each a plain sentence. Empty when none. |
json
{
"success": true,
"data": {
"state": {
"status": "pending",
"step": 1,
"steps": { "welcome": "pending", "settings": "complete", "privacy": "pending", "scan": "pending" },
"updated_at": "2026-10-23T09:05:00+00:00"
},
"warnings": {}
}
}Errors:
| Status | When |
|---|---|
422 | A submitted prefix breaks a rule. The body lists each field as general.link_prefix, general.internal_prefix or general.affiliate_prefix. |
Finish the setup
POST /onboarding/complete
Closes setup for the site, whichever steps were used.
Permission: manage_options (Permissions)
Body parameters: None.
Response: 200. data is the state object with status complete, step 3, the scan step complete and completed_at set.
Skip the setup
POST /onboarding/skip
Closes setup without finishing it. Saved settings stay, and defaults cover the rest.
Permission: manage_options (Permissions)
Body parameters: None.
Response: 200. data is the state object with status skipped and skipped_at set.
System and terms
Get system information
GET /system-info
Returns the support details the Help drawer shows: environment, themes and plugins. It holds no secrets and no personal data.
Permission: manage_options (Permissions)
Query parameters: None.
Response: 200. data holds three objects.
environment, all values strings:
| Field | Type | What it holds |
|---|---|---|
plugin_version | string | The Link Manager Pro version. |
license_state | string | The license state, inactive when none is known. |
wordpress_version | string | The WordPress version. |
php_version | string | The PHP version. |
mysql_version | string | The database server version, or an empty string. |
memory_limit | string | The WordPress memory limit, such as 256M. |
max_upload_size | string | The largest upload WordPress takes, such as 64 MB. |
server_software | string | The web server name from SERVER_SOFTWARE, or an empty string. |
multisite | string | Yes or No. |
locale | string | The site locale, such as en_US. |
themes:
| Field | Type | What it holds |
|---|---|---|
active | string | The active theme's name and version. |
total | integer | Installed themes. |
items | array | Objects with name (string), version (string) and active (boolean). The active theme comes first, then the rest by name. |
plugins:
| Field | Type | What it holds |
|---|---|---|
active_count | integer | Active plugins. On multisite this includes network-active ones. |
total | integer | Installed plugins. |
items | array | Objects with name (string), version (string) and active (boolean). Active plugins come first, then the rest by name. |
json
{
"success": true,
"data": {
"environment": {
"plugin_version": "1.3.0",
"license_state": "active",
"wordpress_version": "6.8",
"php_version": "8.2.12",
"mysql_version": "8.0.36",
"memory_limit": "256M",
"max_upload_size": "64 MB",
"server_software": "nginx/1.25.3",
"multisite": "No",
"locale": "en_US"
},
"themes": {
"active": "Twenty Twenty-Five 1.0",
"total": 2,
"items": [ { "name": "Twenty Twenty-Five", "version": "1.0", "active": true } ]
},
"plugins": {
"active_count": 3,
"total": 4,
"items": [ { "name": "Link Manager Pro", "version": "1.3.0", "active": true } ]
}
}
}List post categories and tags
GET /wp-terms
Lists WordPress post categories and tags. The auto-link targeting picker uses it.
Permission: link_manager_pro_access (Permissions)
Query parameters: None.
Response: 200. data is an array, not a page. It holds up to 500 categories, then up to 500 tags, each group ordered by name. Empty terms are included.
| Field | Type | What it holds |
|---|---|---|
id | integer | The term ID. |
name | string | The term name. |
taxonomy | string | category or post_tag. |
group | string | The group label, Categories or Tags, translated. |
json
{
"success": true,
"data": [
{ "id": 3, "name": "Guides", "taxonomy": "category", "group": "Categories" },
{ "id": 9, "name": "Reviews", "taxonomy": "post_tag", "group": "Tags" }
]
}Internal click tracking
Record an internal click
POST /track/internal-click
Records one click on an ordinary link inside a post, from a visitor's browser. It is public and answers { "ok": true } even when it records nothing.
Permission: none. The route checks its own guards, below (Permissions).
The route runs these checks in this order:
- The settings
tracking.track_clicksandtracking.track_internal_clicksmust both beyes. The first is on by default, the second is off. When either is off, the route answers{ "ok": true }and stops. - A
source_post_idof 0 or less gives403with the codeinvalid_nonce. For a logged-in visitor,lmp_noncemust also be a valid nonce for this post, created for the actionlmp_internal_click_{source_post_id}; a missing or wrong one gives the same403. A logged-out visitor sends no nonce. - The request must come from this site. The route reads the
Originheader, orRefererwhen there is noOrigin. Its host must equal the site host, ignoring a leadingwww.. A missing header or another host gives403with the codeinvalid_origin. - One visitor address may send 30 requests per minute for one post. Past that, the route answers
{ "ok": true }and records nothing. - An empty
target_urlis ignored.
After the guards, the plugin still skips the click when the visitor is a filtered bot, the address is not on this site, the address is one of this site's own short links or a link's other address that opens its link, or the source post is not published.
The front-end script gets the nonce from the page. On a single post or page of an enabled post type, the plugin loads lmp-tracker.js and sets the object lmpTrackerConfig with endpoint, postId, siteHost, homePath, selector and maskedPrefixes. Only for a logged-in visitor it also holds restNonce and nonce; the nonce value is the post-scoped nonce.
Body parameters: (form data, as navigator.sendBeacon sends it, or JSON)
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
source_post_id | integer | Yes | 0 | The post that holds the link. |
target_url | string | Yes | empty | The clicked address, as the browser resolved it. |
anchor_text | string | No | empty | The link text. The script sends up to 500 characters. |
lmp_nonce | string | Logged-in visitors only | none | The post-scoped nonce from lmpTrackerConfig.nonce. |
_wpnonce | string | No | none | The wp_rest nonce from lmpTrackerConfig.restNonce. Only for a logged-in visitor, and WordPress core checks it. |
The script sends a click only for links on this site inside the content selector (default .entry-content), skips links under this site's own prefixes (link, internal and affiliate) and the previous prefixes the router still answers, and skips links with the attribute data-lmp-notrack.
Response: 200. data holds:
| Field | Type | What it holds |
|---|---|---|
ok | boolean | Always true. |
json
{
"success": true,
"data": { "ok": true }
}Errors:
| Status | When |
|---|---|
403 | The nonce of a logged-in visitor is missing or wrong (invalid_nonce), or the origin is not this site (invalid_origin). |
WordPress search
The block editor and the classic link dialog find short links and affiliates through the WordPress core search route, GET /wp-json/wp/v2/search. The plugin adds two values for its type parameter. These are core routes, so core's rules apply: the route is public, and the results come back as a plain JSON array without the Link Manager Pro envelope.
Both types return nothing, an empty array, unless the visitor is signed in and has the capability the link_manager_pro_picker_capability filter returns (edit_posts by default). The link_manager_pro_picker_enabled filter can turn them off. Search is matched on the slug and keywords. A link's destination matches only for links without a password.
Search short links
GET /wp-json/wp/v2/search?type=link-manager-pro
Lists short links for the editor's link picker, newest first.
Permission: a signed-in user who can edit_posts, by default. The route is open to every visitor, and it returns an empty list to anyone else.
Query parameters:
| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
type | string | Yes | none | link-manager-pro. |
search | string | No | empty | Matches the slug or a keyword. With no password, it matches the destination too. Empty returns every eligible link. |
page | integer | No | 1 | The page number. |
per_page | integer | No | 10 | Results per page. Core's default. |
The route leaves out links that back a Simple Redirect, and expired links by default (the link_manager_pro_picker_excluded_statuses filter changes that).
Response: 200. A JSON array. The headers X-WP-Total and X-WP-TotalPages give the counts. Each item:
| Field | Type | What it holds |
|---|---|---|
id | integer | The short link ID. |
title | string | The slug. |
url | string | The public short URL. |
type | string | link-manager-pro. |
bash
curl -u USER:APPLICATION_PASSWORD \
"https://example.com/wp-json/wp/v2/search?type=link-manager-pro&search=widget&per_page=5"json
[
{
"id": 42,
"title": "blue-widget",
"url": "https://example.com/link/blue-widget",
"type": "link-manager-pro"
}
]Search affiliates
GET /wp-json/wp/v2/search?type=link-manager-pro-affiliate
Lists affiliates for the editor's link picker, newest first.
Permission: the same as short link search. It also returns nothing when the link_manager_pro_picker_include_affiliates filter returns false.
Query parameters: the same as Search short links, with type set to link-manager-pro-affiliate. search matches an affiliate the way the Affiliates list does.
Response: 200. A JSON array with the same headers. Each item:
| Field | Type | What it holds |
|---|---|---|
id | integer | The affiliate ID. |
title | string | The site name, else the domain, else the affiliate link. |
url | string | The tracked short URL for the affiliate. An affiliate with no link returns an empty string. |
type | string | link-manager-pro-affiliate. |
The url is always the tracked short link, never the raw affiliate address. When the affiliate has no short link yet, the route creates one while it builds the result, but only for a user who can create short links. For a user who can't, that affiliate is left out of the results.
json
[
{
"id": 7,
"title": "Example Shop",
"url": "https://example.com/link/example-shop",
"type": "link-manager-pro-affiliate"
}
]The link_manager_pro_picker_item filter can change each item before it is returned.