# WKO Firmen A-Z Scraper - Austrian Company Leads (`scrapersdelight/wko-firmen-at-scraper`) Actor

From $2.50 per 1,000 companies, no start fee. Austria's WKO chamber directory firmen.wko.at: name, address, email, phone, mobile, website, plus GISA trade licences and Firmenbuch numbers per company. Measured fill: email on 32.4% of rows, address on 100%. Paste any trade x region list URL.

- **URL**: https://apify.com/scrapersdelight/wko-firmen-at-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Lead generation, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.50 / 1,000 per company row returneds

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

## WKO Firmen A-Z Scraper — Austrian company leads

Reads company result lists from **firmen.wko.at**, the Wirtschaftskammer Österreich's public
*Firmen A-Z* directory, and returns one row per company location with whatever contact details that
company published, plus — optionally — the GISA trade licences off its own company page.

**$2.50 per 1,000 companies. No run-start fee.** Rows your filters remove are never delivered and
never charged. A company that turns up in two of your lists is charged once.

***

### Read this before you buy: 64.9% of these companies publish no contact details

This is a chamber-of-commerce register, not a lead database. WKO shows an email or a phone number
only for members who opted in, and most did not. Counted on **185 companies across 7 trades** on
2026-09-16 — by parsing the bytes and counting, not by estimating:

| field | filled | % |
|---|---:|---:|
| `companyName`, `companyId`, `locationId` | 185 / 185 | **100%** |
| `streetAddress`, `postalCode`, `city`, `fullAddress` | 185 / 185 | **100%** |
| `detailUrl`, `companyUrl` | 185 / 185 | **100%** |
| **`email`** | 60 / 185 | **32.4%** |
| `phone` (landline) | 45 / 185 | 24.3% |
| `mobilePhone` | 38 / 185 | 20.5% |
| a phone of either kind | 63 / 185 | 34.1% |
| `website` | 53 / 185 | 28.6% |
| `businessDescription` (the trading name under the company name) | 55 / 185 | 29.7% |
| **an email OR a phone** | 65 / 185 | **35.1%** |
| **neither an email nor a phone** | 120 / 185 | **64.9%** |
| `statusBadges` (any badge at all) | 8 / 185 | 4.3% |
| `isDormant` (`aktuell ruhend gemeldet`) | 3 / 185 | 1.6% |
| `certificateCount` (`N Zertifikat`) | 4 / 185 | 2.2% |

Empty means genuinely absent: when a company publishes nothing, the whole `<div class="contact-info">`
block is missing from the page. Not one row in 185 carried a blank, a dash or an `"N/A"` pretending
to be a value.

**It varies enormously by trade, and that is the lever you have.** Same day, same parser:

| list | companies read | with an email |
|---|---:|---:|
| `werbeagentur / tirol` | 30 | **25 (83.3%)** |
| `tischler / wien` | 30 | 11 (36.7%) |
| `freizeitbetrieb / burgenland` | 30 | 8 (26.7%) |
| `elektrotechnik / wien` | 30 | 7 (23.3%) |
| `fliesenleger / steiermark` | 30 | 7 (23.3%) |
| `gerüstverleiher / linz-stadt_bezirk` | 5 | 1 (20.0%) |
| `gasthaus / wien-23-bezirk-liesing` | 30 | **1 (3.3%)** |

Professional services publish; hospitality does not. Pick your trade accordingly, and set
**"Only companies with… an email"** so you are never charged for a row you cannot act on.

Where an email does exist it is a good one: of the 60 addresses, **49 distinct domains** and only
9 free-mail (gmail/gmx/aon and friends) — 85% are on the company's own domain.

**The company page does NOT rescue the missing emails.** Tested here directly: for 23 companies whose
list row and company page were both read, the company page supplied an email for **0** of them and
agreed with the list on email presence **23 out of 23**. There is no second surface. (A separate
earlier probe reached the same conclusion on 44 more companies.)

***

### What the company page DOES add

Turn on **"Also open each company page"** and every row you are charged for also carries its
regulatory identity. Counted on **80 company pages**:

| field | filled | % |
|---|---:|---:|
| `gln` — GLN of the public administration | 80 / 80 | **100%** |
| `gisaNumbers` — GISA trade-register numbers | 80 / 80 | **100%** |
| `tradeBranches` — Berufszweig | 80 / 80 | **100%** |
| `tradeLicences` — the full licence records | 80 / 80 | **100%** |
| `commercialRegisterNumber` + `commercialRegisterCourt` — Firmenbuch | 41 / 80 | 51.2% |
| `foundedYear` | 23 / 80 | 28.7% |
| `vatId` — UID-Nummer (ATU…) | 17 / 80 | 21.3% |
| `legalForm` — Rechtsform | 10 / 80 | 12.5% |

Sole traders have no Firmenbuch entry at all, which is most of the gap — those fields come back
`null`, never as an invented blank.

Each licence in `tradeLicences` (109 licences over those 80 companies, **1.36 per company**, up to 7):

| sub-field | filled | % |
|---|---:|---:|
| `tradeGroup` — the Fachgruppe, e.g. "LI Tischler und Holzgestalter" | 109 / 109 | 100% |
| `wording` — the full Gewerbewortlaut, the licence text itself | 109 / 109 | 100% |
| `branch`, `gisaNumber`, `authority` | 106 / 109 | 97.2% |
| `since` — licence start date, as an ISO date | 107 / 109 | 98.2% |
| `managingDirector` — gewerberechtliche Geschäftsführung | 42 / 109 | 38.5% |

Enrichment costs about **42 KB and one extra request per company**, and it runs only on rows that
will actually be delivered. **The price per company does not change.**

***

### What was cut, and why

Fields WKO publishes that this Actor deliberately does **not** emit:

- **`BIC` and `IBAN`.** WKO prints a company's bank details as part of its statutory media
  disclosure. Present on 2 of 23 pages in the first sample. They have no lead-gen use and shipping
  a column of Austrian bank accounts is not something to do by accident, so they are dropped.
- **`Geschäftsbezeichnung`** from the company page. It is the same string as the list page's
  `businessDescription`, which is filled more often (29.7% vs 17.4%), so carrying both would be two
  columns of one fact.
- **Fax.** There is no fax number anywhere on this surface. Across 185 company rows the markup
  contains exactly five kinds of contact link — landline, mobile, email, website, and the Google
  Maps route link — and no fax. The field is not offered rather than offered and always empty.

***

### What one row looks like

```json
{
  "companyId": "9f6b0c29-41cb-4b41-8b05-44231ffd0806",
  "locationId": 9,
  "companyName": "Podersdorf Tourismus- und Freizeit- betriebsgesellschaft m.b.H.",
  "businessDescription": null,
  "email": "info@podersdorfamsee.at",
  "phone": "02177 2227",
  "mobilePhone": null,
  "website": "https://www.podersdorfamsee.at/",
  "streetAddress": "Campingplatz",
  "postalCode": "7141",
  "city": "Podersdorf am See",
  "fullAddress": "Campingplatz, 7141 Podersdorf am See",
  "statusBadges": [],
  "isDormant": false,
  "certificateCount": null,
  "companyUrl": "https://firmen.wko.at/firma/?firmaid=9f6b0c29-41cb-4b41-8b05-44231ffd0806",
  "detailUrl": "https://firmen.wko.at/podersdorf-tourismus-…/burgenland/?firmaid=…&standortid=9",
  "sourceUrl": "https://firmen.wko.at/freizeitbetrieb/burgenland/?firmenname=",
  "scrapedAt": "2026-09-17T01:29:40.033Z",

  "detailFetched": true,
  "gln": "9110015841909",
  "commercialRegisterNumber": "146696f",
  "commercialRegisterCourt": "Landesgericht Eisenstadt",
  "legalForm": null,
  "vatId": null,
  "foundedYear": null,
  "licenceCount": 1,
  "gisaNumbers": ["10345894"],
  "tradeBranches": ["Gewerblicher Sportbetrieb - Bahnengolf"],
  "tradeLicences": [
    {
      "tradeGroup": "FG Freizeit- und Sportbetriebe",
      "wording": "Kleingolfanlage",
      "branch": "Gewerblicher Sportbetrieb - Bahnengolf",
      "gisaNumber": "10345894",
      "managingDirector": "Mag. (FH) Rene Lentsch",
      "authority": "Bezirkshauptmannschaft Neusiedl am See",
      "since": "1997-01-20"
    }
  ]
}
```

