# Tech Stack Enrichment - Website Technology Scanner, DNS + HTML (`snow_leo_data/tech-stack-detector`) Actor

293 technology detections on 10 sites against 69 from a rival Actor, ahead on all 10. Bulk tech stack lookup from the page and DNS: 428 technologies in 46 categories with confidence and evidence. Technographic data, Wappalyzer alternative, no API key. Detects frameworks and hosting.

- **URL**: https://apify.com/snow\_leo\_data/tech-stack-detector.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** Integrations, SEO tools, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$3.00 / 1,000 website scanneds

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

## Tech Stack Detector: Wappalyzer & BuiltWith Alternative

Point this Actor at a website and it tells you what the site is built on and
what the company runs behind it. No API key, no browser, no proxy. A bare
domain is enough.

Most technology detectors read the page and stop there. This one reads the
page **and the DNS layer**, and the DNS layer is where the tools a company
actually pays for leave their traces: the mail host, the single sign-on
provider, the e-signature service, the DMARC monitor, the design and project
tools that each needed a domain-verification record to be switched on. None of
that ever appears in HTML.

#### How much difference the second layer makes

Ten well-known sites, scanned by this Actor and by the strongest Actor in this
niche on the same day. The rival run is
`nexgendata/wappalyzer-replacement`, run id `fUuGC6bZibqELAwaX`, 12 September
2026; its output is kept in `tools/rival_run.json` so the comparison can be
repeated without paying for the run again. Our side is reproduced by
`python3 tools/measure.py`.

| Site | This Actor | of that, from DNS | Rival |
|---|---|---|---|
| stripe.com | 34 | 25 | 11 |
| shopify.com | 38 | 28 | 3 |
| vuejs.org | 11 | 3 | 3 |
| www.nytimes.com | 39 | 30 | 18 |
| wordpress.org | 14 | 4 | 2 |
| www.hubspot.com | 41 | 32 | 4 |
| basecamp.com | 20 | 14 | 6 |
| www.zendesk.com | 40 | 28 | 3 |
| gitlab.com | 38 | 26 | 15 |
| news.ycombinator.com | 18 | 13 | 4 |
| **total** | **293** | **203** | **69** |

293 detections against 69 — ahead on all 10 sites, behind on 0 of them. On
nytimes.com specifically: 39 against 18, and among the extra ones are Notion,
DocuSign, Dropbox, Zoom, Miro, Atlassian, Jamf, Segment, Wiz, OneTrust and
Klaviyo. A page-only scanner cannot see a single one of them, because the New
York Times does not advertise its internal tooling in its homepage markup.

#### What it looks at

Six layers, each with its own confidence, and every detection carries the exact
evidence it came from so you can check it by hand:

- **Response headers** (confidence 100) — `server`, `x-powered-by`,
  `x-shopify-stage`, `cf-ray`, `x-vercel-id` and the rest.
- **DNS records** (confidence 100) — MX, NS, SPF includes, TXT
  domain-verification tokens, CAA and DMARC, read over DNS-over-HTTPS from
  Google with Cloudflare as the fallback resolver.
- **Cookies** (95) — every `Set-Cookie` line, not just the last one.
  `_shopify_y`, `wordpress_`, `__cf_bm`, `laravel_session`.
- **The meta generator tag** (95) — usually the only place a CMS states its
  version honestly.
- **The TLS certificate** (95) — one extra handshake names the certificate
  authority, the protocol version and the expiry date.
- **Script and stylesheet URLs** (90) and **the page body** (85).
- **Implication** (60) — WooCommerce means WordPress means PHP. Labelled as
  implied, never mixed in with direct evidence.

The tables hold **428 distinct technologies** across **46 categories**: 248
page-level fingerprints and 207 DNS-level vendors, with 27 names that both
layers can find.

#### Monitor mode: pay for changes, not for the same answer

Switch on **Monitor mode** and the Actor remembers every domain between runs in
a named key-value store. On the next run a site only produces a row if its
technology set is new or has changed, and the row then carries
`technologies_added` and `technologies_removed` — the actual names, not just a
flag.

