Competitor Tracker & Co. Docs

Detected changes

Read the changes Competitor Tracker & Co. detects on competitors you track: pricing, product and messaging, filtered per competitor, per snapshot or org-wide.

Whenever we ingest a fresh snapshot of a tracked page, an agent compares it to the most recent prior one and writes down anything meaningful that's different. The change endpoints expose those notes three ways: scoped to a single competitor, scoped to a single snapshot or as a unified org-wide feed.

Heads up

The two feeds used to hand back one entry per page. They now hand back one entry per update, with the per-page changes attached as members, and their counts follow. If you built against the older shape, add raw=true and you get it back byte for byte.

Concepts

A change is a single short description (≤ 240 chars) of something that differs between two snapshots, classified into a category and assigned a priority.

A discovery is one update, however many pages it landed on. A competitor ships a scheduling module and we catch it on the homepage, on the product menu and on nine feature pages. That is one discovery holding 11 changes, not 11 things to read. The org-wide feed and the per-competitor feed return discoveries by default, and each one carries its underlying changes as members.

A change run is one analysis pass against a snapshot. Every snapshot has at most one run, ending in one of these terminal states:

  • completed — analysis finished. Zero or more changes attributed to the snapshot.
  • skipped_no_diff — the new snapshot was byte-identical to the prior one. Nothing to read.
  • skipped_first_snapshot — no prior snapshot to diff against (first capture of the tracked page).
  • errored — the run failed. The response includes a phase and summary describing what went wrong.

Run states are durable. A skipped_no_diff run is how the API tells you "the pack checked at this time. Nothing moved."

Two ways we file a change

Every change carries two independent labels that answer different questions.

  • Category — the nature of what moved: pricing, product, messaging, corporate or geography. It rides on the change and shows up as category.
  • Page type — the kind of page the change was spotted on. It rides on the page rather than the change — you read it as pageType on a tracked page and on a snapshot's trackedPage.

Category is what moved. Page type is where we were looking. A pricing-category change can surface on a pricing page or, now and then, on the homepage — the two axes stay separate on purpose.

We read the page type from the page's URL, so treat it as a good first guess rather than gospel. The buckets:

  • pricing — plans, tiers and price points
  • product — capability and feature pages
  • marketing — use-case and customer-facing pitch pages
  • docs — help, reference and guides
  • changelog — release notes and what's-new timelines
  • integrations — marketplace and integration listings
  • security — trust and compliance pages
  • legal — terms, privacy and agreements
  • corporate — about, careers and press
  • updates — blog and news
  • homepage — the site's front door
  • other — anything the read can't place into a sharper bucket

Merged entries

A discovery reads like a single change with its evidence attached.

  • description and descriptionSource — the sentence on the entry, and who wrote it. See Whose sentence it is.
  • priority — the highest priority among the members. category comes from that same member.
  • firstDetectedAt and lastDetectedAt — the window the update landed in. A rollout takes minutes to cross a site, so a merged entry spans a range rather than sitting at one instant.
  • crawlDate — the day it landed. We merge within one competitor and one day, so an update that straddles midnight comes back as two entries.
  • memberCount — how many changes went into the entry.
  • distinctPageCount and trackedPageTotal — the spread. See The spread.
  • pageTypeCounts — how many pages of each page type the update touched, ordered by page type. Two changes on one pricing page count as one pricing page.
  • members — every underlying change, each with its own changeId, snapshotId, description, priority and detectedAt, plus the page it was spotted on as trackedPageId, pageType, pageUrl and pagePath.

Exactly one member carries isRepresentative: true. That is the change the entry stands for, and its snapshotId is the one to open when a reader wants to see the page.

The spread

Two numbers say how far an update reached.

  • distinctPageCount — how many of the competitor's tracked pages it touched.
  • trackedPageTotal — how many pages we were watching that competitor on when we merged.

Read them as a pair. 8 of 23 is across 8 of 23 tracked pages. When the two match, the update reached every page we watch, which is worth wording differently: across the whole website. A distinctPageCount of 1 is one page moving, and the entry reads exactly like a single change.

