Detect what any website is built with: CMS, ecommerce, payments, analytics, tag managers, CDN, hosting, ATS, email and JavaScript frameworks, with evidence for every detection. Get a vendor_change record when a site swaps a tool. Pay per domain scanned, no subscription.
Two fixes to change detection, both found on run S78X1s8E7ZGloH8Q2: three domains that
returned six dataset rows and $0.21 of changes that nobody had made.
One row per domain
vendor_change records now go to a named dataset of their own, not into the run's
dataset. The new changesDatasetName input names it (tech-stack-changes by default) and
it is your change feed: the Actor appends to the same store on every run, so every
vendor move this account has detected sits in one place instead of one fragment per run.
It is created the first time a change is confirmed, and the run's OUTPUT reports its
name, id and how many records that run added, under changesDataset.
The run's own dataset is now exactly one row per domain you asked for. Two row shapes
in one dataset read as duplicates in the Console table, and the "Vendor changes" view
listed every result row with its change columns empty, because a dataset view can select
fields but cannot filter by row type.
The "Vendor changes" view now reads the result row's own comparison, so every row in it
is a domain with its changes filled in: added, removed, upgraded, what is still being
confirmed, and any suppression reason. The "dataset view shows empty change columns"
limitation is gone.
New flat changesAdded, changesRemoved, changesUpgraded and changesPending
columns alongside the nested changes block, for the two surfaces that cannot read
nested JSON: a CSV export and the Console table. Present only on a row that has a
comparison, so a blank column means "first scan", never "wrong row type".
Billing is unchanged: one vendor_change per changed domain, under the same charge limit.
A change has to happen twice
New changeConfirmScans input (default 2). A technology must be seen in its new state
this many consecutive scans before it counts as a change and is billed. The first sighting
is a candidate: it is reported on the row under changes.pending, it does not move
changed or changeCount, and it costs nothing. The next scan either confirms it — one
record, one charge — or contradicts it, and it is dropped without ever being reported.
This catches the one kind of noise no other rule can, because the detections are all
true: ad-heavy sites load a different subset of their ad tags on every visit, so
AppNexus, Tapad, Hotjar, Cxense, Rubicon Project, Facebook Pixel, DoubleClick Floodlight
and Datadog genuinely come and go between two scans minutes apart. Persistence separates
that from a migration; confidence, category and scan quality cannot.
Rows carry changes.pending (the names waiting) and changes.confirmScans (the threshold
used), so a customer can see a move being considered rather than wondering where it went.
changeConfirmScans: 1 restores the previous behaviour exactly — billed on the first scan
that sees a difference.
A scan that could not see the page no longer rewrites your history. On a partial or
no-response scan an absent technology neither opens nor advances a removal candidate, and
stays in the stored set — so the healthy scan that follows no longer reports the whole site
as freshly installed. The "multi-homed sites can still produce an occasional change"
limitation is gone with it.
Snapshots written before this release load unchanged and start with nothing pending, so
the first scan after the upgrade opens candidates rather than billing them. Each tier keeps
its own candidates, like its own history.
0.1.0 — 2026-09-15
First release.
Technology detection
Scan any list of domains or URLs and get the full technology stack behind each one: CMS,
ecommerce platform, payment processors, analytics, tag managers, CDN, hosting, applicant
tracking system, email provider, security headers, JavaScript frameworks, live chat, A/B
testing, CRM and cookie-compliance tooling.
Detection runs against a fingerprint database of 7,613 technologies across 109
categories, pinned to an exact upstream commit and recorded on every output row so a
result can be reproduced later.
Signals come from HTML, response headers, cookies, meta tags, script URLs, robots.txt
and sitemap.xml (optional), DNS records and the TLS certificate. DNS and TLS lookups
cost no bandwidth and are on by default.
Every detection carries a confidence score and the evidence behind it: which field
matched, the pattern that fired and the text it matched.
DNS output includes MX, NS, CNAME and A records, SPF include: targets, third-party TXT
verification records, the inferred email provider and whether DMARC is published.
Flat cms, ecommerce, emailProvider, ats and techCount columns alongside the
full nested output, so CSV and spreadsheet exports are usable as-is.
minConfidence: 51 is a one-flag precision mode: it drops every detection that rests
only on the inline-JavaScript heuristic, which is capped at confidence 50.
The inline-JavaScript heuristic ignores generic identifiers. A bare root has to be
namespaced (Shopify.theme), a compound name (SHOPIFY_API_BASE_URL), or at least six
characters and not an everyday word, so ordinary source text no longer reports CmonSite
for baseUrl, Catch for catch, List.js for list or Spin.js for spinner.
categoriesFilter narrows what is reported without narrowing what is detected, so
implied technologies stay correct.
JavaScript rendering (Tier 2)
renderJs: true renders each page in headless Chromium and adds evidence no static fetch
can produce: real JavaScript globals, DOM selectors evaluated by a browser CSS engine,
the hosts the page calls over XHR, scripts injected after load, and stylesheet text.
Tier 2 merges with Tier 1 rather than replacing it, so a render never loses evidence the
static fetch already had.
Images, media and fonts are blocked at the network layer, keeping a render to roughly
1.6–3.4 MB per page.
Rendering requires at least 2048 MB of run memory. Below that the run stays on Tier 1,
rows are stamped renderSkipped, and nothing extra is charged.
renderTimeoutSecs (default 25 s) is the browser's own budget for one page — loading it
and reading it — kept separate from timeoutSecs, which bounds a single HTTP request.
The whole render is held to it: a page that pins its main thread after loading costs one
domain instead of stalling every domain queued behind it.
renderBlockResources chooses what the browser refuses to download: default (images,
media and fonts), aggressive (stylesheets too, at the cost of CSS-based detections) or
none.
A render that completes but finds nothing a fingerprint can read — no JavaScript global,
no matched DOM selector, no XHR host, no stylesheet text — leaves the row on Tier 1 with
renderSkipped: "no_signals" and charges no domain_rendered. The row would have been
identical to the Tier 1 one.
A render whose in-page probe ran out of budget is stamped renderTruncated: true, so a
partial read is never mistaken for an absent technology.
The browser now presents the same Chrome identity the Tier 1 fetch does, instead of
announcing itself as HeadlessChrome with navigator.webdriver set — some sites serve
different markup to the two, and it was the paid tier getting the bot treatment.
A crashed Chromium is recovered from in every case, including one that dies during
start-up; previously such a browser could be handed out for the rest of the run.
Change detection
Every scan is compared with your previous scan of the same domain and a vendor_change
record is written when the stack has moved, listing what was added, removed and upgraded.
Scan history is stored in a named key-value store on your own Apify account, retained for
180 days, and deletable at any time with purgeSnapshots: true.
Suppression rules keep monitoring quiet and cheap: first scans never count, a stricter
confidence floor applies to changes than to output, partial scans never report removals,
scans that got no response never report removals, an identical change set is suppressed
for 24 hours, an exact reversal of the previous change is suppressed as a flap, and
page characteristics such as HTTP/3 or Open Graph never count as vendor changes.
Scan history is kept separately per tier: a rendered scan is compared only with your
previous rendered scan, and a Tier-1 scan only with your previous Tier-1 one. A browser
sees tools a static fetch cannot, so mixing the two would have billed the change of
instrument as a change of stack. Both series share one bucket record, so a mixed-tier
schedule costs no extra storage calls, and purgeSnapshots: true clears both.
Certificate authorities — DigiCert, Let's Encrypt, Sectigo and the rest of the
"SSL/TLS certificate authorities" category — stay on the technology list but never count
as a vendor change. Which authority answers depends on the edge node and the renewal
cycle, not on a decision anyone made. In the same spirit, a TLS-issuer or DNS move never
bills on its own, and the DNS fingerprint now covers MX and NS only: A and CNAME records
differ per edge node and are left out of change detection entirely.
Standby API
The Actor runs as a warm HTTP service as well as a batch job: GET /?domain=… for a
single domain, POST / for up to 25, GET /health for readiness and counters, and
GET /openapi.json for the full contract.
A warm Tier 1 request answers in well under a second. Chromium is loaded lazily, so a
container that only ever serves Tier 1 never starts a browser.
Pricing
Pay-per-event pricing: domain_scanned, domain_failed, domain_rendered and
vendor_change, plus the platform's per-run start event. No subscription.
Proxy bandwidth is included in the event price and is never billed to you on top of it.
Residential proxies are not supported — one rendered page costs more in residential
transfer than the render earns — and a run configured with the RESIDENTIAL group stops
in its first second with that explanation rather than scanning and charging.
A 4xx or 5xx response is a result, not a failure — those rows still carry technologies
from headers, DNS and TLS.
A render that fails, times out or finds nothing is never charged.
The run summary's chargedTotals now matches the invoice. It previously reported twice
the real domain_scanned count, because the platform reports two charges for the one it
makes on an atomic write-and-charge; the bill itself was always correct.
dryRun: true computes everything and charges nothing, so a large run can be priced
before it is paid for.