That turns a list of domains into a watchlist: a competitor migrating from
Magento to Shopify, a prospect installing Intercom, a client moving mail from
Google Workspace to Microsoft 365. Put it on a schedule and a quiet week costs
you nothing in rows.

If nothing changed, the run finishes successfully with an empty dataset and
says so in words — `empty_reason: nothing-changed` — rather than leaving you to
guess.

#### An empty result always has a name

A green run that delivers nothing is the worst thing an Actor can do to you, so
this one never does it silently. Every empty outcome has its own named reason
in the REPORT record and a matching warning in the log. There are 12 of
them:

`no-urls-given`, `input-dataset-unreadable`, `all-urls-invalid`, `dns-technology-filter-without-dns-layer`,
`require-contradicts-exclude`, `min-confidence-above-maximum`,
`category-filter-matches-nothing`, `charge-limit-reached`, `filtered-out`,
`nothing-changed`, `nothing-detected`, `all-sites-unreachable`.

The first 8 of them are decided from your input **before a single request
is sent**. If you ask to keep only sites using Google Workspace and switch the DNS
layer off, that is a guaranteed zero — Google Workspace is only ever visible in
DNS — and you are told immediately instead of after a thousand pages have been
downloaded on your budget.

#### Input

Everything is optional except the list of websites, and the list itself is
accepted in whatever shape you already have it.

- **Websites** — bare domains (`stripe.com`), full URLs, `{"url": ...}`
  objects, or one long comma/newline separated string. An email address is
  reduced to its domain. The same list is read from any of `urls`, `startUrls`,
  `domains`, `websites`, `targetUrls`, `url`, `domain` and `website`, so output
  from another Actor can be pasted in without renaming a column.
- **Input dataset ID** and **Website field name** — take the list straight out
  of another Actor's dataset instead of typing it.
- **Detect DNS-layer technologies** — on by default. This is the layer that
  makes the difference above.
- **Read the TLS certificate** — on by default.
- **Probe known DKIM selectors** — off by default. Asks for 16 well-known
  selectors to prove which mail sender actually signs the domain's email; on a
  list of a thousand domains that is sixteen thousand extra lookups, which is
  why it is off unless you want it.
- **Only these categories** — keep only CMS, or Ecommerce, or Payment
  Processors, and so on. A category name that does not exist is reported back
  with the list of the ones that do, instead of quietly emptying your run.
- **Only sites using** / **Exclude sites using** — filter the site list itself.
  Matching is by substring, so `shopify` also keeps Shopify Plus and Shopify
  Hydrogen. This is the lead-generation mode: feed in ten thousand domains,
  get back the ones on WooCommerce.
- **Minimum confidence** — 90 keeps only hard evidence and drops everything
  inferred from the page body.
- **Monitor mode: only report changes** and **also emit unchanged sites**.
- **Include confidence / versions / evidence / security headers / all response
  headers** — each can be switched off.
- **Compact output** — for AI agents and for anything that pays per token.
  Measured on one nytimes.com row: 11 120 bytes full, 1 910 bytes compact.
- **Drop empty fields** — for loading straight into a database.
- **Parallel sites**, **HTTP timeout**, **Maximum rows**.

#### Output

One row per website. The full row carries the url, the final url after
redirects, the domain, the HTTP status, the response time, the scan
timestamp (`checked_at`), the page title,
description and language, the meta generator, the server header, the security
headers, the TLS issuer and expiry, the full DNS picture (`mx`, `ns`, `spf`,
`dmarc`, `caa`, the TXT record count and the zone that was actually queried),
and then the technologies themselves:

- `technologies` — a list of `{name, category, layer, version, confidence,
  evidence}`.
- `tech_names`, `tech_count` — the flat list and its size.
- `categories`, `category_list` — grouped by category.
- `page_tech_count` and `dns_tech_count` — how many came from each side, so you
  can see at a glance where the answer came from.
