# Short-Link Portfolio Destination Drift and Expiry Monitor (`kingii98/short-link-portfolio-destination-drift-and-expiry-monitor`) Actor

Resolves a portfolio of short links, tracked links and QR-code links, then compares every final destination against a stored baseline. Reports the links that broke, expired, loop, or now point somewhere else. HTTP only, no browser, no proxy, no database.

- **URL**: https://apify.com/kingii98/short-link-portfolio-destination-drift-and-expiry-monitor.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 run starteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Short-Link Portfolio Destination Drift and Expiry Monitor

Resolve a portfolio of short links, tracked links and QR-code links each week.
Compare every final destination against the destination that the previous run
stored. Report the links that broke, that expired, that loop, and that now point
somewhere else.

Link rot is silent. A printed QR code, an old post and a partner link keep their
traffic until the destination moves or the short link expires. Nobody sees the
loss until the numbers fall. This Actor is the scheduled watch that sees it
first.

The Store already holds resolvers that trace one redirect chain one time. This
Actor is not a resolver. The value is the **baseline comparison**: it remembers
what each link pointed to last week, and it tells you what moved.

- HTTP only. No browser, no proxy, no paid API, no external database.
- The buyer supplies the portfolio. The Actor discovers no links.
- Private, loopback and reserved targets are refused at every hop.
- A broken link, an expired link and an empty change report are results. The
  run ends SUCCEEDED. A FAILED run means a real malfunction.

### What it does

For each link:

1. Walks the redirect chain one hop at a time, up to the hop limit. Each hop
   keeps its own status code. `httpx` never follows a redirect on its own.
2. Refuses any hop that resolves to a loopback, private or reserved address.
3. Reads up to 64 KB of the destination page and looks for a parking page, a
   domain sales page or a "this link has expired" notice.
4. Compares the final host and the final path against the baseline.
5. Writes the new destination back into the named baseline store.

### Input

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `links` | array | four public sample links | 1 to 2000 links. A plain URL string, or an object with `url`, an optional `expected_host` and an optional `label`. |
| `maxHops` | integer | `10` | Redirects that one link may take before the Actor stops and marks it `BROKEN`. 1 to 20. |
| `timeoutSeconds` | integer | `15` | Timeout of one HTTP request. 5 to 60. |
| `concurrency` | integer | `8` | Links resolved at the same time. 1 to 20. |
| `detectParkedPages` | boolean | `true` | Read the destination body and look for expiry markers. |
| `stateStoreName` | string | `short-link-destination-baseline` | The named key-value store that holds the baseline. |

Every field has a schema default, so a run with empty input `{}` succeeds.

```json
{
  "links": [
    "https://example.com/promo",
    { "url": "https://example.com/partner", "expected_host": "partner.example.net", "label": "Autumn flyer QR" }
  ],
  "stateStoreName": "autumn-campaign-links"
}
```

Give **one portfolio one store name**. Two portfolios that share a store name
share a baseline, and a link that appears in both is compared against whichever
run wrote last.

### Output

Three record types land in one dataset. The dataset schema gives each one its
own view.

#### `link` — one row for each link

`url`, `state`, `finalUrl`, `finalHost`, `finalPath`, `finalStatus`,
`hopCount`, `hops` (the full chain with a status code for each hop),
`statusChain`, `loopDetected`, `hopLimitReached`, `gone`, `parkedSignals`,
`expectedHost`, `expectedHostMet`, `destinationChanged`, `previousFinalUrl`,
`previousSeenAt`, `baselineKnown`, `reasons`, `error`, `responseTimeMs`,
`checkedAt`.

#### `change` — the change report

One row for each link whose final host or final path moved since the last run:
`oldFinalUrl`, `oldFinalHost`, `oldFinalPath`, `oldSeenAt`, `newFinalUrl`,
`newFinalHost`, `newFinalPath`, `hostChanged`, `pathChanged`, `changedAt`. The
same list is written to the `CHANGE_REPORT` record of the default key-value
store, so a webhook or a downstream Actor can read it in one call.

#### `summary` — one row for each run

`linksRequested`, `linksReported`, `linksResolved`, `linksSkipped`,
`stateCounts`, `linksNeedingAttention`, `changesReported`, `baselineKnownFor`,
`firstRun`, `stateStoreName`, `verdict`.

### States

The state of a link is the first of these that applies.

| State | Meaning |
| --- | --- |
| `LOOP` | The chain returns to a URL that it already visited. |
| `EXPIRED` | The destination answers `410 Gone`, or the page holds a parking, sales or expired-link marker. |
| `BROKEN` | The link could not be resolved, the chain is longer than the hop limit, or the destination answers 4xx or 5xx. |
| `DESTINATION_CHANGED` | The final host or the final path moved since the last run, or the final host is not the `expected_host` that the buyer declared. |
| `OK` | The link resolves to a live destination that did not move. |

### The baseline

The baseline lives in the named key-value store, under the key `BASELINE`. It
holds one entry for each link: the final URL, the final host, the final path,
the final status, the state and the time it was seen. One read starts the run
and one write ends it.

- **The first run reports no change.** It writes the baseline. The second run is
  the first that can report drift.
- A link that timed out, that looped or that answered an error **keeps** the
  destination that the previous run stored. One bad week never erases the
  baseline, and never raises a false change alarm.
- A link that leaves the portfolio keeps its entry, so a run over a subset does
  not lose the rest. The map is bounded at 20000 entries; the oldest go first.

### Schedule it

