Tech Stack & Buying Signal Enrichment
Pricing
from $10.00 / 1,000 domain scanneds
Tech Stack & Buying Signal Enrichment
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.
Pricing
from $10.00 / 1,000 domain scanneds
Rating
0.0
(0)
Developer
Flux Pull
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
3 days ago
Last modified
Categories
Share
Give this Actor a list of domains and get back 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 — resolved from HTML, response headers, cookies, DNS records and the TLS certificate.
It also does the part a one-shot lookup cannot: change detection. Every scan is
compared against your previous scan of the same domain, and when the stack actually moves —
and the next scan confirms it — a vendor_change record is appended to a change-feed dataset
that spans every run. That is how a sales team learns that a prospect just
left Shopify, dropped Segment or installed a new tag manager — on the day it happens,
rather than at the next quarterly data refresh.
You pay per domain scanned, not per month. A new Apify account comes with $5 of free platform credit every month, and this Actor draws on it like any other Store Actor — about 500 domains scanned before you pay anything, with no credit card required.
What you get for each domain
| Field group | What it contains |
|---|---|
| Technologies | Every match from a 7,613-technology, 109-category fingerprint database — name, slug, categories, version where visible, a 0–100 confidence score, and the evidence that produced it |
| Headline fields | Flat cms, ecommerce, emailProvider, ats and techCount columns, so a CSV export is usable without unpacking nested JSON |
| Category summary | Grouped view: analytics, payments, CDN, hosting, tag manager, live chat, CRM, A/B testing, cookie compliance, security, JavaScript frameworks |
| DNS | MX, NS, CNAME, A records, SPF include: targets, third-party TXT verification records (which name the SaaS tools a company has actually signed up for), the inferred email provider, and whether a DMARC policy is published |
| TLS | Certificate issuer and organisation, validity window, SAN count, wildcard flag, negotiated TLS version and ALPN |
| Security posture | HSTS and its max-age, CSP, X-Frame-Options, X-Content-Type-Options, referrer and permissions policy |
| Change record | added, removed, versionChanged, dnsChanged, tlsIssuerChanged, plus a before/after summary — only when something really changed, and only once a second scan agrees. On the row as changes and as flat changesAdded/changesRemoved/changesUpgraded/changesPending columns; collected in a change-feed dataset that spans every run |
| Provenance | The exact fingerprint-database commit each row was produced with, so a disputed detection is reproducible |
Every detection carries its evidence: which field matched (header, cookie, meta,
scriptSrc, html, dns, tls, robots, js, dom, xhr, css, implies …), the
pattern that fired, and the matched text. Nothing is a black box.
How to detect what a website is built with
- Open the Actor and paste your domains into Domains — bare hosts
(
allbirds.com), full URLs, internationalised domains and ports all work, and duplicates are removed. For long lists use Domains (pasted list) or point Domains file URL at a public text or CSV file. - Leave everything else on its defaults. DNS, DMARC and TLS lookups are on and cost no
bandwidth;
renderJsis off; change detection is on. - Click Start. A Tier 1 scan takes about 0.8–1.7 seconds per domain and 20 run in parallel by default. A 15-domain smoke list completed at 38 domains per minute at half that concurrency, so a list of a thousand is minutes of work, not hours.
- Read the results in the Overview dataset view, or export to CSV, JSON, Excel or Google Sheets. The dataset holds exactly one row per domain you asked for. Changes since your last run appear in the Vendor changes view, and are also collected in a change-feed dataset of their own that keeps growing across runs.
The first run of a domain records a baseline and never reports a change — there is nothing to compare it against yet. From the second run on, you get the difference, and by default a difference has to show up in two consecutive scans before it counts.
Minimal input
{"domains": ["allbirds.com", "vercel.com", "wix.com"]}
A realistic input
{"domains": ["allbirds.com", "techcrunch.com", "wix.com", "1password.com/careers"],"maxConcurrency": 20,"timeoutSecs": 12,"includeDns": true,"includeTls": true,"includeWellKnown": true,"minConfidence": 51,"trackChanges": true,"renderJs": false}
Pricing: how much does a website technology lookup cost?
This Actor is billed pay per event. You are charged for the events below and for
nothing else — the platform compute the run consumes is not billed to you on top of them.
(Apify offers a separate "pay per event + usage" mode that does bill platform usage
separately; it is not used here.) That includes proxy bandwidth: the datacenter proxy you
can switch on in Proxy configuration is paid for out of the event price, not charged to
you on top of it. Residential proxies are the one thing this pricing cannot absorb — see
Do I need a proxy? — so a run configured with the RESIDENTIAL group
stops immediately and explains why, rather than scanning and billing you for it.
| Event | Charged when | Price |
|---|---|---|
apify-actor-start | Once per run, charged by the platform at its default rate: one event per GB of run memory, and Apify waives the first 5 seconds of compute | $0.00005 per GB |
domain_scanned | An HTTP response of any status came back, or at least one technology was detected | $0.010 |
domain_failed | No HTTP response at all and zero technologies | $0.001 |
domain_rendered | A JavaScript render completed and produced signals — charged in addition to domain_scanned | $0.025 |
vendor_change | The stack differs from your stored snapshot and the next scan agrees — charged once per changed domain, never per changed technology | $0.070 |
Try it free. Apify's free plan includes $5 of platform usage credit each month and asks
for no credit card, and pay-per-event Actors draw on that credit like everything else in the
Store. At $0.010 a scan that is roughly 500 domains at no cost, or roughly 140 with
renderJs: true at $0.035 a domain. Set dryRun: true to price a list exactly before any of
the credit is spent.
A 4xx or 5xx is a result, not a failure: headers, DNS and TLS still ship and usually
still carry detections, so those rows bill as domain_scanned. domain_failed is the
tenth-of-a-price case where nothing answered and nothing was found.
Worked examples
1,000 domains, one run, no JavaScript rendering
| Item | Quantity | Unit | Total |
|---|---|---|---|
| Actor start | 1 run at 4 GB | $0.00005 per GB | $0.0002 |
domain_scanned | 1,000 | $0.010 | $10.00 |
| Run total | $10.00 |
1,000 domains, one run, renderJs: true
| Item | Quantity | Unit | Total |
|---|---|---|---|
| Actor start | 1 run at 4 GB | $0.00005 per GB | $0.0002 |
domain_scanned | 1,000 | $0.010 | $10.00 |
domain_rendered | 1,000 | $0.025 | $25.00 |
| Run total | $35.00 |
500 domains monitored daily for 30 days, 2% of them changing during the month
| Item | Quantity | Unit | Total |
|---|---|---|---|
| Actor start | 30 runs at 4 GB | $0.00005 per GB | $0.006 |
domain_scanned | 500 × 30 = 15,000 | $0.010 | $150.00 |
vendor_change | 2% of 500 = 10 | $0.070 | $0.70 |
| Month total | $150.71 |
Change detection is the cheap part of monitoring: the scans cost the money, and a change bills once per domain no matter how many tools moved. Suppression rules (below) make sure a daily schedule does not bill the same change thirty times, and a move has to show up in two consecutive scans before it bills at all — so a site that shuffles its ad tags on every visit is not a line on your invoice.
Set Dry run to compute everything and charge nothing — that is how to price a large run before you commit to it. You can also cap a run's spend with Apify's maximum-cost setting.
Input reference
| Field | Type | Default | What it does |
|---|---|---|---|
domains | array | — | Domains or URLs to scan. Bare hosts, full URLs, IDN and ports accepted; duplicates removed |
domainsText | string | — | The same list pasted as text, split on newlines, commas, semicolons or spaces |
domainsUrl | string | — | Public URL of a plain-text or CSV-first-column file, one domain per line (max 50 MB) |
maxDomains | integer | 0 | Hard cap on domains processed this run; 0 means no cap |
maxConcurrency | integer | 20 | Parallel domain scans |
timeoutSecs | integer | 10 | Per-HTTP-request timeout |
followWww | boolean | true | If the apex domain fails, retry the www. variant and vice versa |
includeDns | boolean | true | TXT / MX / NS / CNAME / SOA lookups — no bandwidth cost, and the source of email-provider and SaaS-verification signals |
includeDmarc | boolean | true | One extra TXT query for _dmarc.<domain> |
includeTls | boolean | true | Certificate issuer, validity, SAN count, wildcard, TLS version |
includeWellKnown | boolean | false | Two extra small requests for /robots.txt and /sitemap.xml |
inlineJsHeuristic | boolean | true | Approximate JavaScript-variable detection by searching inline script text; capped at confidence 50 |
minConfidence | integer | 50 | Drop detections below this score. 51 excludes every inline-JS-only result |
categoriesFilter | array | [] | Report only these categories. Detection always runs in full, so implied technologies stay correct |
renderJs | boolean | false | Tier 2. Render the page in headless Chromium. Extra charge; needs at least 2 GB of run memory |
renderConcurrency | integer | 4 | Tier 2. Parallel browser contexts, clamped by run memory |
renderTimeoutSecs | integer | 25 | Tier 2. The browser's budget for one page — loading it and reading it. Separate from timeoutSecs, which bounds one HTTP request. Heavy pages behind a proxy want 20–40 |
renderBlockResources | string | default | Tier 2. default blocks images, media and fonts; aggressive also blocks stylesheets (and the css detections with them); none downloads everything |
trackChanges | boolean | true | Compare against your last scan and emit vendor_change records |
snapshotStoreName | string | tech-stack-snapshots | Named key-value store holding your scan history. Use different names for separate tracking sets |
changesDatasetName | string | tech-stack-changes | Named dataset collecting every vendor_change record across runs — your change feed. Created the first time a change is confirmed |
changeMinConfidence | integer | 75 | A technology must score at least this to count as added or removed |
changeConfirmScans | integer | 2 | Consecutive scans a move must survive before it counts as a change and is billed. Candidates wait in changes.pending. 1 bills on the first scan that sees a difference |
purgeSnapshots | boolean | false | Empty the snapshot store before the run — how you honour a deletion request or start a tracking set over |
dryRun | boolean | false | Compute changes, leave history untouched, charge nothing |
proxy | object | proxy off | Apify Proxy configuration. Not needed for normal use. Datacenter groups and your own proxyUrls are supported; the RESIDENTIAL group is not |
userAgent | string | "" | Leave empty for a realistic Chrome fingerprint, or set your own |
Output examples
A scanned domain
One domain_result row per domain. Trimmed here — … marks where lists were cut:
{"type": "domain_result","domain": "allbirds.com","finalUrl": "https://www.allbirds.com/","status": 200,"redirectChain": [{ "url": "https://allbirds.com", "status": 301 }],"scannedAt": "2026-09-15T11:43:11.328Z","durationMs": 1223,"tier": 1,"cms": "Backdrop","ecommerce": "Shopify","emailProvider": "Microsoft 365","ats": null,"techCount": 30,"changed": true,"changeCount": 3,"changes": {"previousScannedAt": "2026-09-14T11:39:15.053Z","added": [{ "name": "PayPal", "categories": ["Payment processors"] }],"removed": [{ "name": "Adyen", "categories": ["Payment processors"] }],"versionChanged": [],"dnsChanged": false,"tlsIssuerChanged": false,"pending": { "added": ["Hotjar"], "removed": [], "versionChanged": [] },"confirmScans": 2},"categorySummary": {"ecommerce": "Shopify","payments": ["PayPal", "Catch", "Shop Pay"],"cdn": "Cloudflare","tagManager": "Google Tag Manager","emailProvider": "Microsoft 365","liveChat": "Gladly","security": ["HSTS", "reCAPTCHA"],"jsFrameworks": ["ExtJS", "Vue.js"]},"technologies": [{"name": "Shopify","slug": "shopify","categories": [{ "id": 6, "name": "Ecommerce" }],"confidence": 100,"evidence": [{ "type": "meta", "pattern": "shopify-digital-wallet", "match": "/11044168/digital_wallets/dialog" },{ "type": "scriptSrc", "pattern": "cdn\\.shopify\\.com", "match": "cdn.shopify.com" }],"website": "https://shopify.com","saas": true}],"dns": {"mx": ["allbirds-com.mail.protection.outlook.com"],"cname": "shops.myshopify.com","txtVerifications": [{ "vendor": "DocuSign", "record": "docusign=7de1885c-…" },{ "vendor": "Miro", "record": "miro-verification=523f3ed99ca1…" }],"spfIncludes": ["allbirds_com._es.easydmarc.com", "Microsoft 365"],"provider": "Microsoft 365","dmarcPresent": true},"tls": {"issuerOrg": "Let's Encrypt","validTo": "2026-11-08T22:33:31.000Z","tlsVersion": "TLSv1.3","alpn": "h2"},"security": { "hsts": true, "csp": true, "xFrameOptions": "DENY" },"fingerprintsCommit": "2b5eed3a5eede8b2415fcd0b27c003d625ad88d3"}
changes is the whole comparison with your previous scan of this domain, carried on the
row itself — so one row per domain is all you ever have to read. changes.pending is the
waiting room: Hotjar was seen in a new state once, it is not counted in changeCount, and
it costs nothing unless the next scan agrees.
The same comparison is repeated as four flat columns — changesAdded, changesRemoved,
changesUpgraded ("Sentry 7.0.0 → 7.12.1") and changesPending ("+Hotjar",
"-Datadog") — because a CSV export and the Console table cannot read nested JSON. They are
what the Vendor changes view displays, and they are absent on a first scan, when there is
genuinely nothing to compare.
The DNS block is worth a second look. cname: shops.myshopify.com is the ecommerce
platform confirmed at the infrastructure layer; mx names the email provider; and every
txtVerification is a SaaS product the company went to the trouble of verifying a domain
for. None of that costs a page load.
Monitor technology changes over time
Set a schedule, leave trackChanges on, and the Actor keeps a private history of every
domain you have scanned. Snapshots live in a named key-value store on your Apify
account — tech-stack-snapshots by default, renameable with snapshotStoreName — hashed
into 256 buckets, written through on every scan, kept for 180 days. Each domain keeps one
history per tier, so a rendered scan is only ever compared with your previous rendered scan
and a Tier-1 scan with your previous Tier-1 one.
Your change feed is a dataset of its own
Every run's dataset holds exactly one row per domain you asked for — nothing else. Confirmed
changes go somewhere better: a named dataset on your own account, tech-stack-changes by
default and renameable with changesDatasetName, which the Actor appends to run after run.
That store is your change feed. Point a webhook, a schedule, a spreadsheet or a BI tool at it
and you have every vendor move this account has ever detected in one place, instead of one
fragment per run expiring on its own retention clock. It is created the first time a change
is confirmed, so an account that has never seen one never has an empty dataset lying around,
and each run's OUTPUT records its name, id and how many records that run contributed.
A record looks like this:
{"type": "vendor_change","domain": "allbirds.com","scannedAt": "2026-09-15T11:43:11.328Z","previousScannedAt": "2026-09-15T11:39:15.053Z","added": [{ "name": "Google Tag Manager", "categories": ["Tag managers"] },{ "name": "PayPal", "categories": ["Payment processors"] }],"removed": [{ "name": "Adyen", "categories": ["Payment processors"] }],"versionChanged": [],"dnsChanged": false,"tlsIssuerChanged": false,"changeCount": 3,"summaryBefore": { "ecommerce": "Shopify", "emailProvider": "Microsoft 365", "techCount": 30 },"summaryAfter": { "ecommerce": "Shopify", "emailProvider": "Microsoft 365", "techCount": 30 }}
The same information is on the domain's own row under changes, so you can work purely from
the one-row-per-domain dataset if you prefer.
A change has to happen twice
A technology seen in a new state once is a candidate, not a change. It is reported on the
row under changes.pending, it does not move changed or changeCount, and it is not
billed. The next scan decides: if it agrees, the change is confirmed, written to the change
feed and charged once; if it contradicts it, the candidate is dropped and you never hear
about it. changeConfirmScans sets how many consecutive scans are needed — 2 by default, up
to 5, or 1 to be told the moment anything differs.
This exists because the noisiest changes are all true. An ad-heavy site loads a different subset of its ad-tech tags on every visit, so AppNexus, Tapad, Hotjar, Cxense, Rubicon Project, Facebook Pixel, DoubleClick Floodlight and Datadog really do come and go between two scans minutes apart — no confidence floor, category rule or scan-quality check can tell that from a real migration, because nothing about the detection is wrong. Persistence can: a company that adopts a tool still has it tomorrow. A real move therefore costs you one extra scan cycle to hear about, and a flicker costs you nothing at all.
A change is only a change when it is worth acting on, so several rules sit between "the lists differ" and "you are billed":
| Rule | Effect |
|---|---|
| First scan | Never bills a change. There is no "before" |
| Confirmation | A move must be seen in the same new state changeConfirmScans scans in a row (default 2). Until then it sits in changes.pending, costs nothing, and disappears silently if the next scan disagrees |
| Confidence floor | Only technologies at changeMinConfidence (default 75) or above take part. The default output floor is 50, so a low-confidence heuristic hit can never bill a change on its own |
| Partial scan | If a scan sees fewer than half the technologies of the previous snapshot — a WAF challenge, a timeout — removals are suppressed (changes.suppressed: "partial_scan") |
| No response | A scan that never reached the site reports additions from DNS and TLS but never removals ("no_response"). What the page would have shown is unknown, not gone — and your stored history keeps it, so the next healthy scan does not report the site as freshly installed |
| 24-hour repeat | An identical change set inside 24 hours is suppressed ("repeat"), so a daily schedule bills once per real change |
| Flap | A change that exactly undoes the previous one is suppressed ("flap"). Sites answering from several edge nodes otherwise oscillate forever |
| Page characteristics | Technologies filed only under "Miscellaneous" — HTTP/3, Open Graph, PWA, RSS — stay on the row but never bill a change. They depend on which edge node answered |
| Certificate authorities | Technologies filed only under "SSL/TLS certificate authorities" — DigiCert, Let's Encrypt, Sectigo and the rest — stay on the row but never bill a change. Which authority you see depends on which edge node answered and on the renewal cycle, not on a decision anyone made |
| Separate history per tier | A rendered scan is compared only with your previous rendered scan, never with a Tier-1 one. A browser sees tools a static fetch cannot, so mixing the two would bill you for the change of instrument. Switching renderJs on starts a second history, and its first scan is a first scan |
| DNS and TLS moves | Never bill on their own. changes.tlsIssuerChanged is reported and never charged; changes.dnsChanged covers MX and NS only — a real email-provider or DNS-host move — while A and CNAME records are left out, because they differ per edge node. A charge always means a technology was added, removed or upgraded |
| Version appearing from nowhere | A version is only an upgrade when both snapshots carried one. A technology that merely gains a version is not billed |
To delete your history, run once with purgeSnapshots: true, or delete the
tech-stack-snapshots store from Storage in Apify Console. Either way, every domain
then looks like a first scan and nothing is billed for a change. The change feed is a
separate dataset and is left alone; delete it from Storage → Datasets if you want the
record of past changes gone too.
When to turn on JavaScript rendering
Tier 1 — the default — fetches one page (body capped at 1.5 MB) plus DNS and TLS. No browser, and that is why it is fast and cheap.
Tier 2 (renderJs: true) loads the page in real headless Chromium and reads what only a
browser can see: the actual values of 5,594 JavaScript property paths, 1,787 DOM selectors
evaluated by a real CSS engine, the hosts the page calls over fetch/XHR, scripts injected
after load, and stylesheet text. Tier 2 is a superset of Tier 1 — it merges the rendered
DOM with the transport body rather than replacing it, so a paid render can never lose
evidence the free fetch already had.
Measured technology counts:
| Domain | Tier 1 | Tier 2 | Tier 1 at minConfidence: 51 | Tier 2 at minConfidence: 51 |
|---|---|---|---|---|
| allbirds.com | 30 | 38 | 13 | 24 |
| vercel.com | 37 | 43 | 27 | 35 |
| wix.com | 45 | 50 | 32 | 38 |
Turn it on when you care about client-side tools that never appear in HTML: animation and personalisation libraries, wallet buttons, affiliate and attribution scripts, tag managers that inject other tags, consent platforms loaded after interaction.
Leave it off for platform, CDN, hosting, email and ATS attribution. Those come from headers, DNS and TLS, where a render adds nothing but cost.
Memory. Rendering needs at least 2048 MB of run memory. Below that the run stays on
Tier 1, every row is stamped renderSkipped: "insufficient_memory", and no
domain_rendered is charged — you are never billed for a render that did not happen. Above
the floor, each concurrent render needs about 1 GB on top of the first GB:
slots = min(renderConcurrency, max(1, floor((memoryMbytes − 1024) / 1024)))
| Run memory | Effective parallel renders (at renderConcurrency: 4) |
|---|---|
| 2048 MB | 1 |
| 3072 MB | 2 |
| 4096 MB | 3 |
| 5120 MB and above | 4 |
Asking for more renders than the memory affords is not an error — the run just does them in smaller batches. 4096 MB is the comfortable setting for rendering.
Time. One render gets renderTimeoutSecs (default 25 s) to load the page and
read it — navigation, the settle, and the in-page probe all share that one budget, so a
page that pins its main thread costs one domain rather than a run. A render that runs out
stays on Tier 1 with renderSkipped: "render_failed" and charges nothing extra. Most pages
finish in 3.5–4.7 seconds; heavy pages, and anything behind a proxy, want 20–40.
timeoutSecs is a different number and stays small: it bounds a single HTTP request, and a
browser loading a page with a hundred subresources is not a single HTTP request.
Bandwidth. Images, media and fonts are blocked at the network layer by default, which
brings a render down to roughly 1.6–3.4 MB per page (measured: allbirds 3.4 MB, vercel
1.6 MB, wix 2.5 MB). renderBlockResources: "aggressive" drops stylesheets too — cheaper
and faster, at the cost of the ~200 technologies detected from stylesheet text.
Recommendations and tips
minConfidence: 51is the one-flag precision mode. It drops every detection that rests only on the inline-JS heuristic, which is capped at exactly 50. Verified over allbirds, vercel and wix: 0 inline-JS-only technologies survive, while technologies that have real evidence and an inline-JS hit keep both. The default of 50 favours recall.- Leave DNS and TLS on. They cost no bandwidth, they work even when the site blocks
you, and SPF
include:targets plus TXT verification records are among the strongest buying signals available anywhere. - Turn on
includeWellKnownif you want robots-derived detections and sitemap hints; it costs two small requests per domain. - Treat
atsas a bonus, not a headline. Applicant tracking systems are almost never linked from a homepage — companies link/careerson their own domain and the vendor host appears one click deeper. Passexample.com/careersexplicitly when you want ATS coverage; that is how1password.com/careersresolves to Ashby. - Use
categoriesFilterto narrow the output, not the detection. Detection always runs in full so implied technologies stay correct; the filter only trims what is reported. - Rehearse big runs with
dryRun: true. Everything is computed and pushed; nothing is charged. - Keep separate tracking sets apart with
snapshotStoreName— one store per customer list, per territory, per campaign.
Use it as a tech stack API
The Actor supports Standby mode: a warm container that answers HTTP requests directly,
with no run to start and no queue to wait for. Your Standby URL is per-Actor and stable
across runs — copy it from the Endpoints tab on the Actor's detail page in Apify
Console rather than building it by hand. Requests authenticate exactly like the Apify API,
with Authorization: Bearer <your token>.
# One domain, with DNS and TLS.curl -H "Authorization: Bearer $APIFY_TOKEN" \'https://<your-standby-url>/?domain=allbirds.com&dns=1&tls=1'# The same request with a JavaScript render.curl -H "Authorization: Bearer $APIFY_TOKEN" \'https://<your-standby-url>/?domain=allbirds.com&render=1'# Up to 25 domains in one call. The body takes the whole input object.curl -X POST -H "Authorization: Bearer $APIFY_TOKEN" \-H 'content-type: application/json' \-d '{"domains":["vercel.com","wix.com"],"includeDns":true,"includeTls":true}' \'https://<your-standby-url>/'# Liveness and readiness.curl -H "Authorization: Bearer $APIFY_TOKEN" 'https://<your-standby-url>/health'
GET / returns a single domain_result. POST / returns
{ "count": 2, "results": [ … ] }. GET /health returns readiness, the loaded fingerprint
commit, in-flight requests and the run's event totals. GET /openapi.json serves the full
OpenAPI contract, which is also what renders the interactive Endpoints tab.
Query parameters mirror the batch input: domain, dns, dmarc, tls, wellKnown,
inlineJs, render, renderConcurrency, track, minConfidence, categories
(comma-separated), timeoutSecs, followWww, userAgent.
Errors come back as JSON with an error object carrying a code and a message: 400 for
an invalid request, 402 once the run's pay-per-event charge limit is reached, 429 when
the container is already running its maximum number of scans or, for render=1, when every
render slot is busy (RENDER_BUSY, with a Retry-After header: retry after a few seconds,
or give the Standby container more memory for more slots), and 504 when a scan takes
longer than 30 seconds. A scan that runs into the 504 still finishes in the background and
is still billed, so treat a 504 as a signal to lower timeoutSecs for that domain or to
scan it in batch mode instead.
Measured on a warm container:
| Request | Time |
|---|---|
| Tier 1 | 0.65–0.87 s |
First render=1 in a container (loads the browser) | 4.3–5.0 s |
Subsequent render=1 | 3.5–3.8 s |
Chromium is loaded lazily: a container that only ever answers Tier-1 requests never
starts a browser, and /health reports tier2.loaded so you can confirm it.
Scheduling and integrations
Because this is an Apify Actor, everything the platform offers comes for free: schedules (scan your watchlist every morning), webhooks on run completion, exports to CSV, JSON, XML, Excel and Google Sheets, and the standard Apify integrations — Zapier, Make, Slack, Airbyte, LangChain and the rest — plus the Apify API and the JavaScript and Python SDKs for anything custom.
A practical monitoring setup: one scheduled daily run over your account list with
trackChanges: true, and a webhook on run completion that reads the tech-stack-changes
dataset — your change feed, which accumulates across every run — and posts what is new into
Slack or your CRM. Each run's OUTPUT says how many records it added, so a webhook can skip
the read entirely when the answer is zero. The run's own dataset stays one row per domain,
and its Vendor changes view shows the same comparison per domain if you would rather work
from there.
FAQ
Is there a free trial?
Effectively, yes, and it is Apify's rather than ours. Every new account is on the free plan,
which includes $5 of platform usage credit each month and requires no credit card, and
pay-per-event Actors like this one draw on that credit the same way the rest of the Store
does. At $0.010 per Tier 1 scan that is about 500 domains a month at no cost, or about 140
with renderJs: true. Run with dryRun: true first to see exactly what a list would cost
before any of the credit is spent.
How much does it cost to scan 10,000 domains?
$100.00 for a Tier 1 run: 10,000 × $0.010, plus a run-start fee of a fraction of a cent. With
renderJs: true it is $350.00, because each render adds $0.025 on top of the scan. Domains
that answer nothing at all bill $0.001 instead of $0.010.
How accurate is it, and what does the confidence score mean?
Confidence is a 0–100 score aggregated from the evidence behind a detection. A response
header, a cookie, a meta tag, a script URL, a DNS record or a real JavaScript global is
hard evidence and typically scores 100. The inline-JS heuristic — searching inline script
text for a variable name that a browser would have to run the page to confirm — is capped
at exactly 50 and always tagged as inlineJs evidence. Setting minConfidence: 51
therefore removes every detection that rests only on a guess, which is the precision mode.
Every row also records the fingerprint-database commit it was produced with, so a disputed
detection can be reproduced exactly.
How is this different from BuiltWith?
BuiltWith sells subscriptions — its published plans start at $295 per month and go to $995 per month (checked 2026-09-15) — and its main product is a large historical index you query. This Actor is priced per domain scanned, at $0.010 for a Tier 1 scan, with no subscription and no minimum. It scans live at the moment you ask rather than serving you a cached crawl, and it returns the evidence for every detection. What it does not have is BuiltWith's multi-year historical archive: your history here starts the first time you scan a domain. If you need "what did this site use in 2019", that is a different product. If you need "what is this site using now, and tell me when it changes", this is the cheaper answer and a reasonable BuiltWith alternative for prospecting and enrichment workloads.
How is this different from Wappalyzer?
Wappalyzer is best known as a browser extension and an API over the same open fingerprint
tradition. This Actor uses the community fingerprint dataset maintained at
enthec/webappanalyzer — the continuation of the open Wappalyzer rule set — pinned to an
exact commit, with a detection engine written from scratch. As a Wappalyzer alternative
the practical differences are: DNS, DMARC and TLS signals that a page-only scanner does not
collect; explicit evidence on every detection; batch and warm-API modes on the same
codebase; and change detection between scans, which no lookup-only tool can provide.
Can I use it as an API?
Yes, two ways. Standby mode gives you a warm HTTP endpoint that answers a single domain in under a second (see above). The regular Apify API lets you start a run, poll it and read the dataset, which is the better fit for large batches. Both are covered by your Apify token.
Can I schedule it and get alerted when something changes?
Yes. Use an Apify schedule for the run and a webhook on run completion, then read the
tech-stack-changes dataset — the change feed every run appends to, so your alerting reads
one growing store rather than hunting through per-run datasets. OUTPUT.changesDataset names
it and says how many records the run added. Only genuinely changed domains produce a
vendor_change record: a move has to repeat on the next scan before it counts, and the
suppression rules above mean a daily schedule bills and reports a given change once, not once
per run.
What data is stored, and how do I delete it?
Three things. Your results live in the run's dataset, like any Actor. Your confirmed changes
are appended to a named dataset — tech-stack-changes unless you rename it — which persists
across runs and holds nothing but the change records themselves; delete it from Storage →
Datasets whenever you like. Your scan history lives
in a named key-value store on your own Apify account — tech-stack-snapshots unless you
rename it — holding, per domain and per tier, the technology names and confidences, a hash,
the TLS issuer and a DNS fingerprint. A domain you scan both ways therefore has two
entries, one for its Tier-1 history and one for its rendered history, in the same bucket
record. No page content is stored. Retention is 180 days. To delete it, run once with
purgeSnapshots: true — which clears both histories — or delete the store from Storage
in Apify Console. The Actor runs under your account and writes only to your own storages.
Why does a site show 0 technologies?
Usually one of four reasons. The site returned no HTTP response at all, in which case the
row carries an error code and bills as domain_failed. It served a challenge or block
page to a datacenter IP — the row is still a result, and DNS and TLS still produce
detections. It is a genuinely plain site with no recognisable fingerprints. Or its stack is
entirely client-rendered, which is what renderJs: true is for. The error field and the
status code tell you which.
When should I turn rendering on?
When you need client-injected tools — see the section above for measured before/after counts. For platform, CDN, hosting, email and ATS attribution, Tier 1 already has the answer and a render only adds cost.
Do I need a proxy?
No, not for normal use. This Actor fetches the domains you already do business with, and
there is nothing anti-bot to defeat. If a specific target blocks Apify's datacenter
addresses, switch on the default datacenter proxy in Proxy configuration, or supply
your own proxyUrls — either way the bandwidth is covered by the per-event price and is
never billed to you on top of it.
Residential proxies are not supported. A rendered page is a few megabytes and
residential transfer is roughly $8/GB, which is more than the domain_rendered event
itself earns — so the price on this Actor cannot honestly include it, and charging you
twice for the same run would be worse. A run configured with the RESIDENTIAL group stops
in its first second with that explanation and scans nothing, so you are never billed for
it. A 403 is not a dead end anyway: headers, DNS and TLS still produce a usable row.
Known limitations
- A failed render is not retried. The row degrades to Tier 1, is stamped
renderSkipped: "render_failed", and nodomain_renderedis charged. Heavy pages can exceed the default 25-secondrenderTimeoutSecs; set it to 40 for those runs. - A render that finds nothing does not bill. If a render completes but returns no
JavaScript global, no matched DOM selector, no XHR host and no stylesheet text — a
challenge page, a hard block, an app shell that never booted — the row stays Tier 1 with
renderSkipped: "no_signals"and nodomain_renderedis charged, because the row would be identical to the Tier 1 one you already paid for. - A very heavy page can be read only partly. The in-page probe has its own budget
inside
renderTimeoutSecs; when it runs out the row is stampedrenderTruncated: trueand some of the 5,594 JavaScript paths and 1,787 DOM selectors were never evaluated. RaiserenderTimeoutSecsfor pages that hit it. - A confirmed change is one scan cycle late. A move has to appear in two consecutive
scans before it is reported and billed, so on a daily schedule you hear about a real
migration a day after the first scan that saw it.
changes.pendingshows it in the meantime;changeConfirmScans: 1trades the delay back for the noise. - Applicant tracking systems are rarely visible from a homepage — pass a
/careerspath when you want them. - Version numbers are only reported when the page exposes them. Many modern stacks do not.
Fingerprint data and licensing
Technology detection uses the community fingerprint dataset published by
enthec/webappanalyzer, which is licensed under
GPL-3.0 and pinned to an exact commit. It is data only — the detection engine here is
independent work under the MIT licence, and no GPL-licensed code is copied, linked or
adapted. The dataset is fetched when the image is built and is not redistributed. Running
this Actor as a hosted service is use, not distribution, so GPL-3.0's distribution
obligations are not triggered. Detection results are facts about third-party websites and
are yours to use freely. Full statement: NOTICE.md.