- In monitor mode: `change_type` (NEW / UPDATED / UNCHANGED),
  `technologies_added`, `technologies_removed`.

The last record of every run is a **REPORT**: how many sites were requested,
scanned, answered and unreachable, how many DNS lookups were made, how many
detections came from each layer, what each filter dropped, which input values
were not addresses and why, and per-host failures.

#### Where a site answers nothing

A site behind a challenge page still tells you something. A 403 from Cloudflare
arrives with the same `server`, `cf-ray` and `__cf_bm` cookie as a successful
response, so the row still names the protection in front of the site, and the
DNS layer answers regardless of whether the site is up at all. A domain that is
simply dead — no HTTP answer and no DNS records — produces **no row at all**
and is counted as `blank_rows` in the REPORT, because a row that says nothing
is a row you would have been charged for.

#### Honest limitations

Things the strongest rival in this niche, or the commercial services this
Actor is an alternative to, do that this one does not:

- **Database size.** Wappalyzer's own catalogue and the Actors that embed it
  claim 7 000+ technologies. This Actor knows 428. The 428 are the ones that
  appear on real commercial sites, and on the ten-site comparison above the
  bigger catalogue still lost 69 to 293 — but if you need the long tail of
  obscure libraries, that is a real difference.
- **No JavaScript execution.** The page is read as the server sends it. A
  technology that is only loaded by a later client-side script, and leaves no
  trace in the initial HTML, headers or cookies, will be missed. This is the
  price of running without a browser, and it is also why a scan costs a
  fraction of a browser-based one.
- **No historical data.** BuiltWith sells the history of a site's stack over
  years. This Actor tells you what is true now; monitor mode builds the history
  from the day you start watching, not before.
- **No traffic, spend or company-size estimates.** Technologies only.
- **Public suffix handling is an approximation.** The zone queried in DNS is
  derived from the hostname with a practical rule (two labels, three for known
  compound endings such as `co.uk` and `com.au`) rather than the full public
  suffix list. On an unusual suffix the DNS layer stays silent rather than
  reporting something wrong; the zone that was queried is in every row under
  `dns.zone`, so you can see what happened.
- **DKIM proof is opt-in, not exhaustive.** 16 well-known selectors are
  probed when you ask for it. A vendor using a private selector name is not
  found this way.

#### FAQ

**Do I need an API key or a proxy?**
No. Standard library HTTP and DNS-over-HTTPS only. That is also why the run
cost is low.

**What do I put in the input — URLs or domains?**
Either. `stripe.com`, `https://stripe.com/pricing`, `{"url": "stripe.com"}` and
`sales@stripe.com` all resolve to the same scan.

**Can it take its list from another Actor?**
Yes — put that Actor's dataset id into **Input dataset ID** and name the field
holding the address. A missing or unreadable dataset does not kill the run; it
is reported and any websites you typed by hand are still scanned.

**How do I find every site on my list that uses WooCommerce?**
Put `woocommerce` into **Only sites using**. Only matching sites produce rows,
and the REPORT tells you how many were dropped by that filter.

**Why does a row have technologies I cannot find in the page source?**
Because they came from DNS. Look at the `layer` field of each detection —
`dns` means the evidence is an MX, NS, SPF, TXT, CAA or DMARC record, and the
`evidence` field shows the record itself.

**Does the DNS layer work for a subdomain?**
The DNS layer queries the registered domain, not the hostname you typed:
records live on `example.com`, not on `www.example.com`. Measured:
`www.nytimes.com` has zero TXT records, `nytimes.com` has 45. The zone actually
queried is in `dns.zone`.

**Why is a technology listed with confidence 60?**
It was implied by another one rather than found directly — WooCommerce implies
WordPress implies PHP. Set **Minimum confidence** to 90 to drop everything
inferred.

**Why is `version` null so often?**
Because most technologies never state a version anywhere a scanner can read. A
version is filled in only when the header, script URL or generator tag really
contains one. An invented version is worse than no version.

