# Campaigns and UTM endpoints

These REST routes manage campaigns and report on the clicks of the short links tagged with a campaign value. They also store reusable UTM templates. Each entry lists the parameters, the response fields and the errors.
Page: https://docs.linkmanagerpro.com/developers/rest-api/campaigns
Use when someone asks: link manager pro campaigns api; utm campaign report api; utm templates api
Intent: Reference

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

All paths are relative to the base URL `/wp-json/link-manager-pro/v1`. Examples use `https://example.com` and an Application Password placeholder (`USER:APPLICATION_PASSWORD`). Inside WordPress, send the `X-WP-Nonce` header instead. Every route on this page needs `link_manager_pro_access` ([Permissions](https://docs.linkmanagerpro.com/developers/rest-api/#permissions)).

## What a campaign is

A campaign is one `utm_campaign` value. Two things can make a campaign exist:

- **Stored campaign.** A row in the `link_manager_pro_meta` table with `entity_type` `campaign`, `entity_id` `0` and the campaign value as `meta_key`. The row keeps a name, a description and default UTM values. The API calls it `managed: true`.
- **Campaign value found on links.** Any short link whose saved UTM parameters contain a `utm_campaign` value counts as part of that campaign, stored or not. A campaign that exists only this way has `managed: false`. The list screen calls it a virtual campaign, and `type=virtual` selects it.

The list route merges both. A partner's own `utm_campaign` value also creates a list row, with `link_count` `0` unless links use it. Deleting a stored campaign removes only its row. The links keep their UTM values, so the campaign shows again as `managed: false`.

The campaign value is called `slug` in every response and is the key used in the path. It is not a short link slug.

### Campaign values in the path

The routes `PUT /campaigns/{campaign}`, `DELETE /campaigns/{campaign}` and every `/campaigns/{name}/...` route take the value from the path.

- The path pattern matches `[^/]+`, so a value that contains `/` cannot be addressed by these routes.
- The plugin runs `rawurldecode` on the value, removes tags and trims it. Send the value URL-encoded. A non-Latin value such as `весна-2026` works when encoded.
- Lookup of a stored campaign also tries the value as given, decoded once and the older percent-encoded lower-case spelling. A campaign stored by an earlier version under `%d0%b2...` still opens.
- Links are matched by the exact value in their UTM parameters.

### Timing and caching

Campaign figures are cached for 60 seconds, and creating, updating or deleting a campaign clears that cache. A report can lag behind a new click or a link edit by up to a minute.

### Error shapes

An unknown campaign is not handled the same way on every route:

| Route | Unknown campaign |
|---|---|
| `PUT /campaigns/{campaign}` | `422`, with `errors.slug` "Campaign not found." (after the name check). |
| `DELETE /campaigns/{campaign}` | `404`. |
| `GET /campaigns/{name}/report` and `.../report/export` | `404`. |
| `GET /campaigns/{name}/links` | `200` with an empty page. |
| `GET /campaigns/{name}/clicks` | `200` with zero-filled days. |
| `GET /campaigns/{name}/utm-breakdown` | `200` with `[]`. |
| `GET /campaigns/{name}/countries` | `200` with no rows. |

A `404` from this page is a WordPress error `{ "code": "campaign_not_found", "message": "Campaign not found", "data": { "status": 404 } }`. Validation failures are `422` as in the [overview](https://docs.linkmanagerpro.com/developers/rest-api/#responses).

## The campaign object

Two shapes carry a campaign. The stored campaign comes from `GET /campaigns/all`, create and update. The list row comes from `GET /campaigns`.

### Stored campaign (all, create and update)

| Field | Type | What it holds |
|---|---|---|
| `id` | integer | The ID of the row in `link_manager_pro_meta`. |
| `slug` | string | The campaign value, the `utm_campaign` text. |
| `name` | string | The display name. |
| `description` | string | The description, empty when none. |
| `utm_source` | string | Default `utm_source` for new links in this campaign. Empty when none. |
| `utm_medium` | string | Default `utm_medium`. Empty when none. |
| `utm_term` | string | Default `utm_term`. Empty when none. |
| `utm_content` | string | Default `utm_content`. Empty when none. |
| `link_count` | integer | Links whose UTM parameters carry this value. Create and update only. |
| `affiliate_count` | integer | Affiliates whose UTM campaign is this value. Create and update only. |

### List row

| Field | Type | What it holds |
|---|---|---|
| `slug` | string | The campaign value. |
| `name` | string | The stored name, or the value itself for an unmanaged campaign. |
| `description` | string | The stored description, empty otherwise. |
| `managed` | boolean | `true` for a stored campaign, `false` when only links carry the value. |
| `link_count` | integer | Short links carrying the value. |
| `affiliate_count` | integer | Affiliates whose UTM campaign is this value. |
| `total_clicks` | integer | Recorded clicks on those links. |
| `unique_clicks` | integer | Recorded clicks marked unique on those links. |
| `sources` | array of strings | The distinct `utm_source` values of those links, and of a partner using the value. |
| `mediums` | array of strings | The distinct `utm_medium` values, collected the same way. |
| `utm_source`, `utm_medium`, `utm_term`, `utm_content` | string | The stored campaign's defaults. Empty for an unmanaged campaign. |

## Campaigns

### List campaigns

`GET /campaigns`

Returns one page of campaigns, stored and found on links together, with the click totals of each and counts for the filter chips.

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

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `search` | string | No | none | Case-insensitive match on part of the name or the value. |
| `type` | string | No | `all` | `managed` lists stored campaigns, `virtual` lists values found only on links. Any other value gives `all`. |
| `source` | string | No | none | Keeps campaigns that use this exact `utm_source` on a link, or as the stored default. |
| `medium` | string | No | none | The same for `utm_medium`. |
| `page` | integer | No | `1` | The page number. A value below 1 gives page 1. |
| `per_page` | integer | No | `15` | Rows per page. Kept between 1 and 100. A value below 1 gives 1, above 100 gives 100. |

Rows are sorted by `total_clicks`, highest first, then by `slug`.

**Response:** `200`. `data` is a smaller page object than the one in [Pagination](https://docs.linkmanagerpro.com/developers/rest-api/#pagination). It has no `from`, `to` or `*_url` fields.

| Field | Type | What it holds |
|---|---|---|
| `data` | array of objects | The rows of this page, fields in [the list row](#list-row). |
| `total` | integer | Campaigns matching the filters, across all pages. |
| `current_page` | integer | The page returned. |
| `per_page` | integer | Rows per page after the 1 to 100 limit. |
| `last_page` | integer | The last page number, at least `1`. |
| `facets` | object | `source` and `medium`, each an array of `{ "value": string, "count": integer }`, most used first. Counted after `search` and `type`, before `source` and `medium`. |
| `counts` | object | `all`, `managed` and `virtual`, each an integer. Counted after `search`, `source` and `medium`, before `type`. |

```bash
curl -u USER:APPLICATION_PASSWORD \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns?type=managed&per_page=10"
```

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "slug": "spring-sale",
        "name": "Spring sale",
        "description": "",
        "managed": true,
        "link_count": 4,
        "affiliate_count": 0,
        "total_clicks": 312,
        "sources": ["newsletter", "twitter"],
        "mediums": ["email", "social"],
        "utm_source": "",
        "utm_medium": "",
        "utm_term": "",
        "utm_content": "",
        "unique_clicks": 240
      }
    ],
    "total": 1,
    "current_page": 1,
    "per_page": 10,
    "last_page": 1,
    "facets": {
      "source": [{ "value": "newsletter", "count": 1 }],
      "medium": [{ "value": "email", "count": 1 }]
    },
    "counts": { "all": 3, "managed": 1, "virtual": 2 }
  }
}
```

**Errors:** None specific to this route.

### List every stored campaign

`GET /campaigns/all`

Returns all stored campaigns without paging, for a dropdown. Campaigns found only on links are not included.

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

**Query parameters:** None.

**Response:** `200`. `data` is an array of [stored campaigns](#stored-campaign-all-create-and-update), sorted by `slug`, without `link_count`. It is `[]` when none are stored.

```json
{
  "success": true,
  "data": [
    {
      "id": 5,
      "slug": "spring-sale",
      "name": "Spring sale",
      "description": "",
      "utm_source": "",
      "utm_medium": "",
      "utm_term": "",
      "utm_content": ""
    }
  ]
}
```

**Errors:** None.

### List campaign values not yet stored

`GET /campaigns/sync/preview`

Returns the campaign values found on links, and on partners, that have no stored campaign. Use it to offer "create a campaign from this value".

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

**Query parameters:** None.

**Response:** `200`. `data` is an array, most clicks first, `[]` when every value is stored.

| Field | Type | What it holds |
|---|---|---|
| `slug` | string | The campaign value. |
| `suggested_name` | string | The value with `_` and `-` turned into spaces and each word capitalized. |
| `link_count` | integer | Short links carrying the value. |
| `total_clicks` | integer | Recorded clicks on those links. |
| `sources` | array of strings | The distinct `utm_source` values. |
| `mediums` | array of strings | The distinct `utm_medium` values. |

```json
{
  "success": true,
  "data": [
    {
      "slug": "black-friday",
      "suggested_name": "Black Friday",
      "link_count": 2,
      "total_clicks": 57,
      "sources": ["newsletter"],
      "mediums": ["email"]
    }
  ]
}
```

**Errors:** None.

### Create a campaign

`POST /campaigns`

Stores a campaign. To adopt a value found on links, send that value as `slug` so the campaign matches its links.

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

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `name` | string | Yes | none | The display name. Tags are stripped and the text is trimmed. |
| `slug` | string | No | the `name` | The campaign value. A value already used on links is kept as written. Any other value goes through `sanitize_title()`, which gives lower-case letters, digits and hyphens, then URL-decoded. |
| `description` | string | No | empty | Free text. Line breaks are kept. |
| `utm_source` | string | No | empty | Default `utm_source` for the campaign. |
| `utm_medium` | string | No | empty | Default `utm_medium`. |
| `utm_term` | string | No | empty | Default `utm_term`. |
| `utm_content` | string | No | empty | Default `utm_content`. |

A parameter that is not a string is treated as empty. The code sets no length limit on these fields.

**Response:** `200`, not `201`. `data` is the [stored campaign](#stored-campaign-all-create-and-update) with `link_count` and `affiliate_count`. `message` is "Campaign created successfully."

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"name":"Spring sale","utm_medium":"email"}' \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns"
```

```json
{
  "success": true,
  "data": {
    "id": 5,
    "slug": "spring-sale",
    "name": "Spring sale",
    "description": "",
    "utm_source": "",
    "utm_medium": "email",
    "utm_term": "",
    "utm_content": "",
    "link_count": 0
  },
  "message": "Campaign created successfully."
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | `name` is empty: `errors.name` "Campaign name is required." |
| `422` | The campaign value is empty, for example a name made only of symbols: `errors.slug` "Campaign value is required." |
| `422` | A stored campaign already has this value: `errors.slug` "A campaign with this value already exists." |

### Update a campaign

`PUT /campaigns/{campaign}`

Replaces a stored campaign. The call writes every field, so a `description` or `utm_*` field you leave out is saved empty.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `campaign` | string | The current campaign value, URL-encoded. See [Campaign values in the path](#campaign-values-in-the-path). |

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `name` | string | Yes | none | The display name. It is required on every update. |
| `slug` | string | No | the current value | The new campaign value. Left out, empty, or equal to the current value (in any spelling) means no rename. |
| `description` | string | No | empty | Replaces the description. Omitted means saved as empty. |
| `utm_source` | string | No | empty | Replaces the default. Omitted means saved as empty. |
| `utm_medium` | string | No | empty | The same. |
| `utm_term` | string | No | empty | The same. |
| `utm_content` | string | No | empty | The same. |

A new `slug` is normalized like on create. It cannot change while any link uses the current value in its UTM parameters.

**Response:** `200`. `data` is the [stored campaign](#stored-campaign-all-create-and-update) with `link_count` and `affiliate_count` for the value now stored. `message` is "Campaign updated successfully."

```bash
curl -X PUT -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"name":"Spring sale 2026","description":"Email push","utm_medium":"email"}' \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns/spring-sale"
```

```json
{
  "success": true,
  "data": {
    "id": 5,
    "slug": "spring-sale",
    "name": "Spring sale 2026",
    "description": "Email push",
    "utm_source": "",
    "utm_medium": "email",
    "utm_term": "",
    "utm_content": "",
    "link_count": 4
  },
  "message": "Campaign updated successfully."
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | `name` is empty: `errors.name` "Campaign name is required." This check runs first, so an unknown campaign with an empty name gets this error. |
| `422` | The new value is empty: `errors.slug` "Campaign value is required." |
| `422` | No stored campaign has the value in the path: `errors.slug` "Campaign not found." A value that exists only on links is not stored, so it also gets this. |
| `422` | A rename while links use the current value: `errors.slug` "Can’t be changed: N link uses it in its UTM tags." (plural for more than one link). |
| `422` | A rename while affiliates use the current value and no link does: `errors.slug` "Can’t be changed: N affiliate uses it in its UTM tags." (plural: "N affiliates use it in their UTM tags."). The link check runs first. |
| `422` | A rename to a value another stored campaign has: `errors.slug` "A campaign with this value already exists." |

### Delete a campaign

`DELETE /campaigns/{campaign}`

Deletes the stored campaign. Links keep their UTM values, so the value stays in reports as an unmanaged campaign.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `campaign` | string | The campaign value, URL-encoded. |

**Response:** `200`. `data` is `null`. `message` is "Campaign deleted."

```json
{
  "success": true,
  "data": null,
  "message": "Campaign deleted."
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | No stored campaign has that value, including one that exists only on links. The code is `campaign_not_found`. |

## Campaign reports

A campaign report covers every recorded click on the campaign's short links. None of the report routes except `clicks` and the CSV export take date parameters, so their numbers are all-time. The `/campaigns/{name}/...` routes need the campaign value in the path, so [the rules for values in the path](#campaign-values-in-the-path) apply.

### Get a campaign report

`GET /campaigns/{name}/report`

Returns the totals, first and last click and top breakdowns for one campaign, all-time.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:** None. There is no date range; the report is all-time.

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

| Field | Type | What it holds |
|---|---|---|
| `slug` | string | The campaign value. |
| `campaign` | string | The same value as `slug`. |
| `name` | string | The stored name, or the value for an unmanaged campaign. |
| `description` | string | The stored description, empty otherwise. |
| `managed` | boolean | Whether a stored campaign exists. |
| `created_at` | string or null | When the stored campaign was created, UTC `Y-m-d H:i:s`. `null` for an unmanaged campaign. |
| `first_click_at` | string or null | The earliest recorded click, UTC `Y-m-d H:i:s`. `null` without clicks. |
| `last_click_at` | string or null | The latest recorded click, same format. |
| `link_count` | integer | Short links carrying the value. |
| `affiliate_count` | integer | Affiliates whose UTM campaign is this value. |
| `total_clicks` | integer | Recorded clicks on those links. |
| `unique_clicks` | integer | Clicks marked unique. |
| `top_source` | string or null | The tagged `utm_source` with the most clicks, ties broken alphabetically. If no link with clicks has a source, the first source found on the links, else `null`. |
| `sources` | array of strings | The distinct `utm_source` values of the links, in link ID order. |
| `breakdowns` | object | `source`, `medium`, `device`, `browser` and `os`. Each is `{ "items": [{ "label": string, "count": integer }], "total": integer }`. `items` holds at most 10 labels, largest first. `total` counts all clicks in the breakdown, not only the listed ones. Links without a source or medium count under the label "(not set)". |
| `utm_source`, `utm_medium`, `utm_term`, `utm_content` | string | The stored campaign's defaults. Empty for an unmanaged campaign. |
| `tracking` | object | The site's tracking state, fields below. |

`tracking` holds:

| Field | Type | What it holds |
|---|---|---|
| `tracking_enabled` | boolean | Whether click tracking is on. |
| `retention_days` | integer | Days clicks are kept, `0` for no limit. |
| `autolink_site_enabled` | boolean | Whether auto-linking is on site-wide. |
| `history_trusted` | boolean | Whether the click history is complete enough to compare periods. |
| `history_reason` | string | Why not, such as `tracking_off`, `retention_short`, `tracking_resumed`, `retention_changed`, `clicks_restored` or `gap`. Empty when trusted. |

```bash
curl -u USER:APPLICATION_PASSWORD \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns/spring-sale/report"
```

```json
{
  "success": true,
  "data": {
    "slug": "spring-sale",
    "name": "Spring sale",
    "description": "",
    "managed": true,
    "campaign": "spring-sale",
    "created_at": "2026-10-01 09:00:00",
    "first_click_at": "2026-10-02 07:41:12",
    "last_click_at": "2026-10-22 18:03:09",
    "link_count": 4,
    "affiliate_count": 0,
    "total_clicks": 312,
    "unique_clicks": 240,
    "top_source": "newsletter",
    "sources": ["newsletter", "twitter"],
    "breakdowns": {
      "source": { "items": [{ "label": "newsletter", "count": 200 }], "total": 312 },
      "medium": { "items": [{ "label": "email", "count": 200 }], "total": 312 },
      "device": { "items": [{ "label": "Desktop", "count": 180 }], "total": 312 },
      "browser": { "items": [{ "label": "Chrome", "count": 150 }], "total": 312 },
      "os": { "items": [{ "label": "Windows", "count": 120 }], "total": 312 }
    },
    "utm_source": "",
    "utm_medium": "",
    "utm_term": "",
    "utm_content": "",
    "tracking": {
      "tracking_enabled": true,
      "retention_days": 0,
      "autolink_site_enabled": true,
      "history_trusted": true,
      "history_reason": ""
    }
  }
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | The value is neither stored, nor on any link, nor used by a partner. The code is `campaign_not_found`. A value that only a partner uses answers `200` with `link_count` `0` and `managed` `false`. |

### Export a campaign report as CSV

`GET /campaigns/{name}/report/export`

Downloads every recorded click on the campaign's short links as a CSV file, newest first.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `start` | string | No | none | First day, `Y-m-d` in site time. A malformed or impossible date counts as missing. |
| `end` | string | No | none | Last day, `Y-m-d`. The whole day counts. |

**Response:** `200`. The body is CSV, not JSON, with no envelope. The headers are:

| Header | Value |
|---|---|
| `Content-Type` | `text/csv; charset=utf-8` |
| `Content-Disposition` | `attachment; filename="link-manager-pro-report-{value}-{Y-m-d}.csv"`. The value goes through `sanitize_file_name()`, and the date is today in site time. |
| `Cache-Control` | `no-cache, no-store, must-revalidate` |

The first row is a header row. Each later row is one click. The columns, in this order:

| Column | What it holds |
|---|---|
| `Clicked At` | The click time in site time, `Y-m-d H:i:s`. |
| `Link Slug` | The slug of the short link that was clicked. |
| `Target URL` | The destination recorded for the click. |
| `Country` | A two-letter country code, or empty. |
| `Referrer Domain` | The referring host, or empty. |
| `Referrer URL` | The referring page, or empty. |
| `Device` | The device type from the user agent. |
| `OS` | The operating system family. |
| `Browser` | The browser family. |
| `Unique` | `yes` or `no`. |
| `Post ID` | The post the click came from, or empty. |
| `UTM Source`, `UTM Medium`, `UTM Campaign`, `UTM Term`, `UTM Content` | The UTM values recorded for the click, or empty. |

A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets a leading single quote, so a spreadsheet does not run it as a formula. A stored campaign with no links gives only the header row.

```bash
curl -u USER:APPLICATION_PASSWORD -o campaign.csv \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns/spring-sale/report/export?start=2026-10-01&end=2026-10-23"
```

```text
Clicked At,"Link Slug","Target URL",Country,"Referrer Domain","Referrer URL",Device,OS,Browser,Unique,"Post ID","UTM Source","UTM Medium","UTM Campaign","UTM Term","UTM Content"
"2026-10-22 18:03:09",blue-widget,"https://shop.example.com/?tag=mysite-20",US,,,Desktop,Windows,Chrome,yes,18,newsletter,email,spring-sale,,
```

**Errors:**

| Status | When |
|---|---|
| `404` | The value is neither stored, nor on any link, nor used by a partner. The body is the JSON `campaign_not_found` error. A value only a partner uses answers `200` with an empty report. |

### List a campaign's short links

`GET /campaigns/{name}/links`

Returns one page of the short links that carry the campaign value, the most clicked first.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `page` | integer | No | `1` | The page number. A value below 1 gives page 1. |
| `per_page` | integer | No | `10` | Links per page. Kept between 1 and 100. |

Links are sorted by recorded clicks, highest first, then by ID.

**Response:** `200`. With links, `data` is a page object ([Pagination](https://docs.linkmanagerpro.com/developers/rest-api/#pagination)). Each item in `data.data` has the fields of [the short link object](https://docs.linkmanagerpro.com/developers/rest-api/links#list-row-create-response-and-update-response) (without the list-only fields such as `rules_count`) and these extra fields:

| Field | Type | What it holds |
|---|---|---|
| `clicks` | integer | Recorded clicks on this link. `click_count` is the separate lifetime counter. |
| `url` | string | The destination, the same text as `target_url`. |
| `utm_source` | string | The link's `utm_source`, empty when none. |
| `utm_medium` | string | The link's `utm_medium`, empty when none. |

The `short_url` field uses the affiliate prefix for links to an affiliate's domain, as described under [Short links](https://docs.linkmanagerpro.com/developers/rest-api/links#short-links).

With no links, including an unknown campaign, `data` is only `{ "data": [], "total": 0, "current_page": 1, "per_page": 10, "last_page": 1 }`. `current_page` and `per_page` repeat the request.

```json
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 42,
        "slug": "blue-widget",
        "target_url": "https://example.com/products/blue-widget",
        "redirect_type": 301,
        "utm_params": { "utm_source": "newsletter", "utm_medium": "email", "utm_campaign": "spring-sale" },
        "click_count": 128,
        "short_url": "https://example.com/link/blue-widget",
        "clicks": 120,
        "url": "https://example.com/products/blue-widget",
        "utm_source": "newsletter",
        "utm_medium": "email"
      }
    ],
    "last_page": 1,
    "per_page": 10,
    "total": 1
  }
}
```

The example shortens the link fields. A real row has all of them.

**Errors:** None. An unknown campaign returns the empty page.

### Get campaign clicks over time

`GET /campaigns/{name}/clicks`

Returns one row per day with the total and unique clicks on the campaign's links, for a chart, or the same split by `utm_source`.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:**

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `start` | string | No | see below | First day, `Y-m-d` in site time. |
| `end` | string | No | today | Last day, `Y-m-d`. Never later than today. |
| `group` | string | No | none | `source` splits the series by each link's own `utm_source`. Any other value is ignored. |

With neither `start` nor `end`, the range runs from the earlier of the stored campaign's creation day and its first click, up to today. With neither of those, it is the last 30 days. With only `end`, it covers the 30 days ending on that day. A reversed range is swapped, and a range is capped at 7305 days. An invalid date counts as missing.

**Response:** `200`. Without `group`, `data` is an array with one item per day, zero-filled:

| Field | Type | What it holds |
|---|---|---|
| `date` | string | The day, `Y-m-d` in site time. |
| `total` | integer | Clicks that day. |
| `unique` | integer | Unique clicks that day. |

With `group=source`, `data` is `{ "series": [ ... ] }`. Each series item has:

| Field | Type | What it holds |
|---|---|---|
| `key` | string | A `utm_source` value, `""` for links with no source, or `"__other"` for all remaining sources. |
| `points` | array of objects | Days with `date`, `total` and `unique`, as above. |

The three sources with the most recorded clicks get their own item, and the rest merge into `__other`. An unknown campaign gives `series: []`.

```bash
curl -u USER:APPLICATION_PASSWORD \
  "https://example.com/wp-json/link-manager-pro/v1/campaigns/spring-sale/clicks?start=2026-10-20&end=2026-10-22"
```

```json
{
  "success": true,
  "data": [
    { "date": "2026-10-20", "total": 14, "unique": 11 },
    { "date": "2026-10-21", "total": 9, "unique": 7 },
    { "date": "2026-10-22", "total": 0, "unique": 0 }
  ]
}
```

**Errors:** None. An unknown campaign returns zero-filled days for the last 30 days, or an empty `series` with `group=source`.

### Get a campaign's UTM breakdown

`GET /campaigns/{name}/utm-breakdown`

Returns the campaign's clicks grouped by the combination of `utm_source`, `utm_medium`, `utm_term` and `utm_content` on its links, all-time.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:** None.

**Response:** `200`. `data` is an array, most clicks first. It is `[]` when no link carries the value. Each item is one distinct combination:

| Field | Type | What it holds |
|---|---|---|
| `utm_source` | string | The `utm_source`, empty when none. |
| `utm_medium` | string | The `utm_medium`, empty when none. |
| `utm_term` | string | The `utm_term`, empty when none. |
| `utm_content` | string | The `utm_content`, empty when none. |
| `link_count` | integer | Links with this combination. |
| `clicks` | integer | Recorded clicks on those links. |
| `unique_clicks` | integer | Recorded clicks marked unique on those links. |

```json
{
  "success": true,
  "data": [
    {
      "utm_source": "newsletter",
      "utm_medium": "email",
      "utm_term": "",
      "utm_content": "header-button",
      "link_count": 2,
      "clicks": 200,
      "unique_clicks": 160
    }
  ]
}
```

**Errors:** None. An unknown campaign returns `[]`.

### Get a campaign's clicks by country

`GET /campaigns/{name}/countries`

Returns the campaign's recorded clicks grouped by country, all-time, for a map.

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

**Path parameters:**

| Name | Type | What it is |
|---|---|---|
| `name` | string | The campaign value, URL-encoded. |

**Query parameters:** None.

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

| Field | Type | What it holds |
|---|---|---|
| `rows` | array of objects | One item per country, most clicks first: `code` (string, two capital letters) and `total` (integer). |
| `total` | integer | All recorded clicks on the campaign's links, including those with no known country. |
| `unresolved` | integer | Clicks with an empty, `XX` or invalid country code. They are not in `rows`. |

```json
{
  "success": true,
  "data": {
    "rows": [
      { "code": "US", "total": 180 },
      { "code": "DE", "total": 60 }
    ],
    "total": 312,
    "unresolved": 72
  }
}
```

**Errors:** None. An unknown campaign returns `{ "rows": [], "total": 0, "unresolved": 0 }`.

## UTM templates

A UTM template is a saved set of five UTM values with a name. The link form uses it to fill the UTM fields. Templates are rows in `link_manager_pro_meta` with `entity_type` `utm_template` and `entity_id` `0`. The name is the `meta_key`, so names are unique.

### The UTM template object

| Field | Type | What it holds |
|---|---|---|
| `id` | integer | The row ID. It is the `{id}` in the update and delete paths. |
| `name` | string | The template name. |
| `utm_source` | string | Empty when not set. |
| `utm_medium` | string | Empty when not set. |
| `utm_campaign` | string | Empty when not set. |
| `utm_term` | string | Empty when not set. |
| `utm_content` | string | Empty when not set. |

### List UTM templates

`GET /utm-templates`

Returns every saved UTM template, oldest first.

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

**Query parameters:** None. There is no paging.

**Response:** `200`. `data` is an array of [UTM templates](#the-utm-template-object), `[]` when none.

```json
{
  "success": true,
  "data": [
    {
      "id": 3,
      "name": "Newsletter",
      "utm_source": "newsletter",
      "utm_medium": "email",
      "utm_campaign": "",
      "utm_term": "",
      "utm_content": ""
    }
  ]
}
```

**Errors:** None.

### Create a UTM template

`POST /utm-templates`

Saves a new UTM template.

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

**Body parameters:** (JSON)

| Name | Type | Required | Default | What it does |
|---|---|---|---|---|
| `name` | string | Yes | none | The template name, trimmed and cleaned as plain text. It must be unique. |
| `utm_source` | string | No | empty | The `utm_source` value. |
| `utm_medium` | string | No | empty | The `utm_medium` value. |
| `utm_campaign` | string | No | empty | The `utm_campaign` value. |
| `utm_term` | string | No | empty | The `utm_term` value. |
| `utm_content` | string | No | empty | The `utm_content` value. |

**Response:** `200`, not `201`. `data.template` is the [UTM template](#the-utm-template-object). `message` is "UTM template saved."

```bash
curl -X POST -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"name":"Newsletter","utm_source":"newsletter","utm_medium":"email"}' \
  "https://example.com/wp-json/link-manager-pro/v1/utm-templates"
```

```json
{
  "success": true,
  "data": {
    "template": {
      "id": 3,
      "name": "Newsletter",
      "utm_source": "newsletter",
      "utm_medium": "email",
      "utm_campaign": "",
      "utm_term": "",
      "utm_content": ""
    }
  },
  "message": "UTM template saved."
}
```

**Errors:**

| Status | When |
|---|---|
| `422` | `name` is empty: `errors.name` "Template name is required." |
| `422` | Another template has this name: `errors.name` "A template with this name already exists." |

### Update a UTM template

`PUT /utm-templates/{id}`

Replaces a template. Every field is written, so a `utm_*` field you leave out is saved empty.

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

**Path parameters:**

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

**Body parameters:** (JSON) The same as [Create a UTM template](#create-a-utm-template). `name` is required again.

**Response:** `200`. `data.template` is the updated [UTM template](#the-utm-template-object). `message` is "UTM template updated."

```bash
curl -X PUT -u USER:APPLICATION_PASSWORD \
  -H "Content-Type: application/json" \
  -d '{"name":"Newsletter","utm_source":"newsletter","utm_medium":"email","utm_campaign":"spring-sale"}' \
  "https://example.com/wp-json/link-manager-pro/v1/utm-templates/3"
```

```json
{
  "success": true,
  "data": {
    "template": {
      "id": 3,
      "name": "Newsletter",
      "utm_source": "newsletter",
      "utm_medium": "email",
      "utm_campaign": "spring-sale",
      "utm_term": "",
      "utm_content": ""
    }
  },
  "message": "UTM template updated."
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | No template has that ID. The code is `not_found`. This check runs before validation. |
| `422` | `name` is empty: `errors.name` "Template name is required." |
| `422` | Another template has this name: `errors.name` "A template with this name already exists." |

### Delete a UTM template

`DELETE /utm-templates/{id}`

Deletes a saved template. Links that used its values keep them.

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

**Path parameters:**

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

**Response:** `200`. `data` is `null`. `message` is "UTM template deleted."

```json
{
  "success": true,
  "data": null,
  "message": "UTM template deleted."
}
```

**Errors:**

| Status | When |
|---|---|
| `404` | No template has that ID. The code is `not_found`. |

## Related

- [Campaigns](https://docs.linkmanagerpro.com/features/campaigns)
- [REST API overview](https://docs.linkmanagerpro.com/developers/rest-api/)
- [All endpoints](https://docs.linkmanagerpro.com/developers/rest-api/endpoints)
- [Short links endpoints](https://docs.linkmanagerpro.com/developers/rest-api/links)