`locationId` matters: a company with several sites appears once per site, and the pair
`companyId + locationId` is the key this Actor de-duplicates on.

***

### How to point it at what you want

WKO lists are always **trade × region**. Open a trade on firmen.wko.at, pick a region from the site's
own list, and copy the address bar:

```
https://firmen.wko.at/tischler/wien/?firmenname=
https://firmen.wko.at/werbeagentur/tirol/?firmenname=
https://firmen.wko.at/gasthaus/wien-23-bezirk-liesing/?firmenname=
```

Paste as many as you like into **Search result URLs**. Or fill in **Trade** once and list several
**Regions** — umlauts can be typed plainly, they are percent-encoded for you.

Do not invent combinations. An unreal trade × region does not return an empty list, it returns
**HTTP 404** (`/baumeister/oberoesterreich/` → 404, verified). The Actor reports that as
*"that combination does not exist"* and charges nothing for it — it never reports it as an empty
trade.

Every list page prints its exact size in the browser tab — `361 Unternehmen gefunden` — so you can
size a run before you pay for it. Above a thousand WKO stops counting and says `Über 1000`; the
Actor records that as `null`, not as 1000.

***

### Cost: shallow lists are far cheaper than deep ones

This is the one thing worth understanding before a big run. WKO has no page parameter — `?seite=2`
answers HTTP 200 with page 1's rows, verbatim — so the only pagination is its "Mehr laden" button,
and **every click re-renders the entire cumulative list**, not the ten new rows. Measured end to end:

| companies from one list | total bytes | per company |
|---:|---:|---:|
| 30 | 572 KB | **19 KB** |
| ~200 | ~11 MB | ~55 KB |
| ~500 | ~49 MB | ~97 KB |

Ten regions of 50 cost about a third of what one region of 500 costs for the same 500 rows, and
finish sooner because they run in parallel. That is why **Max companies per list** defaults to 200.
`RUN_SUMMARY.transport` reports the bytes and the KB-per-delivered-row your run actually spent.

***

### Rate limits, and what the Actor does about them

firmen.wko.at sits behind a rate-limiting WAF that answers `Access Denied / Signature ID: 0` —
sometimes under an HTTP 200, which is why the Actor checks for that page rather than trusting the
status code. Measured ladder: 30 requests at 1.5 s spacing gave 27 clean 200s; the same 30 at full
speed gave 18 blocks, the first at request 11; sustained parallel load escalated to a 393-second
block of the whole egress IP.

So: each list walks on **its own proxy session** at **1.5 s between clicks**, several lists run in
parallel, and a block is retried on the *same* session after 4 s, 12 s and 40 s — a WKO block does
not consume the ViewState, so waiting resumes the walk, whereas rotating the IP silently ends it.

Company pages are guarded by a JS interstitial (HTTP 429, ~1.1 KB). It is not a CAPTCHA and needs no
browser: one plain JSON POST of the company's own id clears it. Measured on 24 company pages over
Apify's own egress — 23 returned HTTP 200, 9 of them after that handshake, and the single miss was a
proxy-side `UPSTREAM504`, not a block.

**Apify IPs are not pre-blocked.** Verified on 2026-09-16 from Apify egress: RESIDENTIAL pinned to
Austria, plain RESIDENTIAL and datacenter (`auto`) each returned HTTP 200 with a complete result
panel, and a full paging walk plus the interstitial handshake both completed over RESIDENTIAL+AT.
The default is RESIDENTIAL+AT because this site blocks by IP and datacenter addresses are shared
with every other Apify user; datacenter is cheaper and you can switch to it.

***

### Honest limits

- **64.9% of rows have no email and no phone**, and the company page does not fix that. If you need
  a contactable list, use the contact filter and expect roughly a third of the register — heavily
  weighted toward professional-services trades.
- **`vatId` (21.3%), `legalForm` (12.5%), `isDormant` (1.6%) and `certificateCount` (2.2%) are
  sparse.** They are real and they are counted above; they are not a reason to buy.
- **`managingDirector` is a person's name**, published by WKO as statutory trade-law disclosure. It
  is on 38.5% of licences. Treat it as personal data.