**Can it tell me when a site's stack changes?**
That is monitor mode. Turn on **Monitor mode: only report changes**, put the
Actor on a schedule, and you get a row only when something actually moved,
with the added and removed names spelled out.

**Why did my run return nothing?**
Look at `empty_reason` in the REPORT record — there are eleven named reasons
and each one comes with a sentence in the log explaining exactly what to
change.

**How fast is it?**
8 sites are scanned in parallel by default (the timeout per request is 20
seconds), each host rate-limited on its own. A scan is one page fetch, one TLS handshake and five DNS queries per site.

**Is scraping this legal?**
Everything read here is what any browser and any DNS resolver receive when they
ask for a public website: the page a server sends to anyone, its response
headers, and the public DNS records of the domain. No login, no paywall, no
personal data.

# Actor input Schema

## `urls` (type: `array`):

Websites to analyse. A bare domain is enough ('stripe.com'); full URLs, {"url": ...} objects, and comma or newline separated lists all work, and an email address is reduced to its domain. The same list is also accepted under startUrls, domains, websites, targetUrls, url, domain and website, so output from another Actor can be pasted in unchanged.

## `inputDatasetId` (type: `string`):

Take the list of websites from an existing Apify dataset instead of typing it. Use this to chain after a Google Maps, company list or lead scraper.

## `urlField` (type: `string`):

Which field of the input dataset holds the website address. Only used together with Input dataset ID.

## `includeDns` (type: `boolean`):

Read MX, NS, SPF, TXT, CAA and DMARC records as well as the page. This is where mail hosting, SSO, e-signature, DMARC monitoring and dozens of internal SaaS tools become visible - none of them ever appear in the HTML. Turning it off makes a run faster and blinder.

## `includeTls` (type: `boolean`):

One extra handshake per host: names the certificate authority, the TLS version and the expiry date.

## `checkDkim` (type: `boolean`):

Ask for 16 well-known DKIM selectors (google, selector1, k1, s1, pm ...). It proves which mail sender actually signs the domain's email, at the cost of 16 extra DNS lookups per site. Off by default because on a list of a thousand domains that is sixteen thousand extra lookups.

## `categoriesFilter` (type: `array`):

Keep only technologies in these categories, for example CMS, Ecommerce, Analytics, CDN, Payment Processors, Email Hosting, SaaS Vendors. Leave empty for all of them. A category that does not exist is reported back instead of silently emptying the run.

## `onlyWithTechnologies` (type: `array`):

Keep a site only if it uses at least one of these technologies. Matching is by substring, so 'shopify' also keeps Shopify Plus and Shopify Hydrogen. Empty means keep every site.

## `excludeTechnologies` (type: `array`):

Drop a site if it uses any of these technologies. Useful for 'WordPress sites that are not on WooCommerce yet'.

## `minConfidence` (type: `integer`):

Drop detections below this confidence. A response header scores 100, a cookie or meta generator 95, a script URL 90, a match in the page body 85, and a technology only implied by another one 60. Set 90 to keep only hard evidence.

## `onlyChanged` (type: `boolean`):

Remember every site between runs and deliver a row only when its technology set is new or has changed. Each row then also carries technologies\_added and technologies\_removed. Put the Actor on a schedule with this on and you pay for changes, not for the same answer every day.

## `emitUnchanged` (type: `boolean`):

Keep the run memory and the NEW / UPDATED / UNCHANGED label, but deliver every site anyway.

## `includeConfidence` (type: `boolean`):

Add a 0-100 confidence to every detected technology.

## `includeVersions` (type: `boolean`):

Parse a version out of the matched header, script URL or meta generator when the source really states one. Left null otherwise - an invented version is worse than none.

## `includeEvidence` (type: `boolean`):

Add the exact header, cookie, script URL, DNS record or HTML fragment each detection came from, so every row can be checked by hand.

## `includeSecurityHeaders` (type: `boolean`):

Add HSTS, CSP, X-Frame-Options and the other security headers the site sends.

## `includeHeaders` (type: `boolean`):

Add the first 40 raw response headers to each row.