The product pays at portfolio scale and on a schedule. Run it each week or each
month over the whole portfolio, and send the `change` rows to the person who
owns the campaign.

### Pricing

This Actor is pay per event.

| Event | Unit | Price (USD) | Charged when |
| --- | --- | --- | --- |
| `run-started` | run | 0.02 | The input parses, before the first HTTP request. |
| `link-resolved` | link that the Actor resolves | 0.005 | The Actor has sent at least one request for the link. |
| `destination-change-reported` | confirmed destination change | 0.02 | A trusted resolution moved away from the stored baseline. |

A link that the Actor refuses before it sends a request, because the target is
private, reserved or malformed, is **not charged**. It still gets a dataset row.

A change is charged only against a **stored baseline**. The first run charges no
`destination-change-reported` event, because there is nothing to compare. A link
that fails to resolve charges no change event either, because its destination is
not trusted. An `expected_host` that is not reached sets the state to
`DESTINATION_CHANGED`, and it charges no change event, because it is not a move
away from a baseline.

#### What one monthly run costs

A portfolio of 250 links, of which 6 moved.

| Event | Count | Price (USD) | Cost (USD) |
| --- | --- | --- | --- |
| `run-started` | 1 | 0.02 | 0.02 |
| `link-resolved` | 250 | 0.005 | 1.25 |
| `destination-change-reported` | 6 | 0.02 | 0.12 |
| **Total** | | | **1.39** |

#### How the size of the portfolio moves the bill

| Links | Changes | Uncapped (USD) | Charged (USD) |
| --- | --- | --- | --- |
| 25 | 0 | 0.15 | 0.15 |
| 250 | 6 | 1.39 | 1.39 |
| 1000 | 20 | 5.42 | 5.00 |
| 2000 | 40 | 10.82 | 5.00 |

The default maximum total charge for one run is USD 5.00. It covers about 996
links. Raise it on the run before you monitor a portfolio that is larger than
that, or split the portfolio over two schedules.

### Limits

| Limit | Value |
| --- | --- |
| Links for each run | 2000 |
| Redirect hops for each link | 20 |
| Body read for each destination | 64 KB |
| Request timeout | 60 s |
| Parallel links | 20 |
| Baseline entries | 20000 |

### Development

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `links` (type: `array`):

1 to 2000 links. Give a plain URL string, or an object that holds url, an optional expected\_host (the destination host that this link must reach) and an optional label. The Actor does not discover links: this list is the portfolio. A URL that appears twice is checked once.

## `maxHops` (type: `integer`):

The number of redirects that one link may take before the Actor stops and marks the link BROKEN.

## `timeoutSeconds` (type: `integer`):

The timeout of one HTTP request, in seconds.

## `concurrency` (type: `integer`):

How many links the Actor resolves at the same time.

## `detectParkedPages` (type: `boolean`):

Read up to 64 KB of the destination page and look for a sales page, a parking page or an expired-link notice. Switch this off to send one request less for each link.

## `stateStoreName` (type: `string`):

The named Apify key-value store that holds the last known destination of each link. Give one portfolio one store name, so that two portfolios do not share a baseline. 3 to 63 characters of letters, digits and dashes.

## Actor input object example

```json
{
  "links": [
    {
      "url": "http://github.com/"
    },
    {
      "url": "https://wikipedia.org/",
      "expected_host": "www.wikipedia.org"
    },
    {
      "url": "https://www.iana.org/help/example-domains"
    },
    {
      "url": "https://example.com/"
    }
  ],
  "maxHops": 10,
  "timeoutSeconds": 15,
  "concurrency": 8,
  "detectParkedPages": true,
  "stateStoreName": "short-link-destination-baseline"
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `changes` (type: `string`):

No description

## `attention` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "links": [
        {
            "url": "http://github.com/"
        },
        {
            "url": "https://wikipedia.org/",
            "expected_host": "www.wikipedia.org"
        },
        {
            "url": "https://www.iana.org/help/example-domains"
        },
        {
            "url": "https://example.com/"
        }
    ],
    "maxHops": 10,
    "timeoutSeconds": 15,
    "concurrency": 8,
    "detectParkedPages": true,
    "stateStoreName": "short-link-destination-baseline"
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/short-link-portfolio-destination-drift-and-expiry-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "links": [
        { "url": "http://github.com/" },
        {
            "url": "https://wikipedia.org/",
            "expected_host": "www.wikipedia.org",
        },
        { "url": "https://www.iana.org/help/example-domains" },
        { "url": "https://example.com/" },
    ],
    "maxHops": 10,
    "timeoutSeconds": 15,
    "concurrency": 8,
    "detectParkedPages": True,
    "stateStoreName": "short-link-destination-baseline",
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/short-link-portfolio-destination-drift-and-expiry-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "links": [
    {
      "url": "http://github.com/"
    },
    {
      "url": "https://wikipedia.org/",
      "expected_host": "www.wikipedia.org"
    },
    {
      "url": "https://www.iana.org/help/example-domains"
    },
    {
      "url": "https://example.com/"
    }
  ],
  "maxHops": 10,
  "timeoutSeconds": 15,
  "concurrency": 8,
  "detectParkedPages": true,
  "stateStoreName": "short-link-destination-baseline"
}' |
apify call kingii98/short-link-portfolio-destination-drift-and-expiry-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/short-link-portfolio-destination-drift-and-expiry-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/3rDT7KNzgcepU0q7n/builds/iGpdTMZ4DsNahQuFO/openapi.json
