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
pageTypeon a tracked page and on a snapshot'strackedPage.
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 pointsproduct— capability and feature pagesmarketing— use-case and customer-facing pitch pagesdocs— help, reference and guideschangelog— release notes and what's-new timelinesintegrations— marketplace and integration listingssecurity— trust and compliance pageslegal— terms, privacy and agreementscorporate— about, careers and pressupdates— blog and newshomepage— the site's front doorother— anything the read can't place into a sharper bucket
Merged entries
A discovery reads like a single change with its evidence attached.
descriptionanddescriptionSource— the sentence on the entry, and who wrote it. See Whose sentence it is.priority— the highest priority among the members.categorycomes from that same member.firstDetectedAtandlastDetectedAt— 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.distinctPageCountandtrackedPageTotal— 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 ownchangeId,snapshotId,description,priorityanddetectedAt, plus the page it was spotted on astrackedPageId,pageType,pageUrlandpagePath.
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
idis not stable. Merge a competitor's day again and the entries come back under new ids. A member'schangeIddoes 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/changesGET /v1/competitors/{id}/changesGET /v1/competitors/{id}/timeline— wherechange_detectednow counts discoveries for a settled crawl day.raw=truecounts 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 innextCursorfrom 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 araw=truepage. 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_changesor?category=pricing_changes,product_changes). Unknown ids match nothing.priority—high,mediumorlow. Same multi-value shape ascategory.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:
raw—trueorfalse, defaultfalse. See Opting out withraw. Not accepted onGET /v1/snapshots/{id}/changes.until— exclusive upper bound, ISO-8601 with offset. Pair it withsinceto 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 underraw=true, which has never had an upper bound.count— org-wide feed only.count=truereturns{ total, byPriority, byCategory }in place of a page, counting whatever the same query would have listed. Discoveries by default. Addraw=trueand 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.
Agent signup
How an AI agent starts competitor tracking for a person who has no account yet: name a few companies, read their history straight away and hand back a link the person uses to take the organization over.
The weekly brief
Read the weekly brief Competitor Tracker & Co. files, one week at a time: every week on file with its counts, then one week's movers, totals and quiet subjects.