# Website Tech Stack Detector - Shopify, CMS, Ecommerce Platform (`lukehunter/website-tech-stack-detector`) Actor

Tech lookup for any website: Shopify detector, CMS detector, ecommerce platform and analytics detector in one run, plus ad pixels, chat and payments. Finds Klaviyo stores. Home page only, 166 popular technologies, evidence for every match. $0.01 per site.

- **URL**: https://apify.com/lukehunter/website-tech-stack-detector.md
- **Developed by:** [Luke Hunter](https://apify.com/lukehunter) (community)
- **Categories:** Lead generation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 website analyseds

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

## Website Tech Stack Detector - Shopify, CMS, Ecommerce Platform

**For lead-generation teams, agencies, sales ops and growth marketers who qualify leads by the technology a website runs** ("Shopify stores using Klaviyo", "WordPress sites without GA4", "stores on Magento with no live chat"). Give it a list of websites and you get one row per site: **the technologies detected on its home page** - CMS, ecommerce platform, analytics, ad pixels, marketing tools, chat, payments, reviews, CDN, hosting, frameworks and consent tools - each with a **confidence** and the **evidence** that matched (`script src contains cdn.shopify.com`, `meta generator WordPress 6.4`, `cookie _shopify_y`), plus flat columns (`cms`, `ecommerce`, `analytics`, `marketing`, `payments`, `chat`, `cdn`, `hosting`, `frameworks`) ready to filter on.

Pay-per-result: **$0.01 per site analysed - 1,000 sites = $10**. A site is charged only when its home page was fetched, analysed and at least one technology matched. Sites with nothing detected, blocked, unreachable or disallowed in `robots.txt` are delivered so you can see why, and are **never charged**.

It works as a Shopify detector, CMS detector, ecommerce platform detector and analytics detector for a whole list of sites at once: a bulk tech lookup. It also finds Klaviyo stores.

**Honest scope:** this reads the home page only (one request, plus `robots.txt`) and matches 166 technologies chosen because lead lists actually filter on them, not the 7,000+ of the big databases. It does not run a browser, so tags that only exist after JavaScript runs can be missed. Every match shows its evidence so you can check it in the page source. See "How detection works, and its limits".

### Tech lookup: quick start (1 minute)

1. Open the **Input** tab and paste website domains or URLs (one per line, or a whole spreadsheet column or CSV row at once).
2. Click **Start**, then export the dataset to CSV/Excel/JSON or pull it through the API shown below.

```json
{
  "websites": ["plausible.io", "stumptowncoffee.com", "dauntbooks.co.uk"],
  "maxItems": 100
}
```

Other ways to give it your list: a **link to a CSV/TXT file** (`websitesFileUrl`, for example a Google Sheets "Publish to web" CSV link) or **another Actor's dataset** (`datasetId`, see "Enrich a Google Maps or contact-scraper dataset"). Messy input is fine: bare domains, `https://www.` URLs with paths, email addresses (their domain is used; Gmail, Outlook and other free-mail addresses are skipped), several per line, duplicates.

### What it detects: CMS, ecommerce platform and analytics

166 technologies in these categories (the `category` of each match, and the key of `categories`):

| Category | Technologies |
| --- | --- |
| **CMS** (`CMS`) | WordPress, Wix, Squarespace, Webflow, Drupal, Joomla, Ghost, HubSpot CMS, Framer, TYPO3 |
| **Ecommerce platforms** (`Ecommerce`) | Shopify, WooCommerce, Magento (includes Adobe Commerce), BigCommerce, PrestaShop, Shopware, Squarespace Commerce, Ecwid, Salesforce Commerce Cloud, Snipcart |
| **Analytics and tag managers** (`Analytics`) | Google Analytics 4, Universal Analytics, Google Tag Manager, Plausible, Fathom, Matomo, Hotjar, Microsoft Clarity, Mixpanel, Segment, Amplitude, Heap, Adobe Analytics, Adobe Experience Platform Launch, Simple Analytics, Umami, PostHog, FullStory, Optimizely, VWO, Cloudflare Web Analytics, Vercel Analytics, Yandex Metrica |
| **Ad pixels** (`Advertising`) | Meta Pixel, TikTok Pixel, LinkedIn Insight Tag, Pinterest Tag, Google Ads, Google AdSense, Google Ad Manager, X (Twitter) Pixel, Snapchat Pixel, Reddit Pixel, Microsoft Advertising |
| **Marketing and email** (`Marketing`) | Klaviyo, Mailchimp, HubSpot, Marketo, ActiveCampaign, Omnisend, Brevo, ConvertKit, Attentive, MailerLite |
| **Chat and support** (`Chat`) | Intercom, Drift, Zendesk, Crisp, Tawk.to, LiveChat, Tidio, Gorgias, Freshchat, Olark, Help Scout Beacon |
| **Payments** (`Payments`) | Stripe, PayPal, Klarna, Afterpay, Shop Pay, Square, Braintree, Adyen, Amazon Pay, Affirm, Mollie, Paddle, Razorpay |
| **Reviews** (`Reviews`) | Trustpilot, Yotpo, Judge.me, Okendo, Reviews.io, Stamped.io, Loox |
| **CDN** (`CDN`) | Cloudflare, Fastly, Akamai, AWS CloudFront, Shopify CDN |
| **Hosting** (`Hosting`) | Vercel, Netlify, WP Engine, Kinsta, GitHub Pages, Heroku, Pantheon, Google Cloud |
| **Security and bot protection** (`Security`) | Imperva, reCAPTCHA, hCaptcha, Cloudflare Turnstile |
| **JavaScript frameworks** (`JavaScript framework`) | React, Next.js, Vue.js, Nuxt, Angular, AngularJS, Svelte, SvelteKit, Gatsby, Remix, Astro, Alpine.js, htmx |
| **JavaScript libraries** (`JavaScript library`) | jQuery, jQuery UI |
| **UI frameworks** (`UI framework`) | Bootstrap, Tailwind CSS |
| **Back-end frameworks** (`Web framework`) | ASP.NET, Express, Laravel, Ruby on Rails, Django |
| **Web servers** (`Web server`) | Nginx, Apache, LiteSpeed, Microsoft IIS |
| **Languages** (`Programming language`) | PHP |
| **Cookie consent** (`Consent`) | OneTrust, Cookiebot, CookieYes, Usercentrics, Termly, iubenda, Osano, Complianz |
| **Search** (`Search`) | Algolia |
| **Fonts and icons** (`Fonts`) | Font Awesome, Google Fonts, Adobe Fonts |
| **Maps** (`Maps`) | Google Maps |
| **Video embeds** (`Video`) | YouTube, Vimeo |
| **Page builders** (`Page builder`) | Elementor, Divi |
| **SEO plugins** (`SEO`) | Yoast SEO, Rank Math |
| **Error and performance monitoring** (`Monitoring`) | Sentry, New Relic, LogRocket |
| **Scheduling and forms** (`Scheduling and forms`) | Calendly |
| **Public JavaScript CDNs** (`JavaScript CDN`) | jsDelivr, cdnjs, unpkg, Google Hosted Libraries |

The list is our own: every signature was written for this Actor from how each product announces itself on a home page. It does not copy any third-party fingerprint database.

### Example output

One real result, from a captured copy of the stumptowncoffee.com home page:

```json
{
  "inputUrl": "stumptowncoffee.com",
  "domain": "stumptowncoffee.com",
  "finalUrl": "https://www.stumptowncoffee.com/",
  "status": "ok",
  "statusReason": null,
  "technologyCount": 4,
  "technologies": [
    { "name": "Shopify", "category": "Ecommerce", "confidence": "high", "evidence": "meta shopify-digital-wallet tag present" },
    { "name": "Klaviyo", "category": "Marketing", "confidence": "high", "evidence": "inline script contains _learnq" },
    { "name": "Shop Pay", "category": "Payments", "confidence": "high", "evidence": "script src contains shopifycloud/shop-js" },
    { "name": "Shopify CDN", "category": "CDN", "confidence": "high", "evidence": "script src contains cdn.shopify.com" }
  ],
  "categories": {
    "Ecommerce": ["Shopify"],
    "Marketing": ["Klaviyo"],
    "Payments": ["Shop Pay"],
    "CDN": ["Shopify CDN"]
  },
  "cms": null,
  "ecommerce": "Shopify",
  "analytics": [],
  "marketing": ["Klaviyo"],
  "payments": ["Shop Pay"],
  "chat": [],
  "cdn": "Shopify CDN",
  "hosting": null,
  "frameworks": [],
  "changeType": null,
  "added": null,
  "removed": null,
  "checkedAt": "2026-10-09T12:00:00.000Z"
}
```

Field by field:

- `status`: `ok` (analysed, at least one technology matched: the only charged status), `no-technologies-detected`, `blocked`, `unreachable` or `robots-disallowed`. `statusReason` says why for anything but a clean `ok`.
- `technologies`: every match with its `name`, `category`, `confidence` (`high` or `medium`) and `evidence`.
- `categories`: the same technologies grouped, `{category: [names]}`.
- `cms`, `ecommerce`, `cdn`, `hosting`: the single best match of that kind, else `null`. `analytics`, `marketing`, `payments`, `chat`, `frameworks`: lists of names, empty when none.
- `changeType`, `added`, `removed`: change mode only (see "Track technology changes on a schedule"); `null` otherwise.
- `source`: only when you enrich a dataset (see below).

### How detection works, and its limits

For each site the Actor sends **one GET request for the home page** (and reads `robots.txt` first). From that single response it looks at:

- the **script sources**, **link tags** and **inline scripts** in the HTML (JSON data blocks such as JSON-LD and `__NEXT_DATA__` are not searched, they are data not code);
- the **meta tags** (for example `generator`) and distinctive **HTML markup** (comments, script bodies and style bodies are removed first, so a commented-out snippet is not a detection);
- the **response headers** (`server`, `x-powered-by`, `cf-ray`, `x-vercel-id`, ...) and the **names** of the cookies the server sets (never their values).

Each signature is graded: **high** for an unambiguous match (the product's own script host, a documented header, cookie or snippet), **medium** for weaker evidence (a generic file name, a class prefix). A technology implied by another (Next.js implies React, WooCommerce implies WordPress) is reported with `evidence: "implied by Next.js"`. The Actor favours precision over recall: visible text such as "we use Shopify" is never a signal.

What this means for you:

- **Home page only.** A technology used only on checkout, product or app pages is not seen. No second page, sitemap, script file or stylesheet is ever requested.
- **No browser.** Tags injected by JavaScript after load (many tag-manager-injected pixels, consent-gated scripts, single-page apps that render everything client-side) can be missed. Google Tag Manager is reported when it is there, but the tags it injects are not.
- **166 technologies, not 7,000.** A technology outside the list is simply not reported. The list is chosen for lead qualification, not completeness.
- **Absence is not proof.** `ecommerce: null` means "none of the known platforms was detected on the home page", not "this site sells nothing".
- **Sites that block bots** are reported as `blocked`; nothing is worked around.

### Enrich a Google Maps or contact-scraper dataset

Google Maps scrapers give each business's website; this Actor tells you what that website runs. Hand it the dataset and every row's website is analysed, with the source row's fields (`title`, `placeId`, `address`, ...) copied into `source` so results join straight back.

```json
{
  "datasetId": "YOUR_DATASET_ID",
  "maxItems": 500
}
```

**Chain it automatically:** in the source Actor's **Integrations** tab add this Actor and set `datasetId` to `{{resource.defaultDatasetId}}`, so every finished Google Maps run is enriched with no manual step. **By hand:** copy the dataset ID from the source run's **Storage** tab, or pick the dataset in the input form (it uses your own access: you never paste a token).

It reads the first `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website` or `site` column that has a value (set `datasetWebsiteField` to use another column). The output of our own **Email & Contact Scraper** works the same way (its `domain` column), so a lead list can go Google Maps, then contacts, then tech stack. Rows without a website, with a Google Maps or social media link instead of a website, or that repeat a website (a chain's branches share one result row) are skipped and listed with their row number in the `INPUT_REPORT` record.

### Shopify detector: find Klaviyo stores

The flat columns are made for filtering. The Apify dataset API returns whole rows, so filter in your own code, a Make or Zapier step, or `jq`. Fetch only the columns you need with `fields`:

```bash
curl -s "https://api.apify.com/v2/datasets/DATASET_ID/items?token=$APIFY_TOKEN&clean=true&fields=domain,ecommerce,marketing" \
  | jq -r '.[] | select(.ecommerce == "Shopify" and (.marketing | index("Klaviyo"))) | .domain'
```

```js
// Shopify stores that use Klaviyo, but no live chat
const hits = items.filter((r) => r.ecommerce === 'Shopify' && r.marketing.includes('Klaviyo') && r.chat.length === 0);
```

```python
## WordPress sites without GA4 (and without a tag manager that might load it)
hits = [r["domain"] for r in items
        if r["cms"] == "WordPress"
        and "Google Analytics 4" not in r["analytics"]
        and "Google Tag Manager" not in r["analytics"]]
```

More recipes: **Magento stores** (`ecommerce == "Magento"`); **sites on Stripe but not Klarna** (`"Stripe" in payments and "Klarna" not in payments`); **Next.js sites on Vercel** (`"Next.js" in frameworks and hosting == "Vercel"`); **sites with a cookie banner but no analytics** (`categories.Consent` present, `analytics` empty); **only confident matches** (filter `technologies` on `confidence == "high"`). In the Console, the **Tech stack columns** dataset view shows the flat columns as one table. Leave out sites whose `status` is not `ok` when you only want analysed sites.

### Track technology changes on a schedule

Turn on **Only changed technologies** (`onlyChangedSinceLastRun`) and run the Actor on a Schedule. The first run delivers every site once (`changeType: "firstSeen"`) and remembers its technologies. Every later run delivers **only the sites whose detected technology set changed**, with `added` (what appeared) and `removed` (what disappeared); unchanged sites are not delivered and not charged.

```json
{
  "websites": ["plausible.io", "stumptowncoffee.com"],
  "onlyChangedSinceLastRun": true
}
```

A changed row looks like `"changeType": "changed", "added": ["Gorgias"], "removed": ["Mailchimp"]`. A site that is down or blocks the request later keeps its last known technologies and is not reported again. Use it to hear when a prospect adopts or drops a tool (a competitor's, or yours): "just installed Klaviyo", "dropped Intercom".

**Alerts to chat or phone (optional):** fill in a Discord, Telegram, Slack, Microsoft Teams, Google Chat, ntfy, Pushover or generic JSON webhook destination in the **Notifications** section of the input, and each run sends one message per changed site, such as `Tech change: example.com added Klaviyo, removed Mailchimp`. Notifications are free, nothing is sent when nothing changed, and the first run (the baseline) sends nothing.

### Input

| Field | What it does |
| --- | --- |
| `websites` | Domains, URLs or emails, any messy list. Up to 10,000 per run. |
| `websitesFileUrl` | An https link to a CSV/TXT file (up to 5 MB and 20,000 lines), for example a published Google Sheet. |
| `datasetId` | Another Actor's dataset to enrich, with `datasetWebsiteField` and `datasetPassthroughFields` to pick columns. |
| `outputFormat` | `full` (default, every field) or `compact` (a smaller row for AI agents, see "Use with AI agents (MCP)"). Charging is identical. |
| `maxItems` | Hard cap on delivered rows, default 100, maximum 10,000. Also your cost cap. |
| `maxConcurrency` | Sites read at once, default 5, maximum 10. Each site still gets about one request per second. |
| `onlyChangedSinceLastRun`, `stateStoreName` | Change mode (see above). |
| Notification fields | Optional chat, phone and webhook destinations for changes. |

#### Input details

The Store form and the MCP tool schema show a short description for each field (an AI agent sees only the first 500 characters of each); the longer rules are here.

- **`websites`**: Paste anything, one per line or a whole spreadsheet column or CSV row. Each entry may be a domain (`plausible.io`), a URL with a path, query or `www`, an email address (its domain is used; free-mail addresses such as gmail, outlook, yahoo, icloud, proton, gmx, web.de, mail.ru, qq, 163 or aol are skipped), or several of these on one line separated by commas, semicolons, spaces or tabs. Entries are de-duplicated by registrable domain (`www.example.co.uk` and `example.co.uk` are one company; the first one given is read). Entries that cannot be used are never an error: they are skipped and listed, with the reason, in the run status and in the `INPUT_REPORT` record of the run's key-value store. Only the home page of each site is fetched (one request, plus `robots.txt`); a path you give is ignored. Up to 10,000 websites per run.
- **`websitesFileUrl`**: For example a Google Sheets "Publish to web" CSV link (File > Share > Publish to web > "Comma-separated values (.csv)"), a Dropbox shared link or a GitHub "Raw" link. The file is downloaded once (at most 5 MB and 20,000 lines) and every cell is read exactly like an entry of `websites`; it is added to the websites you list and de-duplicated with them. The link must open without signing in. The file is your own input list, so `robots.txt` is not applied to downloading it; it is never requested from private or internal addresses. The websites themselves are still read politely, with `robots.txt` checked first.
- **`datasetId`**: The dataset is typically the default dataset of another Actor's run, for example a Google Maps scraper (Google Maps Scraper by Compass is one) or our Email & Contact Scraper. To chain it automatically, open the source Actor's **Integrations** tab, add this Actor and set the field to `{{resource.defaultDatasetId}}`; by hand, copy the dataset ID from the source run's **Storage** tab. It is read with this run's own access (the account that starts this Actor): you never give a token, and it must be a dataset that account can open. Up to 20,000 rows are read; rows without a website, with a Google Maps, social network or marketplace link instead of a website, with free-mail or invalid values, or that repeat a website are skipped and listed with their row number in `INPUT_REPORT`. It is added to `websites` and `websitesFileUrl` and de-duplicated with them. A link to the dataset in Console or the API is accepted too. `source` on every result row carries fields of the source row so results can be joined back.
- `datasetWebsiteField`: the column name is matched exactly as spelled. Left empty, the first non-empty of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site` is used. A value that is a Google Maps, social network (Facebook, Instagram, LinkedIn, X, YouTube, TikTok, ...) or marketplace link is never read as a website, whichever column it is in.
- `datasetPassthroughFields`: left empty, `title`, `placeId`, `address`, `city`, `countryCode`, `phone`, `categoryName`, `totalScore`, `reviewsCount` and `url` are copied, whichever the row has. At most 15 columns and 2,000 characters per value (longer text is cut; a list or object is copied as JSON text). `source` also always has `datasetRow` (the row's number in the dataset, counting from 0), `sourceRowCount` (how many dataset rows had this website; a chain of branches shares one website) and `otherRows` (`placeId` or title of the other rows of the same website, at most 20); those three names are reserved. When several rows have the same website, the first row's columns are kept.
- **`maxItems`**: you are charged $0.01 only for a row whose home page was analysed and matched at least one technology. Rows for sites with no technology detected, blocked, unreachable or disallowed by `robots.txt` are delivered (so you can see why) and count toward this cap, but are not charged.
- **`maxConcurrency`**: each website only ever receives about one request per second (`robots.txt`, then the home page), so this never hammers a single site; a higher number just finishes a long list sooner. Keep the default unless you have thousands of sites.
- **`onlyChangedSinceLastRun`**: `added` lists what appeared (for example Klaviyo) and `removed` what disappeared (for example Mailchimp). The first run delivers every website and remembers it (`changeType` `firstSeen`); later runs deliver only what changed, and unchanged websites are not delivered or charged. A site that is blocked or down on a later run keeps its last known technologies and is not delivered.
- **`stateStoreName`**: only used in change mode; keeps each website's last technology set across scheduled runs. Leave the default unless several tasks must keep separate histories.

### Output fields

`inputUrl`, `domain`, `finalUrl`, `status`, `statusReason`, `technologyCount`, `technologies` (each `name`, `category`, `confidence`, `evidence`), `categories`, `cms`, `ecommerce`, `analytics`, `marketing`, `payments`, `chat`, `cdn`, `hosting`, `frameworks`, `changeType`, `added`, `removed`, `checkedAt` and, for dataset runs, `source`. Field names are stable.

### Data protection

The Actor reads the public home page every visitor can see. It stores no cookie values (only cookie names, to recognise technologies), collects no personal data, logs no page content, and requires no login. It identifies itself with its own User-Agent, respects `robots.txt` and never tries to get around a block, a login or a bot check.

### Use with AI agents (MCP)

This Actor works as a tool for Claude, ChatGPT, Cursor and other MCP clients through [Apify's MCP server](https://mcp.apify.com). The only input a call needs is `websites`.

**Claude Desktop** (`claude_desktop_config.json`), with your own token from Apify Console > Settings > API & Integrations:

```json
{
  "mcpServers": {
    "apify-tech-stack": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://mcp.apify.com?tools=lukehunter/website-tech-stack-detector",
        "--header", "Authorization: Bearer ${APIFY_TOKEN}"
      ],
      "env": { "APIFY_TOKEN": "<YOUR_APIFY_TOKEN>" }
    }
  }
}
```

Clients that support OAuth (Claude.ai, VS Code) can connect with just the URL `https://mcp.apify.com?tools=lukehunter/website-tech-stack-detector` and sign in to Apify. Never paste a real token into a shared file or chat.

**Example.** You ask: "What ecommerce platform and marketing tools do stumptowncoffee.com and dauntbooks.co.uk use?" The agent calls the tool `lukehunter--website-tech-stack-detector` with:

```json
{
  "websites": ["stumptowncoffee.com", "dauntbooks.co.uk"],
  "outputFormat": "compact",
  "maxItems": 10
}
```

`outputFormat: "compact"` returns, per site, the status, the technology count and the stack grouped by category, without the per-technology evidence strings, so the result fits an agent's context. It is charged exactly like the default `"full"` output: $0.01 per site where at least one technology is detected, nothing otherwise. Set `maxItems` to cap spend.

### Pricing and cost control

**$0.01 per site analysed** (one `site` event). A row is charged only when the home page was fetched, analysed and matched at least one technology (`status: ok`).

| Sites analysed | Cost |
| --- | --- |
| 100 | $1 |
| 1,000 | $10 |
| 10,000 | $100 |

Never charged: sites with `no-technologies-detected`, `blocked`, `unreachable` or `robots-disallowed` (they are delivered so you see why), sites that did not change in change mode, and notifications. Cost is capped by `maxItems` (default 100, so a default run costs at most $1) and by the spending limit you set on the run: the Actor never delivers a row your limit cannot pay for. Compute is cheap: a site costs a fraction of a cent of Apify platform usage on top of that, shown on your usage page.

### Limitations

- Home page only: one GET per site, no deep crawl, no headless browser. Client-side-only tags and technologies used only on inner pages are not seen.
- 166 technologies. Anything outside the list is not reported. Detection is evidence-based, so a technology hidden behind a tag manager, a consent wall or a proxy that rewrites asset hosts can be missed or graded `medium`.
- No version numbers beyond what a site states in a `generator` meta tag (shown in the evidence).
- Sites that block bots, serve a bot-check page, forbid the home page in `robots.txt`, or are down are reported as such and not charged.
- Technologies are read from what the server sends to a plain, honestly identified client. A site may serve a different page to a real browser.

### Use it from your code, Make, Zapier or n8n

Built for automated pipelines: small runs, stable field names, one row per item, and you pay only for rows actually delivered.

**One API call that runs the Actor and returns the data** (replace `$APIFY_TOKEN` with your token from Apify Console → Settings → Integrations):

```bash
curl -s -X POST "https://api.apify.com/v2/acts/lukehunter~website-tech-stack-detector/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"websites": ["plausible.io", "stumptowncoffee.com", "dauntbooks.co.uk"], "maxItems": 20, "maxConcurrency": 5}'
```

**JavaScript** (`npm i apify-client`):

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('lukehunter~website-tech-stack-detector').call({
  "websites": [
    "plausible.io",
    "stumptowncoffee.com",
    "dauntbooks.co.uk"
  ],
  "maxItems": 20,
  "maxConcurrency": 5
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

**Python** (`pip install apify-client`):

```python
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ['APIFY_TOKEN'])
run = client.actor('lukehunter~website-tech-stack-detector').call(run_input={"websites": ["plausible.io", "stumptowncoffee.com", "dauntbooks.co.uk"], "maxItems": 20, "maxConcurrency": 5})
items = client.dataset(run['defaultDatasetId']).list_items().items
```

**Schedules, webhooks and no-code tools:** save your input as a Task, add a Schedule (e.g. hourly or daily), and attach a webhook or one of the Integrations tab's connectors (Make, Zapier, n8n, Google Sheets, Slack) to push each run's results into your own system.

**Only new items each run:** set `"onlyChangedSinceLastRun": true` on a scheduled Task and every run after the first delivers, and charges for, only items it hasn't delivered before for that same input. That makes it a ready-made change feed.

**Reliability:** an automated test runs this Actor's parser against the live site every day, and failures are fixed fast.

**Feedback:** an honest rating on the Store page, good or bad, helps others decide if this Actor fits their needs. Missing a field or found a problem? Open an issue on the Actor page.

### FAQ

#### Is this a full BuiltWith or Wappalyzer replacement?

No, and it does not claim to be. Those tools know thousands of technologies and some also crawl more pages or keep history. This Actor detects 166 popular technologies from the home page and shows the evidence for every match, for $0.01 per site and no subscription. If your lead filters are things like "Shopify + Klaviyo" or "WordPress without GA4", it covers them; if you need long-tail technologies, it does not.

#### Why does a match say `confidence: medium`?

Medium means the evidence is plausible but not unique to that product, for example a generic file name such as `webflow.js` or a class prefix. Filter on `confidence == "high"` when you want only unambiguous matches. The `evidence` string says exactly what matched.

#### Why was a site not charged?

Only `status: ok` rows are charged: the home page was analysed and at least one technology matched. `no-technologies-detected` (a JavaScript-only shell or a hand-built site), `blocked`, `unreachable` and `robots-disallowed` rows are free, and `statusReason` explains each.

#### Why is a technology I can see on the site not detected?

Usually because it is loaded after the page renders (by a tag manager or a script) or lives on an inner page. Only the home page's HTML, headers and cookie names are read. If the technology is in the page source of the home page and in the list, open an issue on the Actor page with the site and we will check the signature.

#### Does it detect which tags Google Tag Manager loads?

No. It reports Google Tag Manager when its container is on the page, but the tags GTM injects at runtime are not in the HTML.

#### Does it respect robots.txt and avoid blocks?

Yes. `robots.txt` is read first and a disallowed home page is never requested (`status: robots-disallowed`). A site that answers 401, 403 or 429 or shows a bot check is reported as `blocked`; nothing is retried from another angle, no proxy or changed identity is used.

#### Can I use a Google Sheet or a CSV as the input?

Yes. Use **File > Share > Publish to web > Comma-separated values (.csv)** in Google Sheets, paste the link into `websitesFileUrl`, and every cell is read like an entry of `websites`. The file is your own list, so `robots.txt` is not applied to downloading it.

#### Can I enrich the output of a Google Maps scraper?

Yes: give its dataset ID as `datasetId`, or add this Actor in the Google Maps scraper's Integrations tab with `{{resource.defaultDatasetId}}`. Each result carries the source row's fields in `source` so you can join it back.

#### How do I only hear about sites whose tech stack changed?

Turn on `onlyChangedSinceLastRun`, schedule the Actor, and optionally add a chat or phone destination under Notifications. See "Track technology changes on a schedule".

#### Does it return version numbers?

Only the ones a site declares itself in a `generator` meta tag, shown in the `evidence` (for example `meta generator WordPress 6.4`). It does not guess versions.

### Related Actors

Other data tools from the same developer, built to the same standard: official or public sources, hard cost caps, and honest documentation of limits.

- **[Domain Email Provider Lookup](https://apify.com/lukehunter/email-provider-mx-lookup)**: the email provider (Google Workspace, Microsoft 365, Zoho and more), MX, SPF and DMARC setup of any domain list, with provider-change alerts.
- **[French Company Leads](https://apify.com/lukehunter/french-companies-sirene)**: French companies (entreprises) from the official Sirene-based API, filtered by NAF code, department, headcount and creation date, with SIREN/SIRET, officers and new-company alerts.
- **[Poland KRS Company Lookup](https://apify.com/lukehunter/poland-krs-company-lookup)**: Polish companies from the official KRS API by KRS number: NIP, REGON, address, share capital, PKD and the board as published, with optional VAT status.
- **[Website Contact Scraper](https://apify.com/lukehunter/company-contact-scraper)**: emails, phone numbers and social profile links from any list of company domains, with the page each came from and a changed-contacts-only mode for scheduled runs.
- **[Impressum Scraper](https://apify.com/lukehunter/impressum-scraper)**: legal entity data from the imprint of any company website in Germany, Austria and Switzerland: legal name and form, managing directors, HRB/HRA or FN register, VAT id and address, with a changed-data-only mode for scheduled runs.
- **[Google Maps Email Extractor & Lead Enricher](https://apify.com/lukehunter/lead-enricher)**: emails, phones, social links, named people and the tech stack of every business in your Google Maps Scraper results, one enriched row per business, with filters such as only Shopify stores with an email.
- **[Federal Register Rule Effective-Date Radar](https://apify.com/lukehunter/federal-rule-effective-date-radar)**: final federal rules about to take effect, with citations, dockets and urgency.
- **[UK Tenders Monitor](https://apify.com/lukehunter/uk-tenders-monitor)**: new UK public-sector tenders from Contracts Finder and Find a Tender, filtered by CPV code, value, region and buyer, with Slack, Discord and Telegram alerts.

# Actor input Schema

## `websites` (type: `array`):

REQUIRED unless websitesFileUrl or datasetId is given. Websites to detect the technology stack of (CMS, ecommerce, analytics, marketing, payments, chat, CDN, hosting), as domains or URLs, e.g. \["plausible.io", "https://www.stumptowncoffee.com"]. One per line or a pasted column. Only the home page is fetched (a path is ignored). De-duplicated by domain; up to 10,000 per run. Unusable entries are listed in INPUT_REPORT, never an error.

## `websitesFileUrl` (type: `string`):

Optional https link to a public .csv or .txt file of websites, e.g. "https://example.com/sites.csv" (a Google Sheets "Publish to web" CSV link, Dropbox shared link or GitHub Raw link works). Max 5 MB / 20,000 lines; every cell is read like an entry of the list above, and the result is merged and de-duplicated with it. Must open without signing in.

## `datasetId` (type: `string`):

Optional. ID of an Apify dataset whose rows have a website, e.g. a Google Maps Scraper or Email & Contact Scraper run (defaultDatasetId), such as "AbCdEf123456". Each website is analysed and `source` carries the row's fields to join back. To chain automatically set it to {{resource.defaultDatasetId}} in the source Actor's Integrations tab. Read with your own account access (no token needed); up to 20,000 rows. Merged with Websites and the file URL.

## `datasetWebsiteField` (type: `string`):

Name of the dataset column holding the website, exactly as spelled, e.g. "website". Only used with a dataset. Leave empty to auto-detect (website, websiteUrl, web, url, domain, homepage, companyWebsite, company_website, site; the first non-empty wins). Google Maps, social network and marketplace links are never read as a website.

## `datasetPassthroughFields` (type: `array`):

Optional, only with datasetId. Extra dataset columns to copy into each result's `source` object, e.g. \["title", "placeId"], max 15 columns. Leave empty for the common Google Maps columns (title, placeId, address, city, phone, categoryName, totalScore, url ...). Values are cut at 2,000 characters. datasetRow, sourceRowCount and otherRows are always added and reserved.

## `outputFormat` (type: `string`):

"full" (default): every field, including each technology's evidence. "compact": a much smaller row for AI agents: domain, status, technologyCount, the stack by category, the best cms / ecommerce / cdn / hosting and medium-confidence names. Charging is identical in both formats. Example: "compact" when results are read into an LLM context.

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

Maximum websites to return, integer 1-10000, default 100. Also your cost cap: at most maxItems x $0.01, charged only for sites where a technology was detected. For a quick test use 5 (e.g. maxItems 5). Sites with nothing detected, blocked or disallowed by robots.txt are delivered free and count toward the cap.

## `maxConcurrency` (type: `integer`):

Websites read in parallel, integer 1-10, default 5, e.g. 10 for a very long list. Each site still receives only about one request per second, so a higher number just finishes sooner. Keep the default unless you have thousands of sites.

## `onlyChangedSinceLastRun` (type: `boolean`):

true returns a website only if its detected technologies changed since the previous scheduled run (`added` and `removed` list the difference). Default false: a normal one-off run returns every website. Agents making one-off calls should leave it false. Example: run weekly on a Schedule with true to see when a site adds Klaviyo or drops Mailchimp; unchanged sites are not charged. The first run delivers everything (changeType firstSeen).

## `stateStoreName` (type: `string`):

Only used when change mode is on. Name of the key-value store that remembers each website's last technology set between scheduled runs, e.g. "website-tech-stack-detector-state". Letters, numbers and hyphens only. Leave the default unless several tasks need separate histories.

## `discordWebhookUrl` (type: `string`):

Post results to a Discord channel. In Discord: Server Settings -> Integrations -> Webhooks -> New Webhook -> choose the channel -> Copy Webhook URL. Example: https://discord.com/api/webhooks/<id>/<token>. Leave empty to skip Discord.

## `telegramBotToken` (type: `string`):

Post results to Telegram. Create a bot by chatting with @BotFather on Telegram (send /newbot) and paste the token it gives you, e.g. 123456789:AA.... Needs "Telegram chat ID" below. Leave empty to skip Telegram.

## `telegramChatId` (type: `string`):

Where the bot should post: a numeric chat id (e.g. 123456789, or -1001234567890 for a group or channel) or a public @channelusername. The bot must be added to that chat or channel first (as an administrator for a channel), and for a private chat you must press Start on the bot. Needs "Telegram bot token" above.

## `slackWebhookUrl` (type: `string`):

Post results to a Slack channel (Block Kit cards, up to 10 per message). Create an incoming webhook at api.slack.com/apps -> Create New App -> Incoming Webhooks -> Add New Webhook to Workspace. Example: https://hooks.slack.com/services/T000/B000/XXXX. Leave empty to skip Slack.

## `slackCompatibleWebhookUrl` (type: `string`):

Post results to a Mattermost or Rocket.Chat incoming webhook, which accept Slack-style webhook payloads. Must be a public https:// address, e.g. https://chat.example.com/hooks/abc123 (no localhost, private IP or .local/.internal hosts). These apps do not render Slack Block Kit, so they get a plain Markdown list. For real Slack use "Slack webhook URL" above. Leave empty to skip.

## `teamsWebhookUrl` (type: `string`):

Post results to a Teams channel as an Adaptive Card. In Teams: channel -> ... -> Workflows -> "Post to a channel when a webhook request is received" -> copy the URL. The host must end in .logic.azure.com or .powerplatform.com, e.g. https://prod-12.westeurope.logic.azure.com/workflows/... The retired Office 365 connector webhooks are not supported. Leave empty to skip Teams.

## `googleChatWebhookUrl` (type: `string`):

Post results to a Google Chat space. In the space: Apps & integrations -> Webhooks -> Add webhook -> copy the URL. Example: https://chat.googleapis.com/v1/spaces/<space>/messages?key=...\&token=.... Leave empty to skip Google Chat.

## `ntfyTopic` (type: `string`):

Get a phone push with no account via ntfy (ntfy.sh). Install the ntfy app, subscribe to a topic name, and enter the same name here. Anyone who knows the topic can read it, so pick a long, unguessable name (letters, numbers, - and \_), e.g. acme-leads-7k2x9q. At most 10 individual pushes per run, then one "...and N more". Leave empty to skip ntfy.

## `ntfyServer` (type: `string`):

Only used with "ntfy topic". Leave as https://ntfy.sh, or enter the public https:// address of your own ntfy server, e.g. https://ntfy.example.com.

## `ntfyToken` (type: `string`):

Only used with "ntfy topic", for a self-hosted server or a reserved topic that needs a login. An access token from ntfy (starts with tk\_, e.g. tk_abc123...). Leave empty for open topics.

## `pushoverUserKey` (type: `string`):

Get a phone push via Pushover. Your user key (30 characters, e.g. uQiRzpo4DXghDmr9QzzfQu27cmVRsG) is shown on the Pushover dashboard (pushover.net). Needs "Pushover app token" below. At most 10 individual pushes per run, then one "...and N more". Leave empty to skip Pushover.

## `pushoverAppToken` (type: `string`):

The API token of a Pushover application you create at pushover.net/apps/build (name it after this Actor), e.g. azGDORePK8gMaC0QOYAMyEEuzJnyUi. Needs "Pushover user key" above.

## `webhookUrl` (type: `string`):

POST the delivered rows as JSON to any public https:// URL: {source, actor, runId, count, items}. At most 100 rows per POST; every delivered row is sent regardless of the max-notifications cap. For example, route results to email, a spreadsheet or any app. Localhost, private IP and .local/.internal addresses are rejected. Leave empty to skip.

## `maxNotifications` (type: `integer`):

Caps how many results are sent to chat and phone channels per run (Discord, Telegram, Slack, Teams, Google Chat, ntfy, Pushover), 1-100, default 25 (e.g. 10). Extra results are still in the dataset. The generic webhook is not capped. Only used when a destination above is set.

## Actor input object example

```json
{
  "websites": [
    "plausible.io",
    "stumptowncoffee.com",
    "dauntbooks.co.uk"
  ],
  "outputFormat": "full",
  "maxItems": 100,
  "maxConcurrency": 5,
  "onlyChangedSinceLastRun": false,
  "stateStoreName": "website-tech-stack-detector-state",
  "ntfyServer": "https://ntfy.sh",
  "maxNotifications": 25
}
```

# Actor output Schema

## `websites` (type: `string`):

No description

## `stack` (type: `string`):

No description

## `inputReport` (type: `string`):

No description

# 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 = {
    "websites": [
        "plausible.io",
        "stumptowncoffee.com",
        "dauntbooks.co.uk"
    ],
    "maxItems": 100,
    "maxConcurrency": 5
};

// Run the Actor and wait for it to finish
const run = await client.actor("lukehunter/website-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 = {
    "websites": [
        "plausible.io",
        "stumptowncoffee.com",
        "dauntbooks.co.uk",
    ],
    "maxItems": 100,
    "maxConcurrency": 5,
}

# Run the Actor and wait for it to finish
run = client.actor("lukehunter/website-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 '{
  "websites": [
    "plausible.io",
    "stumptowncoffee.com",
    "dauntbooks.co.uk"
  ],
  "maxItems": 100,
  "maxConcurrency": 5
}' |
apify call lukehunter/website-tech-stack-detector --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lukehunter/website-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/KKJ6T7mIrVKbujEG0/builds/EKdMTQX1sO8zQgU9Q/openapi.json
