# Tech Stack & Buying Signal Enrichment (`flux_pull/tech-stack-buying-signal-enrichment`) Actor

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.

- **URL**: https://apify.com/flux\_pull/tech-stack-buying-signal-enrichment.md
- **Developed by:** [Flux Pull](https://apify.com/flux_pull) (community)
- **Categories:** Lead generation, Developer tools
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 domain scanneds

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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 & Buying Signal Enrichment

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

1. 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.
2. Leave everything else on its defaults. DNS, DMARC and TLS lookups are on and cost no
   bandwidth; `renderJs` is off; change detection is on.
3. 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.
4. 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

```json
{
  "domains": ["allbirds.com", "vercel.com", "wix.com"]
}
```

#### A realistic input

```json
{
  "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?](#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:

```json
{
  "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:

```json
{
  "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: 51` is 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 `includeWellKnown`** if you want robots-derived detections and sitemap hints;
  it costs two small requests per domain.
- **Treat `ats` as a bonus, not a headline.** Applicant tracking systems are almost never
  linked from a homepage — companies link `/careers` on their own domain and the vendor host
  appears one click deeper. Pass `example.com/careers` explicitly when you want ATS
  coverage; that is how `1password.com/careers` resolves to Ashby.
- **Use `categoriesFilter` to 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>`.

```bash
## 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 no `domain_rendered` is charged. Heavy pages can
  exceed the default 25-second `renderTimeoutSecs`; 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 no `domain_rendered` is 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 stamped `renderTruncated: true`
  and some of the 5,594 JavaScript paths and 1,787 DOM selectors were never evaluated.
  Raise `renderTimeoutSecs` for 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.pending` shows it in the
  meantime; `changeConfirmScans: 1` trades the delay back for the noise.
- **Applicant tracking systems are rarely visible from a homepage** — pass a `/careers`
  path 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`](https://github.com/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`](NOTICE.md).

# Changelog

This Actor's version history is a separate document: https://apify.com/flux\_pull/tech-stack-buying-signal-enrichment/changelog.md

# Actor input Schema

## `domains` (type: `array`):

Domains or URLs to analyse. Bare hosts (example.com), full URLs, IDN and ports are all accepted. Duplicates are removed.

## `domainsText` (type: `string`):

Alternative to the list above: paste domains separated by newlines, commas, semicolons or spaces. Merged with 'Domains'.

## `domainsUrl` (type: `string`):

Public URL of a plain-text or CSV-first-column file, one domain per line. Fetched once at start-up (max 50 MB).

## `maxDomains` (type: `integer`):

Hard cap on domains processed this run. 0 means no cap. A safety net for oversized inputs.

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

Parallel domain scans. 20 is a good default; raise for large lists on 2 GB or more of memory.

## `timeoutSecs` (type: `integer`):

Per-HTTP-request timeout. A site that does not answer in time is cut off rather than holding up the run; DNS and TLS still run, and the row bills as domain\_scanned if they detect anything, otherwise as domain\_failed.

## `followWww` (type: `boolean`):

If the apex domain fails, retry the www. variant (and vice versa).

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

TXT / MX / NS / CNAME / SOA records. Free (no bandwidth) and the source of email-provider and SaaS-verification signals. Keep this on.

## `includeDmarc` (type: `boolean`):

One extra TXT query for \_dmarc.<domain>. 'This company publishes a DMARC policy' is a free signal that they run real, managed email. Needs DNS lookups to be on.

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

Certificate issuer, validity, SAN count, wildcard, TLS version. Free and useful for CDN/hosting attribution.

## `includeWellKnown` (type: `boolean`):

Two extra small requests per domain. Adds robots-based detections, sitemap hints and some ATS signals.

## `inlineJsHeuristic` (type: `boolean`):

Approximate JavaScript-variable detections by searching inline script text, capped at 50 confidence and tagged as inlineJs evidence. Turn off for maximum precision.

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

Drop detections below this score. Set to 51 to exclude every inline-JS heuristic result.

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

Leave empty for the full stack. Filtering affects the output only; detection always runs in full so implied technologies stay correct.

## `renderJs` (type: `boolean`):

Launch headless Chromium to evaluate real JavaScript globals, DOM selectors and XHR hosts. Detects client-rendered tools that HTML alone cannot see. Billed as domain\_rendered on top of domain\_scanned.

## `renderConcurrency` (type: `integer`):

Parallel browser contexts. Each one needs about 1 GB of run memory on top of the first GB, so a run is capped at 1 render on 2 GB, 2 on 3 GB and 3 on 4 GB — a higher value here is clamped to what the run memory allows and the run log says so.

## `renderTimeoutSecs` (type: `integer`):

The browser's budget for one page: loading it and reading it, together. Separate from 'Request timeout', which bounds a single HTTP request — a browser loading a heavy page needs far longer than a plain fetch. A page that runs out stays on Tier 1 with renderSkipped: "render\_failed" and nothing extra is charged. Heavy pages behind a proxy need 20–40 seconds.

## `renderBlockResources` (type: `string`):

Which bytes the browser refuses to download. 'default' blocks images, media and fonts — none of them carry a technology fingerprint, and they are most of a page's weight. 'aggressive' also blocks stylesheets, which is faster still but turns off stylesheet-based detections. 'none' downloads everything and is for debugging a render that looks wrong.

## `trackChanges` (type: `boolean`):

Compare each domain against your last scan and emit a vendor\_change record when the stack differs. The first scan of a domain never produces a change.

## `snapshotStoreName` (type: `string`):

Named key-value store holding your scan history. Use different names to keep separate tracking sets.

## `changesDatasetName` (type: `string`):

Named dataset that collects every vendor\_change record, run after run — your change feed. It is created the first time a change is confirmed. This run's own dataset keeps exactly one row per domain, so the two never mix.

## `changeMinConfidence` (type: `integer`):

A technology must score at least this to count as added or removed. Higher than the output floor on purpose, so low-confidence noise never bills you for a change.

## `changeConfirmScans` (type: `integer`):

A technology must be seen in its new state this many scans in a row before it counts as a change and is billed. Ad-heavy sites load a different set of tags on every visit, and 2 is enough to tell that apart from a real move: the row shows the candidate under changes.pending meanwhile, and nothing is charged. Set to 1 to bill on the first scan that sees a difference.

## `purgeSnapshots` (type: `boolean`):

Empty the snapshot store before this run starts. Every domain then looks like a first scan, so no vendor\_change is emitted and nothing is billed for one. This is how you honour a deletion request or start a tracking set over.

## `dryRun` (type: `boolean`):

Compute changes but leave the stored history untouched, and charge nothing. Useful for testing and for costing a large run before you pay for it.

## `proxy` (type: `object`):

Not needed for normal use: we fetch your own target domains and expect no anti-bot. Enable the default datacenter proxy if a target blocks Apify IPs, or supply your own proxy URLs. Residential proxies are NOT supported: this Actor's per-event price includes the bandwidth, and a run configured with the RESIDENTIAL group stops immediately with an explanation rather than scanning. Datacenter proxy usage is included in the event price and is never billed to you on top.

## `userAgent` (type: `string`):

Leave empty to use a realistic Chrome fingerprint (recommended: some sites serve different markup to bots). Set your own to identify your crawler.

## Actor input object example

```json
{
  "domains": [
    "allbirds.com",
    "hubspot.com"
  ],
  "maxDomains": 0,
  "maxConcurrency": 20,
  "timeoutSecs": 10,
  "followWww": true,
  "includeDns": true,
  "includeDmarc": true,
  "includeTls": true,
  "includeWellKnown": false,
  "inlineJsHeuristic": true,
  "minConfidence": 50,
  "categoriesFilter": [],
  "renderJs": false,
  "renderConcurrency": 4,
  "renderTimeoutSecs": 25,
  "renderBlockResources": "default",
  "trackChanges": true,
  "snapshotStoreName": "tech-stack-snapshots",
  "changesDatasetName": "tech-stack-changes",
  "changeMinConfidence": 75,
  "changeConfirmScans": 2,
  "purgeSnapshots": false,
  "dryRun": false,
  "proxy": {
    "useApifyProxy": false
  },
  "userAgent": ""
}
```

# Actor output Schema

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

Every row this run produced, as JSON: exactly one record per scanned domain, with its full technology list, evidence and — under 'changes' — what moved since your previous scan of it.

## `overview` (type: `string`):

One row per domain: the headline stack (CMS, ecommerce, email, ATS), how many technologies were found, and whether anything changed.

## `vendorChanges` (type: `string`):

What moved since the previous scan of each domain: tools added, tools removed, versions upgraded, and moves still waiting for a second confirming scan. Your change history across all runs is kept separately, in the named dataset given by the 'Change feed dataset name' input ('tech-stack-changes' by default) — open it from Storage → Datasets.

## `runSummary` (type: `string`):

Counts and timings for this run: domains scanned, failed, changed and rendered, and the events charged.

# 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 = {
    "domains": [
        "allbirds.com",
        "techcrunch.com",
        "vercel.com",
        "sentry.io"
    ],
    "maxConcurrency": 20,
    "timeoutSecs": 10,
    "minConfidence": 50,
    "renderConcurrency": 4,
    "renderTimeoutSecs": 25,
    "renderBlockResources": "default",
    "snapshotStoreName": "tech-stack-snapshots",
    "changesDatasetName": "tech-stack-changes",
    "changeMinConfidence": 75,
    "changeConfirmScans": 2,
    "proxy": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("flux_pull/tech-stack-buying-signal-enrichment").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 = {
    "domains": [
        "allbirds.com",
        "techcrunch.com",
        "vercel.com",
        "sentry.io",
    ],
    "maxConcurrency": 20,
    "timeoutSecs": 10,
    "minConfidence": 50,
    "renderConcurrency": 4,
    "renderTimeoutSecs": 25,
    "renderBlockResources": "default",
    "snapshotStoreName": "tech-stack-snapshots",
    "changesDatasetName": "tech-stack-changes",
    "changeMinConfidence": 75,
    "changeConfirmScans": 2,
    "proxy": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("flux_pull/tech-stack-buying-signal-enrichment").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 '{
  "domains": [
    "allbirds.com",
    "techcrunch.com",
    "vercel.com",
    "sentry.io"
  ],
  "maxConcurrency": 20,
  "timeoutSecs": 10,
  "minConfidence": 50,
  "renderConcurrency": 4,
  "renderTimeoutSecs": 25,
  "renderBlockResources": "default",
  "snapshotStoreName": "tech-stack-snapshots",
  "changesDatasetName": "tech-stack-changes",
  "changeMinConfidence": 75,
  "changeConfirmScans": 2,
  "proxy": {
    "useApifyProxy": false
  }
}' |
apify call flux_pull/tech-stack-buying-signal-enrichment --silent --output-dataset

```

## MCP server setup

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

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/XWa2cJPBkK6q11ezM/builds/3bQwpAkhXaqCCsKsW/openapi.json