## `compactOutput` (type: `boolean`):

Return only url, domain, status, technology names, categories and the counts. Measured on nytimes.com: 11 120 bytes per row full, 1 910 bytes compact.

## `dropEmptyFields` (type: `boolean`):

Leave out fields that are null or empty, for loading straight into a database.

## `concurrency` (type: `integer`):

How many sites to scan at once. Each worker is one site, and every host is rate-limited separately, so raising this does not hammer any single server.

## `timeoutSeconds` (type: `integer`):

Per-request timeout. Raise it for slow sites.

## `maxItems` (type: `integer`):

Stop after this many rows have been DELIVERED, not after this many sites have been looked at - so a filter cannot turn a limit of 30 into an empty dataset. 0 means no limit.

## Actor input object example

```json
{
  "urls": [
    "stripe.com",
    "shopify.com",
    "nytimes.com"
  ],
  "urlField": "url",
  "includeDns": true,
  "includeTls": true,
  "checkDkim": false,
  "categoriesFilter": [],
  "onlyWithTechnologies": [],
  "excludeTechnologies": [],
  "minConfidence": 0,
  "onlyChanged": false,
  "emitUnchanged": false,
  "includeConfidence": true,
  "includeVersions": true,
  "includeEvidence": true,
  "includeSecurityHeaders": true,
  "includeHeaders": false,
  "compactOutput": false,
  "dropEmptyFields": false,
  "concurrency": 8,
  "timeoutSeconds": 20,
  "maxItems": 0
}
```

# Actor output Schema

## `results` (type: `string`):

All collected rows

# 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 = {
    "urls": [
        "stripe.com",
        "shopify.com",
        "nytimes.com"
    ],
    "includeDns": true,
    "includeTls": true,
    "checkDkim": false,
    "categoriesFilter": [],
    "onlyWithTechnologies": [],
    "excludeTechnologies": [],
    "minConfidence": 0,
    "onlyChanged": false,
    "emitUnchanged": false,
    "includeConfidence": true,
    "includeVersions": true,
    "includeEvidence": true,
    "includeSecurityHeaders": true,
    "includeHeaders": false,
    "compactOutput": false,
    "dropEmptyFields": false,
    "concurrency": 8,
    "timeoutSeconds": 20,
    "maxItems": 0
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/tech-stack-detector").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 = {
    "urls": [
        "stripe.com",
        "shopify.com",
        "nytimes.com",
    ],
    "includeDns": True,
    "includeTls": True,
    "checkDkim": False,
    "categoriesFilter": [],
    "onlyWithTechnologies": [],
    "excludeTechnologies": [],
    "minConfidence": 0,
    "onlyChanged": False,
    "emitUnchanged": False,
    "includeConfidence": True,
    "includeVersions": True,
    "includeEvidence": True,
    "includeSecurityHeaders": True,
    "includeHeaders": False,
    "compactOutput": False,
    "dropEmptyFields": False,
    "concurrency": 8,
    "timeoutSeconds": 20,
    "maxItems": 0,
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/tech-stack-detector").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 '{
  "urls": [
    "stripe.com",
    "shopify.com",
    "nytimes.com"
  ],
  "includeDns": true,
  "includeTls": true,
  "checkDkim": false,
  "categoriesFilter": [],
  "onlyWithTechnologies": [],
  "excludeTechnologies": [],
  "minConfidence": 0,
  "onlyChanged": false,
  "emitUnchanged": false,
  "includeConfidence": true,
  "includeVersions": true,
  "includeEvidence": true,
  "includeSecurityHeaders": true,
  "includeHeaders": false,
  "compactOutput": false,
  "dropEmptyFields": false,
  "concurrency": 8,
  "timeoutSeconds": 20,
  "maxItems": 0
}' |
apify call snow_leo_data/tech-stack-detector --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/tech-stack-detector"
        }
    }
}
```

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/oUOZ8rBpGCv0ztgTG/builds/0ivrHovLW7EpXScf0/openapi.json
