# WA Lobbyist Reporting Delta (`titan_coder/wa-lobbyist-reporting-delta`) Actor

Monitors Washington State PDC lobbyist registrations, expense reports, and amendments for journalists, compliance/govt-affairs teams, and watchdog orgs. Filter by entity, name, report type, or amendments only. Free when nothing changes.

- **URL**: https://apify.com/titan\_coder/wa-lobbyist-reporting-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 new or amended lobbyist filing detecteds

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

## WA Lobbyist Reporting Delta

Durable monitor for Washington State's public **Lobbyist Reporting History** — the Public Disclosure
Commission's (PDC) complete log of lobbyist/firm/client registrations, monthly expense reports, annual
reports, and — critically — **amendments to any of the above**. Watch by entity, lobbyist/firm/client name,
or report type, and get notified only about filings genuinely new since your last check. No API key needed,
no login, no captcha.

### Source

`https://data.wa.gov/resource/nuwx-ay5h.json` (Socrata SODA API), dataset **"Lobbyist Reporting History"**
(`nuwx-ay5h`), attribution "Public Disclosure Commission" — 201,431 filings confirmed live 13.09.2026, 435
new filings in the first 12 days of September 2026 alone. Unlike a small CSV export, this dataset is too
large (200K+ rows, many carrying a full JSON blob of the original form) to fetch in full every run — each
watch instead runs two lightweight SoQL queries: an honest `count(*)` under your filter, and a window of the
newest filings by `receipt_date` matching it (see **Honest limits** for what that window does and doesn't
cover).

### Architecture: an append-only filing log, not a status registry

