# Gas Safe Register Businesses - UK Gas Firm Leads (`scrapersdelight/gas-safe-businesses-scraper`) Actor

From $4 per 1,000 businesses, no start fee. Search the official Gas Safe Register by UK postcode: business name, registration number, full address, phone and email. Measured on 95 live businesses: 96.8% email, 95.8% phone, 100% address. Optional appliance qualifications. Deduped by reg number.

- **URL**: https://apify.com/scrapersdelight/gas-safe-businesses-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

$4.00 / 1,000 per registered business 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

## Gas Safe Register Businesses — UK Gas Firm Leads

Search the **official Gas Safe Register** — the statutory register every legal gas business in
Great Britain must be on — by UK postcode, and get back the business, its registration number, its
full postal address, its phone, its email, and (optionally) every appliance it is qualified to work
on. Deduplicated by registration number, so a firm that turns up in five of your postcodes is
delivered once and charged once.

**$4 per 1,000 businesses. No run-start fee.** A run that finds nothing costs nothing.

***

### What you actually get — measured, not estimated

Counted on **95 distinct businesses** (100 business cards across 10 results pages, deduplicated by
registration number), captured live on **2026-09-16** from five postcodes spanning central London,
Manchester, Land's End, Snowdonia and the Channel Islands. The fixtures are in `fixtures/` and
`offline_validate.mjs` re-counts every number below from those bytes on each run.

| Field | Fill | Notes |
|---|---|---|
| `registrationNumber` | **100.0%** | 95/95. The dedupe key and the row's identity. |
| `businessName` | **100.0%** | 95/95 |
| `premise` | **100.0%** | 95/95 — first address line (often the trading name again) |
| `town` | **100.0%** | 95/95 |
| `postcode` | **100.0%** | 95/95, and all 95 are syntactically valid UK postcodes |
| `fullAddress` | **100.0%** | 95/95 — the lines joined, placeholders dropped |
| `distanceMiles` | 98.9% | 94/95, miles from the postcode you searched |
| `street` | 97.9% | 93/95 |
| `email` | **96.8%** | 92/95 |
| `telephone` | **95.8%** | 91/95 |
| `outOfHoursAvailable` | 58.9% true | 56/95 advertise out-of-hours cover; the rest is a real `false`, not a gap |
| `county` | 32.6% | 31/95 — the register prints it only for some addresses |
| `locality` | 7.4% | 7/95 — thin, and said so rather than quietly shipped |

- **Phone AND email: 92.6%** (88/95). **Phone OR email: 100%** (95/95) — every business in the
  sample published at least one way to reach it.
- Where a business publishes no email the register prints a literal **`-`**. Every single unfilled
  contact cell in the sample was exactly that string. This actor turns it into `null`; the string
  `"-"` never reaches your dataset, because a `"-"` in an email column is what breaks a mail merge
  and inflates a fill percentage at the same time.

#### Appliance qualifications (opt-in)

Switch on `includeQualifications` and each row also carries the register's **"Qualified to work
on"** table: every appliance category and specific qualification the business holds, each flagged
for **Natural Gas**, **LPG** and **Building Regulations**, split into Domestic and Commercial.

Measured on 11 distinct businesses on 2026-09-16 (7 from the captured fixtures, 4 more on a live
Glasgow run): between **26 and 82 qualification rows** each, and between **17 and 62** specific
appliance qualifications actually held. All 11 held at least one.

```json
{
  "serviceType": "Domestic",
  "service": "CARAVANS - Closed Flue Gas Fires DOM LPG",
  "isCategory": false,
  "naturalGas": null,
  "lpg": true,
  "buildingRegs": null
}
```

A cell has **three** states, and this actor keeps all three: a tick is `true`, a cross is `false`,
and a blank is `null`. Collapsing blank into `false` would invent an assessment the register never
made.

**It costs time, which is why it is off by default.** One extra page load per business, measured
live at **about 1.9 seconds each** at the default 1,200 ms delay — so a full 50-business postcode
takes roughly **95 extra seconds**. It adds **no charge**: those pages belong to a business you are
already paying for.

