# SaaS Pricing & Plan Change Monitor (`plusonets/saas-pricing-change-monitor`) Actor

Watch exact SaaS pricing URLs and report confirmed changes to plans, prices, limits, and features. Monthly and annual prices stay separate.

- **URL**: https://apify.com/plusonets/saas-pricing-change-monitor.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $30.00 / 1,000 page state checkeds

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## SaaS Pricing & Plan Change Monitor

Product marketing and competitive-intelligence teams use this Actor to watch a short list of public pricing pages. It returns a typed plan matrix — name, price, currency, billing period, per-seat or per-account, limits, and add-ons — and reports a change only after a second agreeing capture. A failed read or a suspected redesign is recorded on its own. It is not reported as a plan being removed.

Unofficial, and not affiliated with Basecamp (37signals), SavvyCal, or Notion. The default input only reads those companies' public pricing pages. Short quotations are kept as evidence. The page itself is not republished.

Contact: plusonecares@gmail.com

### Who it is for

- A product marketer tracking a handful of competitors' published plans.
- An analyst who needs monthly and annual prices kept apart, including an annual total and its monthly equivalent, each labelled as published or derived.

### What you provide

Exact pricing-page URLs. The default input is three public pages that parse from static HTML or embedded page data, without a browser:

- `https://basecamp.com/pricing`
- `https://savvycal.com/pricing`
- `https://www.notion.com/pricing`

Leave the proxy off for those pages. Set **Confirming captures** to `2` (the default) so a one-off parse glitch cannot replace the baseline. Use `1` only when you accept a single read as final.

The baseline lives in a named key-value store (`saas-pricing-state` unless you rename it), keyed by monitor id and URL. Schedule the Actor on that same monitor id. An errored or blocked run does not overwrite a good baseline.

### What you get

The default dataset mixes three flat record types. Filter on `recordType`.

| recordType | When |
| --- | --- |
| `plan_state` | One plan at one billing period. Monthly and annual are different rows. |
| `change` | A confirmed change: price up or down, plan added or removed, limit changed, feature moved, unit or currency changed. |
| `page_status` | The page-level result, including `blocked`, `extraction_failure`, and `redesign_suspected`. |

`priceDisplay` says whether a figure is the published monthly price, the published annual total, the published monthly equivalent, or a derived figure. An annual row's `priceAmount` is the published annual total only. It is null when the page publishes a per-month equivalent and the annual total was calculated from that. A monthly row is never compared with an annual row.

The run key-value store also contains:

- `OUTPUT` — counts, coverage, and charged units
- `WEEKLY_DIGEST.md` and `WEEKLY_DIGEST.html` — included with the run, not a separate charge
- `EVIDENCE_REPORT.html` — only when **Write evidence report** is on, at least one page was chargeable, and the `evidence-report` charge succeeds

#### Example plan row

```json
{
  "recordType": "plan_state",
  "monitorId": "default",
  "sourceUrl": "https://savvycal.com/pricing",
  "observedAt": "2026-10-23T04:00:00Z",
  "coverage": "complete",
  "confidence": 0.98,
  "planName": "Premium",
  "billingPeriod": "annual",
  "priceAmount": 200,
  "priceCurrency": "USD",
  "annualTotal": 200,
  "annualTotalLabel": "published",
  "monthlyEquivalent": 17,
  "monthlyEquivalentLabel": "published",
  "pricingUnit": "per_seat",
  "priceDisplay": "USD 200.00 per year (published annual total); USD 17.00 per month equivalent (published monthly equivalent), per_seat",
  "status": "baseline_established"
}
```

The first successful read is `baseline_established`, not a change. The next differing read is `pending_confirmation` and does not emit `change` rows. The following agreeing read emits `change` rows and replaces the baseline.

#### Example change row