- **A list is a one-way cursor.** Paging state lives in the ASP.NET ViewState and
  `controlsCountField` is inert (0, 5, 20 and 99 all return the same 20 rows), so there is no page
  jumping, no starting in the middle, and no resuming a list mid-way after a run ends.
- **Deep lists are expensive by construction**, not by implementation — see the cost table.
- **`Über 1000` lists cannot be size-checked.** For lists with an exact published count the Actor
  refuses to report a short walk as complete; on an `Über 1000` list there is no number to check
  against, so that guard cannot run.
- **No proven ceiling, and no proof there is none.** The deepest single list reached in testing was
  40 rows by this Actor and 750 by an earlier probe, neither of which met a cap. Whether something
  stops a list beyond that is untested.
- **A truncated paging response cannot be caught by Content-Length**, because the deltas arrive
  chunked with none. The Actor instead parses the length-prefixed delta to a clean boundary and
  fails the list if the body ends mid-segment.
- **Counts here are samples, not the whole register.** 185 list rows across 7 trades and 80 company
  pages. A trade not in that sample may behave differently — check the run summary against your own
  lists.

***

### Run summary

`RUN_SUMMARY` in the key-value store reconciles the run:

- every list, with **the count WKO itself published** beside the number actually parsed and the
  number delivered, plus its requests, bytes and why it stopped;
- the three kinds of nothing kept apart — a combination that **does not exist** (404), a list that
  exists and holds **nobody**, and a list that **could not be finished** — and none of them charged;
- rows not delivered, split by reason: contact filter, dormant filter, duplicate across lists,
  unnamed;
- contact coverage on what was delivered;
- enrichment attempted / succeeded / failed, with the reasons;
- `delivered` and `charged`, which are the same number.

If a list finishes but returns fewer companies than WKO's own title reported, the run **fails** and
says so rather than reporting a partial list as a complete one.

***

### Source and access

`https://firmen.wko.at/robots.txt` (HTTP 200, 1,279 bytes) begins:

```
User-agent: *
Allow: /
Disallow: /*earchComplex.aspx
Disallow: /*earchcomplex.aspx
Disallow: /Wartung/*.aspx
Disallow: /wartung/*.aspx
Allow: /wartung/*efault.aspx
Allow: /Wartung/*efault.aspx
```

Neither the result lists nor the company pages are disallowed, and both carry `meta robots=index`.
Named harvester agents (`EmailCollector`, `EmailSiphon`, `Teleport`, `WebZIP`, `WebBandit`) and
Yandex are disallowed by name; this Actor is none of them. Data is published by the Wirtschaftskammer
Österreich; company and personal fields in it are subject to Austrian and EU law, and how you use
them is your responsibility.

***

### Verifying this build

```
node offline_validate.mjs
```

250 assertions, no network and no `node_modules`, run against the raw bytes in `fixtures/` — captured
from firmen.wko.at on 2026-09-16 and written in binary, because the ASP.NET delta is a
length-prefixed protocol and a text-mode save shifts every declared length. The field-fill numbers in
this README are re-counted by the validator on the 75 fixture rows, the paging state machine is
driven over the real captured deltas, and the end-of-list, truncation, block, 404 and cap branches
are each exercised.

# Actor input Schema

## `searchUrls` (type: `array`):

One or more firmen.wko.at result-list URLs, e.g. https://firmen.wko.at/tischler/wien/?firmenname= — trade and region come straight out of the URL. Lists are walked one at a time each, several lists in parallel. A company that appears in two of your lists is delivered and charged once.

## `branche` (type: `string`):

The trade slug as it appears in a firmen.wko.at URL, e.g. "tischler", "elektrotechnik", "werbeagentur". Combined with every entry in "Regions" below. Umlauts may be typed plainly ("gerüstverleiher") — they are percent-encoded for you. If the combination does not exist WKO answers 404 and the run says so instead of reporting an empty trade.

## `regions` (type: `array`):

Region slugs as they appear in a firmen.wko.at URL: a Bundesland ("wien", "steiermark", "burgenland"), a district ("linz-stadt\_bezirk") or a city district ("wien-23-bezirk-liesing"). Smaller regions are dramatically cheaper per company — see "How much" below.

## `contactFilter` (type: `string`):

Rows that fail this filter are never delivered and never charged. They are still counted in RUN\_SUMMARY so you can see how much of the list they were.