(An earlier measurement of ~9 s per business was an artefact of a wrong approach — walking back to
the results list between each fetch. That approach is also *incorrect*, not just slow: see limit 5.
Each business's qualification link is taken off the results bytes already in hand and followed
directly, which was verified live to land on the right business 4 times out of 4.)

#### A sample row

```json
{
  "registrationNumber": "211205",
  "businessName": "Plumbforce Direct Ltd",
  "telephone": "02038416530",
  "email": "scott@plumbforcedirect.co.uk",
  "premise": "Plumbforce Direct Ltd",
  "street": "4 Old Park Lane",
  "locality": null,
  "town": "LONDON",
  "county": null,
  "postcode": "W1K 1QW",
  "fullAddress": "Plumbforce Direct Ltd, 4 Old Park Lane, LONDON, W1K 1QW",
  "distanceMiles": 0.36,
  "outOfHoursAvailable": true,
  "serviceType": "Domestic",
  "searchPostcode": "SW1A 1AA",
  "resultPage": 1,
  "qualificationCount": null,
  "qualifiedFor": null,
  "qualifications": null,
  "scrapedAt": "2026-09-16T21:02:38.092Z"
}
```

***

### Who buys this

- **Installer-network recruiters** (the BOXT / Heatable / Hometree shape) building a vetted
  engineer panel in a given catchment.
- **Manufacturer loyalty schemes** (Worcester Bosch, Vaillant Advance and similar) recruiting
  registered installers — the qualification table tells you who can actually fit the product.
- **Merchants and distributors** opening trade accounts in a new branch catchment.
- **Trade insurers and warranty providers** underwriting registered gas businesses.

What makes the row defensible is what Google Maps cannot give you: a **statutory registration
number**, and the appliance-by-appliance **qualification** behind it.

***

### The honest limits

Read these before you plan a national sweep. They are not caveats bolted on at the end; they are
the shape of the source.

#### 1. There is a hard ceiling of 50 businesses per search, and no national total

Every postcode search — central London, Shetland, Land's End, everywhere tried — answers
`Showing 1 to 10 of 50 results` across exactly **5 pages of 10**. There is no page-size override
and the register never publishes how many businesses it holds in total. **Coverage is a function of
how densely you space your postcodes, not of paging deeper.**

Measured in central London on 2026-09-16: those 50 slots were used up **within 1.24 miles**. In a
dense metro, space your postcodes roughly **2 miles apart** or the grid under-covers. A rural search
reaches far further for the same 50, so one postcode covers much more ground.

**This actor cannot tell you "all UK gas businesses".** It tells you who the register returns for
the postcodes you asked for. `RUN_SUMMARY.coverageCaveat` says the same thing inside every run.

#### 2. The register answers a rate-limited request with HTTP 200 and a block page

This is the nastiest thing about the target and the reason this actor is built the way it is. When
you go too fast, the site does **not** return 429 or 403 — it returns **HTTP 200 carrying an
Imperva/Incapsula block page**. A scraper that trusts the status code parses zero rows and reports
"no gas businesses near this postcode": confident, wrong, and billable.

Every response body is sniffed for the block markers *before* a zero-row parse is believed, and a
block **fails the postcode** (`Actor.fail()` if it happens to every postcode) instead of being
reported as an empty result. A genuinely empty page is distinguishable and is accepted: it still
carries its `Showing … results` envelope. Both cases are in the offline test suite, against real
captured bytes.

Defaults are set to the rate that measured clean: **one search every 15 seconds**, 1.2 s between
pages inside a search. Three back-to-back searches tripped the limiter; it recovered after about
50 seconds idle. Lowering `searchIntervalSecs` will not make your run faster, it will get it
blocked.

#### 3. It needs a real, headful browser — which is what it costs

Measured on 2026-09-16 against this host:

| Client | Result |
|---|---|
| `got-scraping` / `curl`, full Chrome header set | **403**, ~860–968-byte Incapsula body on every path |
| Playwright Chromium, `headless: true` | **403**, 858-byte Incapsula body |
| Playwright Chromium, `headless: false` | **200** — the reese84 interstitial solves itself in ~2.5 s and the real page loads |

So the actor runs a **headful** Chromium on the Apify Playwright image's Xvfb display, at 4096 MB.
That is the cost of this lane, and it is priced in.

One more measured subtlety: clicking the search button with a synthetic mouse event produced a
**403** on the results redirect (the site's cookie-consent overlay sits over the button). Setting
the field values and calling the form's own `requestSubmit()` produced a clean **200**. This actor
therefore never clicks through a consent dialog on your behalf.

#### 4. UNMEASURED: Apify's own proxy pools

The build was verified end to end from one ordinary IP (which happened to be a US one — the site
did **not** geo-block it). It was **not measured** through Apify's DATACENTER or RESIDENTIAL pools,
and no claim is made that it clears Imperva from them. `proxyConfiguration` therefore defaults to
**RESIDENTIAL**, which is the right bet for an Imperva-fronted site, and if you see
`RUN_SUMMARY.blockedPostcodes` filling up, point it at your own proxy. A blocked postcode is
reported as blocked; it is never silently converted into "found nothing", and nothing is charged
for it.

#### 5. The result list re-sorts between requests, so never re-read it to find a row

Requesting the same search page twice does not reliably return the same businesses in the same
order. Measured: ten index-based visits to "the Nth result" returned only **seven distinct**
businesses. The actor never re-reads a list to find a row again — it takes each business's own link
off the page bytes it already parsed, and dedupes everything by registration number. Do not treat
the envelope's `50` as a promise of 50 distinct rows.

#### 6. A valid UK postcode can still be unknown to the register

The register runs its **own** postcode lookup when you submit. A postcode that is perfectly
well-formed can come back with *"That postcode or town was not found in our records. Please try
again with a full UK postcode."* — measured on **B1 1AA**, which is a textbook-shaped Birmingham
postcode.

When that happens the page does not navigate at all. This actor reads the register's own message,
reports the postcode in `RUN_SUMMARY.rejectedPostcodes`, **charges nothing for it**, and moves on
to the next one. It does **not** force the submit through (that was tried: it navigates to a
64,122-byte page with no results envelope on it — a junk page there is no honest way to read). A
refused postcode is detected in about 4 seconds, not by burning a navigation timeout.

#### 7. What is deliberately not shipped

- **No engineer-level records.** The register has a second surface behind "View Engineers" with
  named individual engineers. It is **out of scope** for this actor: it is a different function
  (people, not businesses), it multiplies the request count against a hard rate limit, and the
  contact data there is thin — the engineer record inspected during recon printed
  `Telephone: n/a`. The rich contact data is at business level, which is the right level for a lead
  list anyway.
- **No URLs back to the register.** Every deep link on this site is addressed by an encrypted `ep`
  token that **expires three minutes after it is minted**. Shipping one would ship a field that is
  dead by the time you open the dataset, so there is no `registerUrl` field. Look a business up by
  its registration number instead.
- **No ratings or review counts.** The register does not publish them.

#### 8. Robots and access

`robots.txt` (fetched 2026-09-16) disallows `/aspnet_client/`, `/bin/`, `/config/`, `/data/`,
`/macroScripts/`, `/umbraco/`, `/umbraco_client/`, `/usercontrols/`, `/xslt/`, `/engineer/` and
`*?wcag*`. The paths this actor uses — `/find-an-engineer-or-check-the-register/`, `/widgetfc`,
`/findbusinessresults`, `/businesscompetencies` — are **not** disallowed. Everything read is public
data that the register publishes so consumers can check their installer.

***

### Input

| Field | Default | What it does |
|---|---|---|
| `postcodes` | *(required)* | One search each. Anything that is not a valid UK postcode is rejected **before any request**, listed in `RUN_SUMMARY.invalidPostcodes`, and never charged. |
| `serviceType` | `Domestic` | `Domestic` or `Commercial` — separate registers on the site. |
| `maxPagesPerPostcode` | `5` | 10 businesses per page; 5 is the register's own ceiling. |
| `maxBusinesses` | `1000` | Hard stop on delivered (= charged) rows. `0` means no cap. Checked against rows **produced**, so it never overshoots. |
| `includeQualifications` | `false` | The appliance-qualification table. ~9 s per business. No extra charge. |
| `requireContact` | `false` | Drop rows with neither phone nor email — before delivery, so they are never charged. |
| `requireEmail` | `false` | Drop rows with no email — same. |
| `searchIntervalSecs` | `15` | The measured-clean pace. |
| `requestDelayMs` | `1200` | Between pages inside one search. |
| `proxyConfiguration` | Apify **RESIDENTIAL** | See limit 4. |

The default input is deliberately a **one-postcode, one-page, 10-business** run so a first try
costs four cents and finishes in under a minute.

### Output

Rows land in the dataset (three views: overview, contacts-only, qualifications). `RUN_SUMMARY` in
the key-value store reconciles the run and keeps every kind of "nothing" apart:

- `invalidPostcodes` — not a UK postcode; rejected before any request was made
- `rejectedPostcodes` — a valid postcode the register's own lookup does not know
- `blockedPostcodes` — the site's edge refused us; **not** "no businesses there"
- `unreachablePostcodes` — a transport or parse failure, with the reason
- `postcodesThatReturnedNothing` — a real, empty answer
- `excludedByRequireEmail` / `excludedByRequireContact` — your filters, never charged
- `cardsParsed`, `duplicatesSkippedByRegistrationNumber`, `cardsWithoutRegistrationNumber`
- `delivered` and `charged` — always equal
- `perPostcode` — the same breakdown per search

### Billing

One event, `business-scraped`, at **$0.004** per distinct business delivered. No start fee, neither
Apify auto-event. Charging rides on `Actor.pushData(items, event)`, which bills per item and pushes
only what your charge cap allows — so **delivered equals charged**, and a cap never overshoots by a
batch.

### Verified live, end to end

These modules — not a prototype of them — were driven against the live site on 2026-09-16 from an
ordinary IP:

| Run | Result |
|---|---|
| `EH1 1RE`, 5 pages | 50 cards parsed, **50 distinct businesses**, 0 duplicates, 0 cards missing a registration number, in **13.6 s** (38.3 s including browser launch and the Imperva clear) |
| `NE1 4ST` + `CF10 1EP`, 2 pages each | 40 distinct businesses, 39/40 phone, 39/40 email, **no `"-"` leaked into any row** |
| `B1 1AA` | refused by the register's own lookup, detected in **3.9 s**, reported and **not charged** |
| `G1 1XW` qualifications | 4 businesses, 26–76 qualification rows each, **4/4 landed on the right business**, 1.9 s each |

### Verify it yourself

```bash
node offline_validate.mjs
```

No network, no platform, no `node_modules`. It runs every assertion in this README against the
captured bytes in `fixtures/` — including the 858-byte block body, the 6,183-byte interstitial, a
real results page holding zero businesses, and the triple-URL-encoded links whose encoding must
survive the walk untouched.

# Actor input Schema

## `postcodes` (type: `array`):

One search per postcode. The register answers every search with AT MOST 50 businesses across 5 pages of 10, ordered nearest first, and never publishes a national total - so coverage is a function of how densely you space these, not of paging deeper. Measured in central London on 2026-09-16: the 50 slots were used up within 1.24 miles, so dense metros need postcodes roughly 2 miles apart; a rural search reaches much further for the same 50. Anything that is not a valid UK postcode is rejected before a request is made, listed in RUN\_SUMMARY.invalidPostcodes, and never charged.

## `serviceType` (type: `string`):

Which register the search runs against. Domestic covers household work (boilers, cookers, fires); Commercial covers commercial installations. These are separate searches on the site and a business can appear in either or both - run it twice if you want both, the registration-number dedupe is per run.

## `maxPagesPerPostcode` (type: `integer`):

How many of the 5 result pages to walk per postcode. Each page is 10 businesses, so 5 pages is the register's hard ceiling of 50 per search - there is no page-size override. Lower it to sample a wide postcode grid cheaply.

## `maxBusinesses` (type: `integer`):

Hard stop on delivered - and therefore charged - rows across the whole run, counted after deduplication by registration number. 0 means no cap. The cap is checked against rows PRODUCED, not rows flushed to the dataset, so it never overshoots.

## `includeQualifications` (type: `boolean`):

Fetch each business's 'Qualified to work on' table: every appliance category and specific qualification it holds, with a Natural Gas / LPG / Building Regulations flag each. One extra page load per business - measured live on 2026-09-16 at about 1.9 seconds each at the default delay, so a full 50-business postcode takes roughly 95 extra seconds. It adds no charge. A business whose qualification page cannot be read keeps qualifications: null rather than an empty list.

## `requireContact` (type: `boolean`):

Excluded rows are never delivered and never charged. Measured on 95 live businesses on 2026-09-16, every one of them published at least one of the two, so this filter usually removes nothing - it is here so you never pay for a contactless row on the day that changes.

## `requireEmail` (type: `boolean`):

Excluded rows are never delivered and never charged. Measured fill was 92 of 95 businesses (96.8%); where a business publishes no email the register prints a literal '-', which this actor turns into null, never into a string.

## `searchIntervalSecs` (type: `integer`):

The register is rate limited, and its rate-limit response is the dangerous kind: HTTP 200 carrying an Imperva block page rather than an error. Measured 2026-09-16: three back-to-back searches tripped it and it recovered after about 50 seconds idle, while one search every 15 seconds ran clean. Lower this and you will be blocked, not faster.

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

Pause between the 5 result pages of a single search (and between qualification pages). The search session dies 3 minutes after it is minted, so this cannot be raised far without the token expiring mid-walk.

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

The site sits behind Imperva. Residential is the default because a datacenter IP is the first thing an edge like this scores against you - this actor already pays for a real headful browser, and cheaping out on the IP is what turns that into a 403. UNMEASURED and stated plainly: the build was verified from a single ordinary IP, not through Apify's proxy pools, so if you see RUN\_SUMMARY.blockedPostcodes fill up, point this at your own proxy.

## Actor input object example

```json
{
  "postcodes": [
    "M1 1AE",
    "SW1A 1AA",
    "TR19 7AA"
  ],
  "serviceType": "Domestic",
  "maxPagesPerPostcode": 1,
  "maxBusinesses": 10,
  "includeQualifications": false,
  "requireContact": false,
  "requireEmail": false,
  "searchIntervalSecs": 15,
  "requestDelayMs": 1200,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

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

One row per distinct registered gas business, keyed on its Gas Safe registration number: name, registration number, full postal address, phone, email, distance from the postcode you searched, whether it advertises out-of-hours cover, and - if you asked for them - every appliance qualification it holds.

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

RUN\_SUMMARY: what each postcode returned, cards parsed vs rows delivered, duplicates dropped by registration number, invalid postcodes rejected before any request, postcodes the site's edge blocked, contact-fill counts, and the billing reconciliation (delivered == 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 = {
    "postcodes": [
        "M1 1AE"
    ],
    "maxPagesPerPostcode": 1,
    "maxBusinesses": 10,
    "includeQualifications": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/gas-safe-businesses-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 = {
    "postcodes": ["M1 1AE"],
    "maxPagesPerPostcode": 1,
    "maxBusinesses": 10,
    "includeQualifications": False,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/gas-safe-businesses-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 '{
  "postcodes": [
    "M1 1AE"
  ],
  "maxPagesPerPostcode": 1,
  "maxBusinesses": 10,
  "includeQualifications": false
}' |
apify call scrapersdelight/gas-safe-businesses-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/gas-safe-businesses-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/hMyDtc61u0NLawquC/builds/kpc0ZVXYP6sOz3DdA/openapi.json