```json
{
  "recordType": "change",
  "monitorId": "default",
  "sourceUrl": "https://savvycal.com/pricing",
  "observedAt": "2026-11-06T04:00:00Z",
  "coverage": "complete",
  "confidence": 0.98,
  "changeType": "price_up",
  "severity": "major",
  "planName": "Basic",
  "billingPeriod": "monthly",
  "field": "priceAmount",
  "previousValue": "12.0",
  "newValue": "14.0",
  "confirmationCount": 2,
  "changeId": "stable-id-for-this-diff"
}
```

Turn on **Alert only on change** to skip unchanged plan rows. Confirmed changes and page problems are still saved. A successful check is still a `page-state-checked` event, because the check itself is the delivered unit. The digest and `OUTPUT` record it.

### Pricing

This Actor uses pay-per-event pricing. You pay only for these events:

| Event | When it is charged | Price |
| --- | --- | --- |
| Actor start | Once per run, per GB of memory. | $0.005 per GB |
| `page-state-checked` | A pricing URL was read and the matrix was chargeable (`complete`, or `partial` with confidence at least 0.65 and a comparable price). | $0.03 |
| `evidence-report` | Optional. One consolidated HTML report for the run, only after at least one chargeable page. | $0.25 |

Blocked, empty, unsupported, rejected, and suspected-redesign reads are not charged. The same page is not charged twice inside one run, including a platform retry. There is no separate per-result charge.

The default run uses 512 MB. A browser is used only to click a monthly/annual control when that period is missing from the static page, and only when the run has 1024 MB or more. The demo pages do not need it. Extra memory raises the start event (it is priced per GB) and compute time. It does not change the event prices above.

Worked examples, before taxes and before any max-charge cap you set on the run:

1. **Demo, once.** 3 pages, no evidence report, 512 MB. 3 × $0.03 = **$0.09**, plus the start event at $0.005 per GB (0.5 GB is $0.0025).
2. **Weekly watch, 25 pages, four weeks.** No evidence report. 100 × $0.03 = **$3.00**, plus four start events.
3. **Twice a week so a change can be confirmed, plus a weekly evidence report.** 25 pages × 8 runs = 200 × $0.03 = $6.00, plus 4 × $0.25 = $1.00. **$7.00** plus start events. A real price change is not alerted until the second agreeing check.

If the run's max total charge is reached, later URLs are skipped, the evidence report is not written, and baselines already saved stay as they are.

### Scope and limits

- The parser reads static HTML and embedded JSON, including Next.js and Nuxt payloads. It is a generic extractor, not a hand-written adapter for every vendor.
- Billing toggles are the only browser action. Currency switchers and seat sliders are not clicked.
- A page that disallows the fetch in `robots.txt`, fails, or comes back as a challenge is `blocked` and is not billed.
- If a previously good page comes back with a weak or overlapping-poor matrix, the status is `redesign_suspected` or `extraction_failure`. Existing plans are not marked removed.
- Private IPs, localhost, `file://`, credentialed URLs, and non-80/443 ports are rejected.
- The Actor does not collect personal contact details. It does not train a model on the pages it fetches.
- Prices on the live demo pages can change. The dataset from a fresh run is the source of truth, not the example JSON above.

### Integrations

- **Webhook.** On the Actor task, add an Apify webhook for run succeeded. Read the default dataset and keep items whose `recordType` is `change`. `changeId` is stable for the same plan, period, field, and values.
- **Make or n8n.** Use the Apify module "Get dataset items", or HTTP GET `{datasetApi}/items?format=json`. Map `sourceUrl`, `planName`, `billingPeriod`, `changeType`, `previousValue`, and `newValue`.
- **Sheets.** Download the dataset as CSV. The price, limit, feature, and add-on columns are flat text.

Point the schedule at one task per watchlist so the named state store keeps accumulating baselines.

### Run locally

Python 3.12.

```bash
pip install -r requirements.txt
python -m src
```

An empty input uses the three demo URLs. Local runs have no pay-per-event price configured, so charges are recorded in the run summary and are not billed. The first local run creates `./storage`.

```bash
pytest
```

### FAQ