## `excludeDormant` (type: `boolean`):

WKO prints the badge "aktuell ruhend gemeldet" (trade licence currently dormant) on some rows. Counted: 3 of 185 rows (1.6%). Rare, but a dormant firm is the one company in a lead list nobody should be pitching.

## `enrichFromDetailPage` (type: `boolean`):

Fetches /firma/?firmaid=… for every company you are going to be charged for (never for one the caps drop). Counted on 80 company pages on 2026-09-16: GISA trade-licence number, Berufszweig and GLN on 100%, Firmenbuch number and court 51.2%, founding year 28.7%, VAT (UID) number 21.3%, Rechtsform 12.5%. 1.36 licences per company on average, up to 7. Costs about 42 KB and one extra request per company. The price per company does not change.

## `maxCompanies` (type: `integer`):

Hard cap on rows delivered, and therefore on rows charged. 0 means no cap. Rows come back in WKO's own list order, so a capped run returns the beginning of each list rather than a sample of it.

## `maxCompaniesPerLane` (type: `integer`):

Stops each individual list at this depth, so one enormous trade cannot eat the whole run. Lists come in 10s, so this is rounded up to the next 10 in practice.

## `laneConcurrency` (type: `integer`):

How many result lists to walk at the same time. Each gets its own proxy session and its own cookies. Paging inside a list is always sequential: WKO's paging state lives in the ASP.NET ViewState and cannot be jumped.

## `requestDelayMs` (type: `integer`):

Pause between "Mehr laden" clicks on the same list. 1500 ms is the measured safe rate. Lower it and the WAF starts refusing; the Actor then backs off 4 s, 12 s and 40 s on the same session, which is slower than just being polite.

## `proxyConfiguration` (type: `object`):

Apify RESIDENTIAL pinned to Austria. Measured on 2026-09-16 from Apify's own egress: RESIDENTIAL+AT, plain RESIDENTIAL and datacenter (auto) each returned HTTP 200 with a complete result panel, and a full paging walk plus the company-page challenge both completed over RESIDENTIAL+AT — WKO does not pre-block Apify IPs. Datacenter is cheaper but its IPs are shared with every other Apify user, and this site blocks by IP.

## Actor input object example

```json
{
  "searchUrls": [
    "https://firmen.wko.at/ger%C3%BCstverleiher/linz-stadt_bezirk/?firmenname="
  ],
  "regions": [],
  "contactFilter": "all",
  "excludeDormant": false,
  "enrichFromDetailPage": false,
  "maxCompanies": 25,
  "maxCompaniesPerLane": 25,
  "laneConcurrency": 2,
  "requestDelayMs": 1500,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "AT"
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per company location from firmen.wko.at: name, trading name, street, postcode, city, and whatever contact details that company chose to publish, plus WKO's own status badges and — when company-page enrichment is on — its GISA trade licences, Firmenbuch number and GLN.

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

RUN\_SUMMARY: every list that was read with the record count WKO itself published next to the number actually parsed, companies delivered and charged, how many were filtered out and why, contact coverage, and the bytes and requests the run cost.

# 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 = {
    "searchUrls": [
        "https://firmen.wko.at/ger%C3%BCstverleiher/linz-stadt_bezirk/?firmenname="
    ],
    "enrichFromDetailPage": false,
    "maxCompanies": 25,
    "maxCompaniesPerLane": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/wko-firmen-at-scraper").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 = {
    "searchUrls": ["https://firmen.wko.at/ger%C3%BCstverleiher/linz-stadt_bezirk/?firmenname="],
    "enrichFromDetailPage": False,
    "maxCompanies": 25,
    "maxCompaniesPerLane": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/wko-firmen-at-scraper").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 '{
  "searchUrls": [
    "https://firmen.wko.at/ger%C3%BCstverleiher/linz-stadt_bezirk/?firmenname="
  ],
  "enrichFromDetailPage": false,
  "maxCompanies": 25,
  "maxCompaniesPerLane": 25
}' |
apify call scrapersdelight/wko-firmen-at-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/wko-firmen-at-scraper"
        }
    }
}
```

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/lKzdgixgk7XgaaVKY/builds/POmJTZ6rYCR69zdMF/openapi.json