This registry does **not** work like "one license record whose status field changes." It is a log of
**discrete filing events**: an L1 registration, an L2 monthly expense report, an L3/L3c annual report — and
every single **amendment** to any of those — each gets its own permanent, never-reused `id` ("submission
version number", confirmed live via the dataset's own field documentation) and its own `receipt_date`. When
a filer amends a previously filed report, the amendment is **not** an edit of the old row: it is an
entirely new row (`origin` containing `"AMENDED"`, `amends_report` pointing back at the original). This
means the amendment event is *itself* captured simply as "a new filing appeared" — the same mechanism that
catches a first-time registration. (The reverse link, `amended_by_report`, does get added to the *old* row
after the fact, but we don't need to re-poll old rows to detect the amendment — the new row is the signal.)
This is the same append-only shape as `ncua-credit-union-enforcement-watch` and
`delaware-professional-disciplinary-actions`, not the "one entity, mutable status" shape of e.g.
`ny-attorney-bar-status-delta`.

### How it works

1. Each `watch` combines optional filters — `entityId` (matches `entity_id`, `firm_id`, or `client_id`;
   the PDC's own stable identifier for a lobbyist/firm/client across years), `filerNameContains`
   (case-insensitive substring, applied server-side), `reportTypes` (subset of
   `L1_CLIENT|L1_CONTRACT|L1_FIRM|L2|L3|L3C|L5`), and `amendmentsOnly` (only rows whose `origin` contains
   `"AMENDED"`) — or none of them, to watch the newest filings across the entire registry.
2. Every watch runs its own `$where`-filtered `count(*)` and its own newest-first window (`receipt_date`
   DESC, `id` DESC, capped at `FETCH_WINDOW_SIZE` = 1000 rows) — filters are applied **server-side** by
   Socrata, not downloaded-then-filtered.
3. A stable identity is the filing's own `id` (never reused across the dataset's history) plus a content
   hash of its substantive fields. If the underlying data for an existing `id` were ever corrected in place
   (not observed live, but not assumed impossible either), the hash would change and it is honestly treated
   as a revision, not silently ignored or double-billed.
4. Genuinely new (or, defensively, revised) filings are pushed to the dataset and billed once each
   (`new-or-amended-filing-detected`); a check that finds nothing new costs nothing beyond the fixed
   platform run cost.

### Input

```json
{
  "monitorId": "my-lobbyist-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    { "watchId": "gordon-thomas-honeywell", "entityId": 38721 },
    { "watchId": "all-amendments", "amendmentsOnly": true },
    { "watchId": "acme-client", "filerNameContains": "Acme Corp", "reportTypes": ["L1_CLIENT"] }
  ],
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

`userAgentContact` is sent as a descriptive `User-Agent` on every request (polite-scraping practice) —
`data.wa.gov` does not require it, but we still identify ourselves. An optional `socrataAppToken` raises
Socrata's own throttling limits if you have one; not required for normal use.

Add more watches later under the same `monitorId` — each watch keeps its own independent history. A
`watchId` is permanently bound to the filter it first saw; pointing the same `watchId` at different filters
later fails the run instead of silently mixing histories.

### Output row (per filing)

`watchId, stableId, origin, isAmendment, filerName, filerType (Firm|Client|Public agency), entityId,
firmId, clientId, year, receiptDate, filingMethod, reportFrom, reportThrough, amendsReport,
amendedByReport, url, monitorId, runId, discoveredAt, eventId, billed`

### Billing

Pay-per-event: `new-or-amended-filing-detected` — charged only for a filing genuinely new (or, defensively,
revised) since the previous check of that watch. The first check of a new watch establishes a baseline
(no charge). Failed/blocked checks are never charged.

### Not the same product as existing WA lobbying actors

A prior Apify actor covering Washington lobbying (`j0401/wa-lobbying`) pulls from two **different**
datasets — "Lobbyist Compensation by Source" (`9nnw-c693`) and "PDC Enforcement" (`a4ma-dq6s`) — and bills
**$0.003/record as a one-off snapshot** (confirmed live against its own listing and OpenAPI schema
13.09.2026: no mention anywhere of monitor/delta/`sinceLastRun`/scheduled behavior). This actor watches the
**Lobbyist Reporting History** dataset (`nuwx-ay5h`) specifically, bills **only newly-detected filings per
watch per run**, and is built around amendments as a first-class, explicitly filterable signal
(`amendmentsOnly`) — a genuinely different source, filter, and billing model, not a repriced clone.

### Important — read before relying on this for compliance decisions

**The PDC's own dataset does not claim to be a real-time or complete record of lobbying activity in
Washington** — filings are self-reported by lobbyists/firms/clients, paper filings (`filingMethod: "Paper"`,
\~4,500 of 201,431 rows as of 13.09.2026) carry no machine-readable `reportData`, and a separate 180 rows
are missing `year`/`reportFrom`/`reportThrough` entirely. **This actor is an informational monitor of NEW
or AMENDED filings in the
official registry — it is NOT a compliance/risk verdict on any lobbyist, firm, or client, and it is NOT
legal advice.** Always confirm directly with the PDC (or counsel) before acting on any single filing.

### Delivery guarantee: at-most-once (we would rather lose an alert than bill you twice)

Each computed change is delivered to the dataset and charged **at most once**, for as long as the
monitor's claim log exists (see the boundary below). Before any irreversible step (writing the row,
charging the event) the run takes an **atomic claim** on that exact change, using the only atomic
primitive the Apify platform offers: a request queue's unique-key insert. Exactly one run can win that
claim for a given change. The claim log is never consumed, deleted or rotated by this actor; it is a
permanent record of what was already attempted, and `coverage.claimJournalSize` reports its size each run
so you can watch it grow (the platform's counter is eventually consistent, so treat it as a lagging
estimate, not an exact count).

**Where that guarantee ends — the honest boundary.** The claim log lives in a *named* request queue
(`<prefix>-<monitorId>-claims`) in your own account. The at-most-once guarantee holds as long as that
queue keeps existing. If you — or any process holding your account credentials — delete, rename or
re-create it from the Console or API, the log starts empty and previously delivered changes can be
delivered and charged again. That is the unavoidable boundary of *any* durable storage, not a loophole in
the protocol. For the same reason, the actor's storage prefix and internal claim namespace are frozen
after release: changing either would create a fresh, empty log with exactly the same effect.

The response the platform returns for each claim is interpreted **strictly**: only a real boolean `false`
grants the right to write and charge, only a real boolean `true` denies it, and anything else — a missing
field, `null`, `0`, an empty string, a changed SDK response shape — aborts the run's delivery for that
item with `claim_protocol_error` **before** any row or charge. An answer we do not fully understand is
never read as "you may charge".

One thing we deliberately do **not** claim: the monitor's lease makes overlapping runs a fail-closed
exception rather than a fact of life, but between the moment a run verifies it still holds the lease and
the moment the dataset write or charge actually lands there is an unavoidable time gap (the platform
offers no fencing token for datasets or billing). So "a run that lost the lease can never write another
row" would be an overstatement. What actually protects your money is the claim above: the key is already
taken, so even a ghost run cannot charge for the same change twice.

The honest consequence, stated plainly: **if a run dies after taking the claim but before finishing, that
one change is lost**. It is recorded as `dataset_unknown` or `charge_unknown` and it is **not**
re-delivered on the next run — the next run moves on to the watch's next new or amended filing. We deliberately chose possible loss
of one alert over the possibility of charging you twice for the same event. This is *at-most-once*
delivery, not *exactly-once*; any actor that claims exactly-once over a store without compare-and-swap is
overstating what the platform can do.

Practically this only happens if the Apify run is killed mid-delivery (platform abort, timeout, migration).
Every such case is visible: the run's `coverage` and `run_summary` report it, and `run_summary.eventsBilled`
plus Apify's own billing ledger remain the source of truth for what you actually paid for.

### 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` and Apify's own billing ledger are the source of truth for
  confirmed payment**, not the presence of a row in either dataset.
- **Each watch sees only a bounded window of the newest matching filings per run** (`FETCH_WINDOW_SIZE` =
  1000, ordered `receipt_date` DESC then `id` DESC). `coverage.watches[].windowFull` flags when a watch's
  real match count (from its own `count(*)`) exceeded that window in this run — older matches beyond the
  window are honestly not shown this run, not silently dropped forever (the next run's window will still
  be newest-first, so a very high-volume unfiltered watch could in theory miss a burst of >1000 filings
  between two runs — narrow such a watch with `entityId`/`filerNameContains`/`reportTypes` if that matters
  to you). `coverage.watches[].matchedCount` is this watch's own `count(*)` and may differ by a handful of
  rows from what the window actually returned, purely because the two queries are separate HTTP requests
  against a live, continuously-growing dataset (not a transactional snapshot) — this is not treated as a
  structural error.
- `coverage.sourceAccessLimitedCount` is a separate, unrelated counter from `windowFullCount`: it only
  counts watches where the Socrata response itself could not be trusted this run (malformed JSON, a
  non-numeric `id`, a required field missing/blank, or a nullable field present as a whitespace-only string
  instead of a real null — see below), never a watch that simply had more matches than the window.
- Row-level identity depends on every filing in the window supplying a non-empty, purely numeric `id` and
  a non-empty `url` — both true for all 201,431 rows verified live on 13.09.2026. `origin`, `filerName`,
  `filerType`, `receiptDate`, and `filingMethod` are likewise required and never null on any live row
  checked. If any row in a watch's window fails any of these checks, the whole run for that watch is
  reported honestly as `source_access_limited` — no partial baseline/delta/billing happens on a window
  whose completeness can't be trusted, rather than silently skipping just that one row.
- `entityId` is **not** always populated, unlike its own field description suggests — discovered by a live
  smoke test while building this actor, not assumed from documentation: **11,876 rows (all `filerType:
  "Public agency"`) have no `entity_id` at all**, only a `client_id`. `entityId`-filtered watches still
  match such rows via `client_id`; the output field itself is honestly `null` for them, not guessed.
- Nullable fields (`entityId`, `firmId`, `clientId`, `year`, `reportFrom`, `reportThrough`, `amendsReport`,
  `amendedByReport`) are genuinely absent on a real share of live rows (confirmed by count per field,
  13.09.2026) — but a **whitespace-only string** in any of them is not treated as an equivalent to null
  (Socrata itself never represents null that way): such a row fails closed into `source_access_limited`
  for that watch's run rather than being silently coerced to empty.
- History is kept per filing `id` (not a capped seen-ids list): each row's own state (its latest content
  hash and a change-sequence counter) is stored indefinitely per watch, so a filing that were ever revised
  and later reverted to an earlier state would be billed correctly on every genuine transition, never
  silently deduplicated against an older occurrence of the same content.
- We don't invent data: if Socrata's response shape changes (not JSON, not a list, `count(*)` unparseable),
  the run reports it honestly (`structure_changed: ...`) instead of silently returning zero results.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

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

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars).