**Why did a price I can see not alert?** The first difference is pending. With the default of two confirming captures, the next agreeing run emits the change. Check `page_status` for `pending_confirmation` and the digest section "Waiting for confirmation".

**Why was a plan not marked removed?** The new read was empty, low-confidence, or a poor overlap with the saved plan names. That is a redesign or extraction failure. The previous baseline is still there.

**Why are there two prices for one plan?** Monthly and annual are separate rows. The annual row shows the annual total and the monthly equivalent, and each number is labelled published or derived. They are not compared with the monthly plan's price.

**The page says Custom, but a number was saved.** Some pages hide a catalog amount in embedded data while the card says custom. The row note says the visible card says custom and the amount is the catalog list price.

**Do I need a proxy or a browser?** Not for the default pages. Raise memory to 1024 MB or more only if a target hides a requested period behind a toggle.

# Actor input Schema

## `pricingPages` (type: `array`):

Exact public pricing-page URLs. The default three pages parse without a browser.

## `monitorId` (type: `string`):

Stable name for this watchlist. Baselines are kept under this id in the state store.

## `billingPeriods` (type: `array`):

Published periods to keep. A monthly price is never stored as an annual price.

## `preferredCurrency` (type: `string`):

Currency to keep when a page publishes more than one.

## `confirmationCaptures` (type: `integer`):

Agreeing successful reads required before a change is emitted and replaces the baseline.

## `alertOnlyOnChange` (type: `boolean`):

Omit unchanged plan rows. Confirmed changes and page problems are still saved.

## `allowBrowser` (type: `boolean`):

Click a monthly/annual toggle only when that period is missing. On Apify this needs 1024 MB or more.

## `writeDigest` (type: `boolean`):

Save WEEKLY_DIGEST.html and WEEKLY_DIGEST.md in the run key-value store. Not charged separately.

## `writeEvidenceReport` (type: `boolean`):

Optional consolidated HTML report. Charged once per run as evidence-report when at least one page was chargeable.

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

Named key-value store for baselines. A failed read does not replace a good baseline.

## `maxPages` (type: `integer`):

Cap on pricing URLs processed in one run.

## `requestTimeoutSecs` (type: `integer`):

Seconds to wait for each pricing page response before recording it as a gap.

## `proxyConfiguration` (type: `object`):

Optional. Leave Apify Proxy off for the public demo pages.

## Actor input object example

```json
{
  "pricingPages": [
    "https://basecamp.com/pricing",
    "https://savvycal.com/pricing",
    "https://www.notion.com/pricing"
  ],
  "monitorId": "default",
  "billingPeriods": [
    "monthly",
    "annual"
  ],
  "preferredCurrency": "USD",
  "confirmationCaptures": 2,
  "alertOnlyOnChange": false,
  "allowBrowser": true,
  "writeDigest": true,
  "writeEvidenceReport": false,
  "stateStoreName": "saas-pricing-state",
  "maxPages": 30,
  "requestTimeoutSecs": 25,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

## `digestHtml` (type: `string`):

No description

## `digestMarkdown` (type: `string`):

No description

## `evidenceReport` (type: `string`):

No description

## `summary` (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 = {
    "pricingPages": [
        "https://basecamp.com/pricing",
        "https://savvycal.com/pricing",
        "https://www.notion.com/pricing"
    ],
    "monitorId": "default"
};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/saas-pricing-change-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 = {
    "pricingPages": [
        "https://basecamp.com/pricing",
        "https://savvycal.com/pricing",
        "https://www.notion.com/pricing",
    ],
    "monitorId": "default",
}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/saas-pricing-change-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 '{
  "pricingPages": [
    "https://basecamp.com/pricing",
    "https://savvycal.com/pricing",
    "https://www.notion.com/pricing"
  ],
  "monitorId": "default"
}' |
apify call plusonets/saas-pricing-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/saas-pricing-change-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/1huAwdfTbFdz7vWED/builds/10z8e2ZhmNSfalCcq/openapi.json
