# TX TCEQ Petroleum Tank Leak (LPST) Site Delta (`titan_coder/tx-tceq-lpst-site-status-delta`) Actor

Watches Texas TCEQ Leaking Petroleum Storage Tank (LPST) sites by county, city or ZIP and charges only for a real change: a new leak case reported nearby, a case closed by TCEQ, or a case reopened. For Phase I ESA and environmental consultants, lenders, real estate due diligence and insurers.

- **URL**: https://apify.com/titan\_coder/tx-tceq-lpst-site-status-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Real estate, Automation
- **Stats:** 2 total users, 1 monthly users, 83.3% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 lpst case status change delivereds

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

## TX TCEQ Petroleum Tank Leak (LPST) Site Delta

Watches the official **Texas Commission on Environmental Quality (TCEQ)** registry
[*Leaking Petroleum Storage Tank (LPST) Sites*](https://data.texas.gov/d/hedz-nn4q) on `data.texas.gov`
for the Texas **counties, cities or ZIP codes you name**, and reports **only what actually changed since
your last run**:

| `changeType` | What happened at TCEQ |
|---|---|
| `new_case` | A **new leak case appeared in your area** — a petroleum release was reported at a site inside your perimeter. |
| `case_closed` | TCEQ **closed** an existing case (`closure_date` went from empty to a date). |
| `case_reopened` | A previously closed case was **reopened** (`closure_date` went back to empty). |
| `case_updated` | TCEQ **corrected a case date** (reported or closure date changed to a different date). |

Nothing else is delivered and nothing else is charged. A run where nothing changed produces no paid
events at all.

### Who this is for

- **Phase I ESA / environmental consultants** — standing watch on the parcels and corridors you screen,
  instead of re-running a one-off registry lookup every quarter.
- **Lenders and CRE underwriters** taking gas stations, truck stops, fleet yards or former fuel sites as
  collateral: a new LPST case (or a closure) inside the collateral's ZIP changes the file.
- **Real estate developers and title/insurance underwriters** watching a target area before or during a
  transaction.
- **Environmental counsel** tracking when TCEQ finally closes a client's open case.

### How it works

1. `monitorId` names a durable history. **The first run of a new `monitorId` is a silent baseline** — it
   records the current state of every case in each area, delivers nothing and charges nothing.
2. Every later run takes a fresh snapshot of each area and compares it against the durable per-case index
   (`lpst_id -> reported date, closure date, content hash, change sequence`).
3. Each genuine change is written to the dataset, then charged **at most once** — the right to deliver
   and charge is granted by a single atomic claim keyed on `lpst_id` + change sequence + content hash
   (see *Delivery guarantee* below). A case that is closed, later reopened, and then closed **again on
   the same date** produces **three separate paid events**, not two: the change sequence distinguishes
   repeats across the case's history.
4. An optional HTTPS `webhookUrl` receives a digest of the delivered (paid) changes.

Run it on an Apify schedule (daily is a good fit — TCEQ refreshes this dataset daily).

### Pricing

Pay per event: **one event per delivered case change**. No change, no charge. Charging happens only
*after* the row has been written to both the durable dataset and the run's own output dataset.

### Delivery guarantee: at-most-once (not exactly-once)

For any one computed change, the row is delivered to the dataset and the event is charged **no more than
once**. The right to do either is handed out by the only atomic primitive Apify offers —
`RequestQueue.addRequest(uniqueKey)` — in a separate, named request queue
(`<storage prefix>-<monitorId>-claims`) that is used purely as an append-only **journal of granted
rights**: its requests are never handled and never deleted. Apify's key-value store has no
compare-and-swap, conditional write or ETag, so it cannot be the source of that right; it only holds a
diagnostic state machine for reporting.

The trade-off is deliberate: **if a run crashes after taking the claim, that change can be lost** (it is
closed as `dataset_unknown` / `charge_unknown` and never re-delivered) — but you are never charged twice
for it. Two boundaries of the guarantee, stated honestly:

- it holds for as long as the named claims queue exists. Anyone with account access can delete or
  recreate that queue through the Console/API, which starts the journal from scratch — an unavoidable
  limit of any durable storage, not a defect of the protocol;
- it applies from the build in which the claim gate shipped onward. Older builds must not keep running
  against the same `monitorId`.

The same gate also covers the **"case disappeared from the area" / "case came back"** notices: no row and
no charge, but the notice leaves the actor in coverage, the digest and the webhook, and a sent
notification cannot be recalled. Its claim key contains no run id — it is pinned to a durable
disappearance epoch — so the same disappearance is announced once, not once per run.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.**
  Each row is written to the durable dataset first, then mirrored to the run's default dataset before
  billing proceeds for that row. If the durable write succeeds but the default-dataset mirror write
  fails (e.g. transient Apify storage error), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the
  run is not retried into re-creating that exact row. The durable dataset can therefore end up with a
  small number of orphan rows that were never mirrored and never billed. The **default dataset is the
  canonical log of rows successfully written to this run's output** (see its `run_summary` row) — but a
  default-dataset row does not by itself prove the row was billed: the row is written before
  `Actor.charge()` runs, so if charging then fails or comes back `charge_unknown`, the row is present but
  not confirmably paid. **`run_summary.eventsBilled` and Apify's own billing ledger are the source of
  truth for confirmed payment**, not the presence of a row in either dataset.
- **This is a registry-publication monitor, not an environmental assessment.** It reports what TCEQ
  publishes and when that publication changes. It is **not** a Phase I/II ESA, not a contamination
  determination, not a legal opinion and not a clean-site certification. A closed case means TCEQ closed
  *its administrative case*, nothing more. Before any transaction, loan or policy decision, use a
  licensed environmental professional and TCEQ's own records.
- **County/city/ZIP values must match how TCEQ writes them.** Verified live over the whole dataset on
  13.09.2026: `county` and `city` are stored upper-case with no surrounding whitespace, so your input is
  upper-cased and matched **exactly** — no fuzzy matching, no substring search, no merging of similar
  place names. `SAN ANTONIO` and `SAN ANTONIO HEIGHTS` are different cities and always will be. If a
  county name is misspelled, the area simply contains zero cases (reported honestly in `coverage`, not as
  an error).
- **An area holding more than 12 000 LPST cases is refused, not silently truncated** (`target_too_broad`
  in `coverage`). No Texas county comes close today — the largest, HARRIS, holds 4 461 cases (verified
  live) — but a truncated snapshot would fabricate both "new" and "vanished" cases, so it is refused
  instead.
- **Snapshot completeness is verified against the source's own count** on every run. The actor first asks
  TCEQ how many rows match the area, then pages through them with keyset pagination on the unique
  `lpst_id` (no `$offset`, which can skip or repeat rows if the data changes mid-run). If the collected
  row count does not match the count TCEQ itself reported, or an `lpst_id` repeats or goes backwards, the
  whole watch is reported as `source_access_limited` for that run — **no partial baseline, no partial
  delta, no billing** on a snapshot whose completeness cannot be trusted. The next run recovers on its
  own. The same count query also returns `count(reported_date)` and `count(closure_date)`, and the number
  of non-empty dates actually collected must match them exactly — so a date column that disappears from
  the feed, **for all rows or for only some of them**, is caught instead of being delivered as a wave of
  "case reopened" events.
- **Count + keyset pagination is a completeness check, not snapshot isolation — and we do not pretend
  otherwise.** The Socrata API offers no transactional snapshot: the count query and the pages are
  separate reads of a live dataset. The checks above catch a row *count* that changes mid-run, a skipped
  page, a repeated or out-of-order key. They cannot catch a **swap** — one row deleted and another
  inserted between two reads, leaving the total unchanged. In that (rare, TCEQ-side) case the run sees
  the swapped-in case as `new_case` and the swapped-out one as missing; nothing is silently
  mis-classified as a status change of an existing case, but the run is not an atomic snapshot and is not
  advertised as one. TCEQ publishes this dataset in a daily batch, so mid-run mutation is unlikely rather
  than impossible.
- **A case that disappears from your area is reported, never billed, and never deleted from the index.**
  TCEQ does not delete LPST cases, so this normally means a case was re-assigned to a different
  county/city. The record is kept frozen so that, if the case comes back, it cannot be charged again as
  if it were brand new. Each disappearance also gets a durable **tombstone** (`firstMissingAt`,
  `lastSeenAt`, `missingRuns`), and `coverage` lists the **identifiers of the cases that went missing in
  this particular run** (`newlyMissingIds`, per-watch `newlyMissing`) and the ones that came back
  (`returnedFromMissing`) — not just an ever-growing total. A case that returns unchanged costs nothing;
  a case that returns closed (or reopened) is charged once, as the real status change it is.
- **Watched areas must not overlap.** Billing and de-duplication are scoped per `watchId`, so a case
  sitting inside two overlapping areas would be delivered — and charged — twice for one change. Rather
  than let that happen quietly, overlapping (or possibly overlapping) area combinations inside one county
  are **rejected before the run starts**: watch the whole county with one watch, or watch several
  distinct cities/ZIPs with one watch each, but not both at once. Different counties never overlap.
- **A run in which some areas failed never reports itself as a clean "nothing changed".** If at least one
  watch could not be checked, the run's reason becomes `partial_watch_failures`, the digest says in plain
  text how many areas were not checked, and the "no changes found" wording is explicitly qualified as
  covering only the checked part of your perimeter.
- **Only two fields drive billing:** `reported_date` and `closure_date`. Renaming a site, correcting its
  street address or updating the tank registration number is administrative noise and is deliberately
  **not** charged, even though the row's other fields will show the newest values whenever a real change
  is delivered.
- **At most 200 case changes per area per run are delivered** (`change_cap_reached`). This protects your
  wallet from an anomalous bulk re-publication on the source side. The remaining changes are not lost —
  they are picked up by the following runs, in `lpst_id` order.
- **Only one run per `monitorId` at a time.** Concurrent runs are serialised by a durable lease; a run
  that loses the lease stops immediately, reports `lease_lost`, and never delivers or charges anything
  further.
- **A `watchId` is permanently bound to its area** on first use. Reusing the same `watchId` with a
  different county/city/ZIP fails the run with `watch_config_mismatch` **before any network request** —
  otherwise one area's accumulated history would silently be presented as another's.
- **Update cadence is TCEQ's, not ours.** The dataset refreshes daily; running more often than daily just
  produces empty runs.

### Output

Dataset rows carry `watchId`, `lpstId`, `changeType`, `caseStatus`/`previousCaseStatus`,
`closureDate`/`previousClosureDate`, `reportedDate`/`previousReportedDate`, `siteName`, `siteAddress`,
`siteAddressDescription`, `city`, `county`, `zip`, `tceqRegion`, `refNum` (the TCEQ Regulated Entity
number), `pstRegistration`, plus `monitorId`, `runId`, `discoveredAt` and `eventId`.

`coverage` (key-value store) records, for every run: each watch's area, status, reason, rows in its
snapshot, changes detected, cases missing from the snapshot and the ones that newly went missing or came
back, plus run-level counters — `recordsDelivered`, `recordsBilled`, `sourceAccessLimitedCount`,
`targetTooBroadCount`, `changeCapReachedCount`, `missingFromSnapshotCount`, `newlyMissingCount` and
`newlyMissingIds` (`watchId:lpstId`, capped at 100 per run with `newlyMissingIdsTruncated`).

### Source

Texas Open Data Portal, `data.texas.gov`, dataset `hedz-nn4q`, published by the Texas Commission on
Environmental Quality. Public, free, no key required. 29 842 cases and 1 074 open cases as of
13.09.2026.

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars). Keep it stable across scheduled runs — the first run of a new monitorId is a silent baseline and charges nothing.