The spread records where the update landed on the day we merged it. It is not a live count. Stop tracking one of those pages afterwards and nothing on the entry moves: the page still counts toward memberCount, distinctPageCount and pageTypeCounts, and trackedPageTotal still reports what we were watching at the time. pageTypeCounts always adds up to distinctPageCount.

Whose sentence it is

descriptionSource names the author of the entry's description.

  • verbatim — the sentence is one of the member descriptions, unchanged. An agent wrote it about one page and we picked the clearest one to stand for the group.
  • generated — we wrote the sentence for the merged entry, reading the members together.

The difference matters the moment you forward an entry to someone. A verbatim description is exact about the page it came from and may say nothing at all about the other 10. A generated one describes the update as a whole and will not match any single member word for word. A single-page entry is always verbatim: there is nothing to merge, so there is nothing to write.

Merging happens after the crawl settles

We merge a competitor's changes once its crawl has stopped producing them, not as each one arrives. So the newest thing in a feed is often not merged yet. An update that touched 11 pages reads as 11 single-page entries for a while, then becomes one. Nothing is hidden in the meantime and nothing you already hold breaks. Fresh notes take a moment to file.

Two consequences worth designing around:

  • An entry's id is not stable. Merge a competitor's day again and the entries come back under new ids. A member's changeId does not move, so key your own records on that.
  • An entry you read minutes after a crawl can turn up later as one member of a larger entry. Re-read the window instead of assuming the first answer was final.

On the per-competitor feed, a window we have not merged at all yet is served from the per-page changes rather than coming back empty: one entry per change, memberCount of 1, trackedPageTotal of 0 and no pagePath, because those two facts are only known once we merge. The entry's id is then the change's own id. Page through it with the cursor it hands back; the cursor remembers which shape it came from. The weekly brief marks such a week as still filing.

Opting out with raw

Pass raw=true and you get the per-page changes instead, in the exact shape these endpoints returned before merging existed. Same fields, same order, same cursor rules. An integration written against the old shape flips one query parameter and is whole again.

Flip it at the start of a pass, not halfway through. A cursor does not survive the switch, and it fails quietly. See cursor.

Three endpoints take it:

  • GET /v1/changes
  • GET /v1/competitors/{id}/changes
  • GET /v1/competitors/{id}/timeline — where change_detected now counts discoveries for a settled crawl day. raw=true counts each analysed page again. Days we have not merged yet keep their per-page entries either way.

GET /v1/snapshots/{id}/changes does not take it. A snapshot is one capture of one page, so nothing merges there.

Changes for one competitor

GET /v1/competitors/{id}/changes

A page of discoveries detected on the competitor across all its tracked pages, newest first. Pass raw=true for the per-page changes. Returns 404 CompetitorNotFound when the org has no subscription, 403 CompetitorAccessRevoked when the subscription has been removed.

curl "$CT_API/competitors/01HF.../changes?limit=20" \
  -H "Authorization: Bearer $CT_TOKEN"

Changes for one snapshot

GET /v1/snapshots/{id}/changes

Changes attributed to a single snapshot, never merged. Useful paired with GET /v1/snapshots/{id} — the snapshot's signed URLs let you show the artifact alongside the diff. Same access rules as GET /v1/snapshots/{id}.

curl "$CT_API/snapshots/01HF.../changes" \
  -H "Authorization: Bearer $CT_TOKEN"

The org-wide feed

GET /v1/changes

Discoveries across every competitor your organization is actively tailing. Built for digest-style consumers. A daily or weekly job calls this with since=<lastRunISO> and processes the new items. One short report every Monday.

Reading by the week rather than by the run? The weekly brief lists every week on file with its counts and hands back one week's movers and totals in a single call.

curl "$CT_API/changes?since=2026-05-01T00:00:00.000Z&limit=100" \
  -H "Authorization: Bearer $CT_TOKEN"

Filters