## `userAgentContact` (type: `string`):

Sent as a descriptive, identifiable User-Agent on every request to data.wa.gov (polite-scraping practice) — data.wa.gov does not require an API key, but we still identify ourselves. Give your email or a contact link. Not a secret.

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

Optional. Raises data.wa.gov's own throttling limits for high-volume use. Not required for normal use — the dataset is public and works without one.

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

1-20 objects: {"watchId": "my-watch", "entityId": 38721 (optional, matches entity\_id OR firm\_id OR client\_id — the PDC's stable identifier for a lobbyist/firm/client across years), "filerNameContains": "..." (optional, case-insensitive substring against filer name, applied server-side), "reportTypes": \["L2"] (optional subset of L1\_CLIENT|L1\_CONTRACT|L1\_FIRM|L2|L3|L3C|L5), "amendmentsOnly": true (optional, only rows whose origin contains AMENDED)}. Leave all filters empty to watch the newest filings across the entire registry. New watches can be added later under the same monitorId.

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

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

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

Optional. Receives a digest of delivered (paid) new/amended filings as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-lobbyist-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "all-amendments",
      "amendmentsOnly": true
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, stableId, origin, isAmendment, filerName, filerType, receiptDate, url.

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

What this run actually covered and what it charged for: per-watch status and reason, filings delivered and billed, and whether the window had to be truncated for any watch. 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-lobbyist-watch",
    "userAgentContact": "you@example.com",
    "watches": [
        {
            "watchId": "all-amendments",
            "amendmentsOnly": true
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/wa-lobbyist-reporting-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-lobbyist-watch",
    "userAgentContact": "you@example.com",
    "watches": [{
            "watchId": "all-amendments",
            "amendmentsOnly": True,
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/wa-lobbyist-reporting-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-lobbyist-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "all-amendments",
      "amendmentsOnly": true
    }
  ]
}' |
apify call titan_coder/wa-lobbyist-reporting-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/wa-lobbyist-reporting-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/Tmugzd2dyKHCvlUlg/builds/dhBL0XV0kudPjIPR3/openapi.json