## `watches` (type: `array`):

1-8 objects: {"watchId": "canyon-lake", "county": "COMAL", "city": "CANYON LAKE", "zip": "78133"}. county is required and must be the Texas county name exactly as TCEQ publishes it (no word 'County': HARRIS, EL PASO, RED RIVER). city and zip are optional and narrow the area further. One watch = one area; a watch whose area holds more than 12000 LPST cases is rejected with a clear message asking you to narrow it (only the very largest counties come close). AREAS MUST NOT OVERLAP: billing is scoped per watch, so a case sitting in two overlapping areas would be delivered and charged twice for the same change. Overlapping (or possibly overlapping) combinations inside one county are rejected before the run starts — watch the whole county with one watch, OR watch several distinct cities/ZIPs with one watch each, but not both at once. Different counties never overlap. New areas can be added later under the same monitorId.

## `socrataAppToken` (type: `string`):

Optional. data.texas.gov does not require a key for this dataset, but a free Socrata app token (from your own data.texas.gov account) raises the anonymous request-rate ceiling if you run many areas across many monitors. Leave empty for normal use.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when paid case changes were delivered; always — post it every run; never — do not call webhookUrl at all.

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

Optional. Receives a digest of delivered (paid) LPST case changes as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-lpst-watch",
  "watches": [
    {
      "watchId": "canyon-lake",
      "county": "COMAL",
      "city": "CANYON LAKE"
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, lpstId, changeType (new\_case|case\_closed|case\_reopened|case\_updated), caseStatus, previousCaseStatus, closureDate, previousClosureDate, reportedDate, siteName, siteAddress, city, county, zip, refNum, tceqRegion. Informational monitoring of a public TCEQ registry — not an environmental assessment or legal advice.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch area/status/reason, rows in each snapshot, changes detected, records delivered and billed, requested/attempted/succeeded/failed watch counts, and the identifiers of cases that went missing from an area in this run or came back. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# 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 = {
    "monitorId": "my-lpst-watch",
    "watches": [
        {
            "watchId": "canyon-lake",
            "county": "COMAL",
            "city": "CANYON LAKE"
        }
    ],
    "socrataAppToken": "",
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/tx-tceq-lpst-site-status-delta").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 = {
    "monitorId": "my-lpst-watch",
    "watches": [{
            "watchId": "canyon-lake",
            "county": "COMAL",
            "city": "CANYON LAKE",
        }],
    "socrataAppToken": "",
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/tx-tceq-lpst-site-status-delta").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 '{
  "monitorId": "my-lpst-watch",
  "watches": [
    {
      "watchId": "canyon-lake",
      "county": "COMAL",
      "city": "CANYON LAKE"
    }
  ],
  "socrataAppToken": "",
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/tx-tceq-lpst-site-status-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/tx-tceq-lpst-site-status-delta"
        }
    }
}
```

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/CMNexgWdngTiEkMRj/builds/ylKs1k6w1rtAeWsZE/openapi.json