All three endpoints accept the same query parameters:

  • cursor — opaque cursor returned in nextCursor from a prior call. A cursor is only good against the query that produced it, and the shape is part of that query: a cursor from a merged page is not a cursor for a raw=true page. Hand one to the wrong shape and you get no error back. We do not recognise it, so we start the feed from the top and the page reads like a repeat rather than a mistake. Finish a pass in one shape before you switch.
  • limit — 1–200, default 50.
  • category — repeat or comma-separate to filter (?category=pricing_changes&category=product_changes or ?category=pricing_changes,product_changes). Unknown ids match nothing.
  • priorityhigh, medium or low. Same multi-value shape as category.
  • since — ISO-8601 timestamp with offset. Excludes changes detected before that moment.

On merged entries, category and priority match the entry rather than each member, and since matches lastDetectedAt. Ordering follows the same field, newest first, with id as a tiebreaker. Under raw=true all of it reads detectedAt, as it always did.

Three more are not shared by all of them:

  • rawtrue or false, default false. See Opting out with raw. Not accepted on GET /v1/snapshots/{id}/changes.
  • until — exclusive upper bound, ISO-8601 with offset. Pair it with since to read one fixed window. The per-competitor feed honours it in both shapes. The org-wide feed honours it on merged entries and ignores it under raw=true, which has never had an upper bound.
  • count — org-wide feed only. count=true returns { total, byPriority, byCategory } in place of a page, counting whatever the same query would have listed. Discoveries by default. Add raw=true and the totals count per-page changes instead.

Response shape

One merged entry per update, with its members attached:

{
  "items": [
    {
      "id": "01HF...",
      "competitorId": "01HF...",
      "crawlDate": "2026-05-04",
      "description": "Scheduling module added across the product pages, the product menu and the homepage.",
      "descriptionSource": "generated",
      "priority": "high",
      "category": "product_changes",
      "firstDetectedAt": "2026-05-04T04:05:41.000Z",
      "lastDetectedAt": "2026-05-04T04:12:08.000Z",
      "memberCount": 11,
      "distinctPageCount": 11,
      "trackedPageTotal": 23,
      "pageTypeCounts": [
        { "pageType": "homepage", "count": 1 },
        { "pageType": "product", "count": 10 }
      ],
      "members": [
        {
          "changeId": "01HF...",
          "snapshotId": "01HF...",
          "description": "Added a Scheduling entry to the product menu.",
          "priority": "high",
          "detectedAt": "2026-05-04T04:05:41.000Z",
          "trackedPageId": "01HF...",
          "pageType": "product",
          "pageUrl": "https://example.com/product/scheduling",
          "pagePath": "/product/scheduling",
          "isRepresentative": true
        }
      ]
    }
  ],
  "nextCursor": "eyJsIjoi..."
}

members carries all 11 changes. The example is trimmed to one.

And under raw=true, the per-page changes:

{
  "items": [
    {
      "id": "01HF...",
      "competitorId": "01HF...",
      "snapshotId": "01HF...",
      "priorSnapshotId": "01HE...",
      "category": "pricing_changes",
      "priority": "high",
      "description": "Basic plan price increased from $9 to $12 per month.",
      "detectedAt": "2026-05-04T10:23:00.000Z",
      "createdAt": "2026-05-04T10:23:00.000Z"
    }
  ],
  "nextCursor": "eyJkIjoi..."
}

priorSnapshotId is null when the change was detected on the first analyzed snapshot for that page. Rare. Typically only on backfills.

Admin reanalyze

POST /v1/admin/snapshots/{id}/reanalyze

Force a fresh run against a snapshot. Use when the prior run errored or you want a re-read after the catalog was updated. The existing analysis is cleared and a new run is scheduled. The new results replace the old transactionally.

Authentication is via a static admin bearer token (separate from the user-level token):

curl -X POST "$CT_API/admin/snapshots/01HF.../reanalyze" \
  -H "Authorization: Bearer $CT_ADMIN_TOKEN"

Returns 202 Accepted with { "snapshotId": "...", "enqueued": true }. Returns 404 NotFound for an unknown snapshot id and — deliberately — for any caller whose admin token is missing or wrong, so the endpoint is indistinguishable from a non-existent path to unauthenticated probes. Reprocessing runs asynchronously. Poll GET /v1/snapshots/{id}/changes to watch the new result set land.

On this page