# US WARN Layoff Notice Aggregator & Delta Monitor (OR/TX API) (`malonestar/warn-layoff-aggregator`) Actor

Aggregate US state WARN Act layoff & closure notices into one normalized schema and track new / updated / removed deltas. Oregon & Texas live (keyless Socrata); CA/NY/WA/WI/IL/NJ scaffolded. For RevOps, recruiting, econ research and CRE. No API key.

- **URL**: https://apify.com/malonestar/warn-layoff-aggregator.md
- **Developed by:** [Kyle Maloney](https://apify.com/malonestar) (community)
- **Categories:** Lead generation, Developer tools, Agents
- **Stats:** 2 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.40 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## US WARN Layoff Notice Aggregator & Delta Monitor

Aggregate **US state WARN Act layoff & plant-closure notices** into **one normalized
schema**, and get a **delta feed** of exactly what changed since your last run: **new**
notices, **updated** notices (employee count / effective date / type revised), and
**removed** notices.

Every state publishes WARN filings in a different shape (Socrata, CSV, HTML, Excel) with
different column names and layoff-type wording. This actor unifies them so you can monitor
layoffs across states from a single, stable dataset — **no logins, no API keys.**

### What v1.1 changed (2026-08-01 validation pass)

Every source is now checked **live, before a single row is billed**, and every row carries
the outcome of every requested state. Concretely:

- **A dead state is loud.** If Texas fails and Oregon succeeds, every emitted row carries
  `run_complete = false`, `run_states_failed = "TX"` and the error text. Previously the run
  emitted Oregon rows and nothing else, indistinguishable from "Texas had no layoffs".
  If **every** requested state fails, the run fails and nothing is billed.
- **The delta engine no longer invents changes.** Oregon's WARN number is not one-per-row —
  397 live rows carry only 239 distinct WARN numbers, and WARN 9434 alone covers 19 separate
  Kroger store closures. Keying on it made a re-run against byte-identical upstream data
  report **146 "updated" notices**, complete with invented headcount changes. A re-run now
  reports **zero**.
- **`maxResults` is split evenly across states.** `maxResults: 100` over `["OR","TX"]` used
  to return 100 Oregon rows and **zero** Texas rows with no error. Rows now also carry
  `source_truncated` when a state was cut short.
- **Source freshness is on every row.** `source_last_notice_date` and `source_stale_days`.
  Oregon's dataset measured **80 days** since its newest notice on 2026-08-01 — a fact a
  buyer needs, and one no amount of offline testing could surface.
- **Nine fields that were fetched and thrown away are now emitted** — see the table below.
- **An unrecognised layoff-type value fails the run** instead of being silently bucketed to
  `null`.
- Nothing was removed or renamed. Every 1.0 field still means exactly what it meant.

### Who it's for

- **RevOps & recruiting / staffing** — catch fresh layoffs the day they're filed to source candidates or reach affected employers before competitors.
- **Economic & labor-market researchers** — build a clean cross-state layoff time series without hand-scraping each agency portal.
- **Commercial real estate (CRE) & site selection** — flag closures that free up facilities and shifts in regional employment.
- **Investors & risk / supply-chain teams** — track distress signals by company, county and sector.

### Coverage

| State | Source | Status (verified live 2026-08-01) |
|-------|--------|--------|
| **OR** Oregon | Socrata `data.oregon.gov` (`ijbz-jpx8`) | **Live** — 397 rows, newest notice 2026-05-13 |
| **TX** Texas | Socrata `data.texas.gov` (`8w53-c4f6`) | **Live** — 2,368 rows, newest notice 2026-06-23 |
| CA California | EDD WARN report (HTML/Excel) | Scaffolded — requesting it fails loudly |
| NY New York | NY DOL WARN (HTML) | Scaffolded — requesting it fails loudly |
| WA Washington | ESD WARN (Excel) | Scaffolded — requesting it fails loudly |
| WI Wisconsin | DWD WARN (HTML) | Scaffolded — requesting it fails loudly |
| IL Illinois | DCEO WARN (HTML/PDF) | Scaffolded — requesting it fails loudly |
| NJ New Jersey | NJ DOL WARN (HTML) | Scaffolded — requesting it fails loudly |

Adding a state is a single config block (`{ state, format, domain, datasetId, map,
requiredColumns, expectedMinRows, expectedMaxRows }`) in `source.js`. States marked
"scaffolded" publish only HTML/Excel/PDF today. Requesting **only** scaffolded states now
returns HTTP-400-style failure before the run starts rather than a zero-row success, so a
buyer never mistakes "not implemented" for "no layoffs".

### Example input — snapshot

```json
{
  "states": ["OR", "TX"],
  "mode": "snapshot",
  "sinceDate": "2026-01-01",
  "maxResults": 100000
}
```

### Example input — delta (scheduled monitoring)

```json
{
  "states": ["OR", "TX"],
  "mode": "delta",
  "sinceDate": "2026-01-01",
  "priorKvKey": "warn_last_snapshot",
  "deltaStoreName": "warn-layoff-delta"
}
```

In delta mode with `priorKvKey`, the actor diffs against the snapshot stored under that key
in a **named, cross-run** key-value store **and** saves the current snapshot back to it — so
a daily schedule emits only what changed. (The platform's *default* key-value store is
per-run, so a delta built on it would find an empty baseline every run and re-report the
whole registry as "new". This actor uses a named store for exactly that reason.) The stored
key is scoped to the states and window of the run, so two schedules with different filters
cannot overwrite each other's baseline. Alternatively pass the prior records directly as
`priorItems`.

### Output fields — all 38 declared fields

#### The notice

| Field | Meaning |
|---|---|
| `state` | Two-letter code of the state **registry** that issued the notice (the jurisdiction). |
| `state_name` | Full name of that state. |
| `company` | Employer or job-site name named in the notice. |
| `city` | City where the layoff or closure occurs. |
| `county` | County, where the state publishes it. **Texas only** — Oregon publishes none. |
| `site_state` | Two-letter state of the **actual job site**. Oregon publishes this and it is not always `OR`: **26 of 397 live rows** are sites in GA, CA, IL, WI, VA and elsewhere (e.g. WARN 9178, UPS, Atlanta, GA). Texas publishes only Texas sites. |
| `site_state_matches_jurisdiction` | `true` in-state, `false` out-of-state, `null` when the source publishes no site state. |
| `notice_date` | Date the notice was filed (YYYY-MM-DD). |
| `state_received_date` | Date the **state agency received** the notice. Texas publishes this separately and the two differ on **1,249 of 2,368 rows (52.7%)**. Oregon publishes one date, so the two agree there. |
| `effective_date` | Date the layoff or closure takes effect. |
| `employees_affected` | Workers affected. `null`, never `0`, when the state published no number (4 Oregon rows, 4 Texas rows). |
| `layoff_type` | Normalized bucket: `layoff`, `closure` or `relocation`. |
| `layoff_type_raw` | The state's exact reason string. **`layoff_type` null + `layoff_type_raw` null** = the state does not publish a reason (all of Texas). **`layoff_type` null + `layoff_type_raw` non-null** = the state published a value outside the three buckets (Oregon `"Other"`, 16 live rows). |
| `industry` | Industry / sector. **Neither Oregon nor Texas publishes it, so it is null on every row from these two sources.** Retained because removing a published field would break existing pipelines; it will populate when a state that publishes industry is wired. |
| `region` | Workforce development area. **Texas only.** |
| `notice_id` | State-assigned WARN number. **Oregon only — and it is NOT unique**: one WARN number can cover 19 store closures. Use `notice_key`, not this, as a key. |
| `notice_key` | Stable per-row cross-run key: state + notice id + company + city + effective date. Unique on 2,746 of 2,765 live rows; the residual pairs are byte-identical duplicate rows in the sources themselves. |
| `identity_key` | Key for the notice-site independent of every changeable field. Two rows sharing it are the same site across runs even if the effective date moved. |
| `source_url` | The official state open-data endpoint the row was pulled from. |
| `source_dataset_id` | Socrata dataset identifier. |
| `retrieved_at` | ISO 8601 timestamp of this run. |

#### The per-source outcome contract

`null` means **not checked**. `false` means **checked and negative**. These four fields exist
so a buyer can never mistake an outage for an absence of layoffs.

| Field | Meaning |
|---|---|
| `source_status` | `"ok"` for the state that produced this row. |
| `source_row_count` | Exact upstream row count (`count(*)`) measured on this run — the free truncation guard. `null` if not measured. |
| `source_last_notice_date` | Newest notice date present in the source dataset. If this is far in the past, the **registry** has stopped publishing — not the employers. |
| `source_stale_days` | Whole days between `source_last_notice_date` and this run. Oregon measured **80**, Texas **39**, on 2026-08-01. |
| `source_truncated` | `true` when `maxResults` cut this state short. `null` when undetermined. |
| `run_states_requested` | Every state this run attempted. |
| `run_states_ok` | Every state that answered. |
| `run_states_failed` | Requested states that did **not** answer. `null` on a healthy run. A state listed here has no rows here for reasons of availability. |
| `run_states_failed_reason` | Per-state error text. `null` on a healthy run. |
| `run_complete` | `true` only when every requested state answered. |

#### Delta mode only

`change_type` (`new` / `updated` / `removed`), plus `old_employees_affected`,
`new_employees_affected`, `old_effective_date`, `new_effective_date`, `old_layoff_type`,
`new_layoff_type`. All seven are `null` in snapshot mode by design.

**Inputs that populate the conditionally-null fields** (each verified on a live run):
`county` / `region` — any run including `"TX"`. `notice_id` / `layoff_type` /
`layoff_type_raw` — any run including `"OR"`. `site_state_matches_jurisdiction = false` —
`{"states":["OR"]}` (26 rows). `layoff_type null + raw non-null` — `{"states":["OR"]}`
(16 rows). The seven delta columns — `{"states":["OR"],"mode":"delta","sinceDate":"2026-04-01","priorItems":[...]}`.
`run_states_failed` / `run_states_failed_reason` — populate only during a genuine partial
upstream outage; observed live on run `JkmtVBtuTh8EgXBUI`'s successor when
`data.oregon.gov` transiently refused a connection, and pinned by offline fixtures.
`industry` is the one field with no populating input from the two live sources.

### Live drift assertions

An offline fixture cannot see a silently degraded upstream, because the fixture was shaped
from the degraded data. Before any row is fetched or billed, each live state is checked
against the running service and every measured value is written to the log
(`DRIFT PROBE OR: {...}`):

1. **Every column the mapper reads is named in a `$select`.** Socrata returns HTTP 400 on a
   renamed column, so the drift that would otherwise emit a full dataset of billable
   all-null rows fails the run instead. 8 columns asserted per state.
2. **Exact row count** via `count(*)`, checked against a measured band (Oregon 150–20,000
   against a live 397; Texas 800–60,000 against a live 2,368). Bands are deliberately wide —
   a false-positive drift failure on a live actor costs real money.
3. **A negative control**: `<date field> >= '2999-01-01'` must return **exactly 0**. If the
   `$where` clause ever stops being applied, `sinceDate` is silently ignored, and this is
   what catches it.
4. **A positive canary**: the newest row must map to a non-null company *and* notice date.
5. **Freshness**: the source's own `max(<date field>)`, reported on every row.
6. **Closed vocabulary**: any Oregon `layoff_type` value outside the six live values fails
   the run rather than being bucketed to `null`.

### Use as an MCP tool

This actor is callable by AI agents (Claude, Cursor, etc.) via **mcp.apify.com**. The clean,
field-level output schema makes it a good chainable tool: an agent can pull a state's WARN
notices, filter by company, county or headcount, and hand rows to an outreach, enrichment or
analysis step. The `run_complete` / `run_states_failed` fields let an agent tell a genuine
"no layoffs" from an unavailable registry without reading logs.

### Pricing

Pay per result — one WARN notice (or one delta change row) = one dataset item. Graduated
discounts apply on paid Apify plans. A run that fails because every source was unavailable
emits nothing and bills no result events.

### FAQ

**How do I monitor for new layoff notices?** Run in `delta` mode on a schedule with a
`priorKvKey`; each run emits only new / updated / removed rows. The first run establishes
the baseline and reports everything as `new` — the log says so explicitly.

**Which states are live today?** Oregon and Texas, via their keyless Socrata open-data APIs.
California, New York, Washington, Wisconsin, Illinois and New Jersey are scaffolded config
blocks; requesting only those fails the run with a message naming the live states, rather
than returning an empty dataset.

**What counts as an "updated" notice?** The employee count, effective date, notice date, or
layoff type changed versus the prior snapshot — for the *same job site*. A revision to a
region label or the run timestamp is not a change.

**Why does an Oregon row sometimes show a city outside Oregon?** Because Oregon's registry
contains them. `state` is the issuing registry; `site_state` is where the job site actually
is, and `site_state_matches_jurisdiction` flags the 26 live rows where they differ.

**Oregon's newest notice is months old — is the actor broken?** No: `source_last_notice_date`
and `source_stale_days` report the state of the **source dataset**. Oregon's Socrata dataset
had not been refreshed for 80 days as of 2026-08-01. Every row tells you that.

**Do I need an API key?** No. All sources are public and keyless.

**How is it billed?** Pay per result — one WARN notice (or one delta change row) = one
dataset item.

### Related actors

- **`sos-registry-monitor`** — new business registrations across state Secretary-of-State
  registries; the growth-side counterpart to this actor's distress signal.
- **`fdic-ncua-health-rollup`** — bank and credit-union financial health, for the same
  regional-distress question from the balance-sheet side.
- **`city-business-license-leads`** — municipal licence filings for local market-entry
  signal.

# Actor input Schema

## `states` (type: `array`):

Two-letter state codes to pull WARN notices for. Leave empty for all fully-wired sources (currently OR and TX). CA, NY, WA, WI, IL and NJ have config blocks but no machine-readable endpoint yet -- requesting only those FAILS the run loudly rather than returning zero rows.

## `mode` (type: `string`):

snapshot = emit the current normalized WARN notices. delta = compare against a prior snapshot and emit only change rows (new / updated / removed). Delta needs priorItems or priorKvKey.

## `sinceDate` (type: `string`):

Optional lower bound (YYYY-MM-DD) on the notice date. When set, only notices on or after this date are fetched. Leave blank for all available notices.

## `priorItems` (type: `array`):

Delta mode: the prior run's unified notice items (the array of records this actor produced before). The diff is computed purely against these. Ignored in snapshot mode.

## `priorKvKey` (type: `string`):

Delta mode: a key in a NAMED cross-run key-value store (see Delta store name) holding the prior snapshot. The current snapshot is saved back under the same key, scoped to the states and window of this run, so scheduled runs diff against the previous run rather than re-reporting the whole registry as new.

## `maxResults` (type: `integer`):

Maximum number of WARN notices to fetch. The budget is split evenly across the selected states, so a small value returns some of every state rather than all of the first one. Rows carry source\_truncated when a state was cut short.

## `deltaStoreName` (type: `string`):

Name of the cross-run key-value store used by delta mode. The default (per-run) store cannot carry state between runs, so a scheduled delta must use a named store. Change this only to keep two schedules independent.

## Actor input object example

```json
{
  "states": [
    "OR",
    "TX"
  ],
  "mode": "snapshot",
  "priorItems": [],
  "maxResults": 100000,
  "deltaStoreName": "warn-layoff-delta"
}
```

# Actor output Schema

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

Normalized WARN notices in the default dataset.

# 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 = {
    "states": [
        "OR",
        "TX"
    ],
    "deltaStoreName": "warn-layoff-delta"
};

// Run the Actor and wait for it to finish
const run = await client.actor("malonestar/warn-layoff-aggregator").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 = {
    "states": [
        "OR",
        "TX",
    ],
    "deltaStoreName": "warn-layoff-delta",
}

# Run the Actor and wait for it to finish
run = client.actor("malonestar/warn-layoff-aggregator").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "states": [
    "OR",
    "TX"
  ],
  "deltaStoreName": "warn-layoff-delta"
}' |
apify call malonestar/warn-layoff-aggregator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=malonestar/warn-layoff-aggregator",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/sWpXBwUUaeQNwgtdU/builds/mbvV6mhdmujKWycPd/openapi.json
