# US College Accreditation Action Delta (`titan_coder/us-college-accreditation-action-delta`) Actor

Watches the US Department of Education DAPIP registry and reports only genuinely new college accreditation actions: probation, show cause, heightened monitoring, loss of accreditation, closure. Filter by state, institution or agency. For students, investors, lenders, compliance, journalists.

- **URL**: https://apify.com/titan\_coder/us-college-accreditation-action-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 accreditation action 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

## US College Accreditation Action Delta

Durable monitor for **DAPIP** — the US Department of Education's *Database of Accredited Postsecondary
Institutions and Programs*. It watches the official accreditation-action journal and reports only actions
that are genuinely new since your last check: **probation, show cause, heightened monitoring, warning,
loss of accreditation, voluntary withdrawal, institution closure, additional-location closure**, and the
positive counterparts (initial accreditation, probation removed, renewal) if you want them.

Built for students and families checking a school before enrolling, investors and lenders exposed to
education companies, edtech and OPM partners, compliance and vendor-risk teams, and journalists tracking
college closures. No API key, no login, no captcha.

### Source

`POST https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles` with body
`{"CSVChecked": true, "ExcelChecked": false}` returns `DAPIPData.zip` (~3.7 MB), the same bulk export the
DAPIP website itself offers. Verified live on 13.09.2026: HTTP 200, `Content-Type: application/zip`, no
key, no captcha; `GET` on the same route returns 405 — it is POST-only. Inside are exactly three files;
this actor reads `AccreditationActions.csv` (87,557 actions) and `InstitutionCampus.csv` (45,135 campuses,
used to attach institution name, campus type, address and state to every action).

The archive is **generated on every request**, so each run sees the registry as of that moment. In the
live snapshot there were **5,483 actions dated 2026** (roughly 600 a month), including 234 *Heightened
Monitoring or Focused Review*, 233 *Loss of Accreditation: Voluntary Withdrawal*, and 113 *Probation*.

### How it works

1. Each `watch` is a combination of `actionCategory` (`adverse` by default), optional `state`,
   `institutionNameContains`, `agencyNameContains`, and `lookbackDays` (default 365).
2. Every run downloads the ZIP **once** — cached for the whole run no matter how many watches you define —
   verifies it, and then filters that one shared snapshot per watch.
3. Before a single action is classified as new, the snapshot must pass an integrity gate: ZIP CRC check,
   exactly the expected set of member files, decompressed-size limits, strict UTF-8 decoding, a minimum
   row count, a check against the row count of the last accepted snapshot, no conflicting duplicate campus
   ids, and 100% coverage of actions by the campus file (otherwise state/name filters would silently miss
   actions). If any of that fails, the entire run reports `source_access_limited` and touches no baseline,
   no history, no watch binding and no billing.
4. Genuinely new actions are pushed to the dataset and billed **at most once** each
   (`accreditation-action-detected`). A run that finds nothing new costs nothing beyond the fixed platform
   run cost.

### Input

```json
{
  "monitorId": "my-accreditation-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    { "watchId": "california-adverse", "actionCategory": "adverse", "state": "CA", "lookbackDays": 365 },
    { "watchId": "wasc-agency", "actionCategory": "adverse", "agencyNameContains": "WASC" },
    { "watchId": "example-college", "actionCategory": "all", "institutionNameContains": "Example College" }
  ],
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

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

### Output row (per action)

`watchId, stableRowId, changeType ("new_action"|"action_revised"), dapipId, institutionName, parentName,
locationType, state, address, agencyId, agencyName, programId, programName, sequentialId,
actionDescription, severity, actionDate, endDate, justification, justificationOther, occurrence,
contentHash, previousContentHash, sourceUrl, monitorId, runId, discoveredAt, eventId, billed`

`severity` is one of `critical`, `warning`, `adverse_other`, `positive`, `neutral`, `unclassified`.

### Billing

Pay-per-event: `accreditation-action-detected` — charged only for an action genuinely new since the
previous check of that watch (a retroactive ED edit of an already-published action counts as one new
action — see the identity note under *Honest limits*). The first check of a new watch establishes a
baseline and is not charged. Failed or gate-blocked runs are never charged.

### Important — read before relying on this

**This is an informational monitor of what the Department of Education publishes to its DAPIP registry.
It is not an assessment of the quality of any school, not a prediction that a school will close, and not
legal, financial or enrolment advice.** An accreditation action is a regulatory event with context that
this registry does not carry; an institution on probation is not the same as an institution that has lost
accreditation, and many monitoring statuses are later removed. ED publishes actions with a lag and does
not guarantee completeness. Always confirm any single entry directly with DAPIP, the accrediting agency,
or the institution before acting on it.

### 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`) 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 same gate also decides, atomically, which filter a `watchId` is permanently
bound to — that claim is taken only after the snapshot integrity gate passes, so a run that fails the
gate still leaves no durable trace.

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`.

### Honest limits

- **The DAPIP API is undocumented.** The route used here (`ope.ed.gov/dapip/api/...`) is the one the DAPIP
  website's own front-end calls; ED publishes no contract for it and is under no obligation to keep its
  shape. If the response shape changes, this actor fails closed (`structure_changed` /
  `source_access_limited`, zero charges) rather than inventing data — but it will need a code update
  before it works again. This is a real risk, stated up front rather than buried.
- **The source gives its action rows no stable identifier.** Verified by recounting all 87,557 live rows:
  `DapipId` identifies the *institution*, not the action (only 26,780 distinct values); even all eleven
  columns together leave 83,393 distinct rows, because an identical row can repeat up to 23 times.
  Identity is therefore synthesised deterministically: a full SHA-256 of the action's key fields (with
  `actionDate` normalised to ISO first, so a cosmetic `6/26/...` → `06/26/...` reformat by ED cannot mint a
  new id), plus the payload's content hash, plus an occurrence number counted **only among fully identical
  rows**. Occurrence is deliberately *not* a position in the sorted key group: numbering a whole group that
  way meant a legitimately appended row could take over an existing row's id and push that row onto a new
  one — one append billed as two events.
- **A retroactive edit by ED therefore reads as a new action, not as a "revision" of the old row.** Because
  the payload hash is part of the identity, if ED later fills in an `endDate` or rewrites a justification on
  an already-published action, the old row id disappears and a new one appears; you are billed once, for
  one new action. This is the honest reading: with no source-side row id there is no way to *prove* that
  the edited row is the same row, and the previous scheme paid for that unprovable claim by letting edits
  disturb neighbouring rows' ids. The actor still tells apart such an in-place edit from ED actually
  deleting a row: it compares the size of the row's key group in the snapshot with the size it recorded
  last time (see the append-only guarantee below).
- **`lookbackDays` is a real window, not a nicety.** An action older than the window is invisible to that
  watch. Schedule runs comfortably more often than your `lookbackDays`, or an outage longer than the
  window will let actions slip past.
- Each watch is capped at 10,000 matching actions per run, newest-first. Live volumes for reference:
  `adverse` over 365 days = 2,247 across the entire country, `adverse` over the maximum 1,095 days =
  7,635 — both fit. Only `actionCategory: "all"` over long windows exceeds the cap (26,714 over 1,095
  days). When that happens, `coverage.watches[].windowFull` and `matchedCount` say so explicitly; data is
  never silently dropped.
- **A new action type ED invents will be shown, not hidden.** All 47 action descriptions present in the
  live registry are classified by severity. Anything outside that vocabulary is labelled
  `severity: "unclassified"` and is included under `actionCategory: "adverse"` and `"all"` — the deliberate
  choice being that showing you an unrecognised action is better than hiding what might be the most
  serious one.
- **State comes from parsing the campus address**, which succeeds for 44,896 of 45,135 campuses (99.5%).
  For the rest, `state` is `null` and a watch filtered by state will honestly not see them.
- **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 mirror write fails, 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 that exact row is not re-created. The durable dataset can therefore
  hold 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: it is written before
  `Actor.charge()` runs, so if charging then fails or returns `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.
- **No server-side filtering exists** for this export (verified live: the endpoint accepts only
  `{CSVChecked, ExcelChecked}`). All filters are applied on our side over one shared snapshot per run;
  they reduce the rows delivered to you, never the network cost.
- **Append-only is enforced per record, not by row count.** History is kept per action (not a capped
  seen-ids list): each action's latest content hash, its `actionDate`, its key-group hash and a persistent
  change-sequence counter are stored per watch. A record is dropped from that index **only** once its
  `actionDate` is safely past the watch's `lookbackDays` boundary. If a record that is still inside the
  window vanishes from a snapshot, and that disappearance is not explained by an in-place payload edit
  (its key group did not shrink) or by the 10,000-row window cap, the watch reports `source_access_limited`
  for that run: nothing is pruned, nothing is delivered, nothing is charged. A whole-file row-count gate
  cannot do this job — ED could drop one old row and add a new one, leaving the count flat, and the dropped
  row would later be re-billed as new when it came back.
- **An explained disappearance is remembered, not forgotten.** When a record's disappearance *is* explained
  by an in-place payload edit, its id is not deleted from the index — it is frozen there under a tombstone
  (`firstMissingAt` / `lastSeenAt` / `missingRuns`) until its `actionDate` ages out of the window naturally.
  So if ED later publishes both the original row and the edited one, the original arrives as something we
  already know: **zero dataset rows and zero charges for it**. Because a tombstoned id no longer counts
  towards its key group, a legitimate payload edit also never wedges the watch into a permanent
  `source_access_limited`.
- If any required field is empty, a nullable field contains only whitespace, a column disappears, a date
  stops parsing, or a row's column count is wrong, the run reports it honestly and charges nothing,
  instead of building a delta on data whose completeness can't be trusted.

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). Reuse the same value on every run so the actor can tell new accreditation actions from ones you have already been charged for.

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

Sent as a descriptive, identifiable User-Agent on every request to ope.ed.gov (polite-scraping practice). The source needs no API key, but we still identify ourselves. Not a secret.

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

1-20 objects: {"watchId": "my-watch", "actionCategory": "adverse" | "positive" | "neutral" | "all" (default adverse), "state": "CA" (optional, 2-letter state code), "institutionNameContains": "..." (optional, case-insensitive substring of institution / parent institution / program name), "agencyNameContains": "..." (optional, case-insensitive substring of the accrediting agency name), "lookbackDays": 365 (optional, 7-1095)}. Leave the filters empty to watch every adverse action in the whole registry. All filters are part of the watch identity: to change one, use a new watchId. New watches can be added later under the same monitorId.

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

new\_alerts — post the webhook only when new paid accreditation actions 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 accreditation actions as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-accreditation-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "california-adverse",
      "actionCategory": "adverse",
      "state": "CA",
      "lookbackDays": 365
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, actionDate, severity, actionDescription, institutionName, parentName, state, agencyName, programName, justification, endDate, changeType, dapipId.

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

What this run actually covered and what it charged for: per-watch status and reason, how many actions matched the filter before the per-watch window was applied, actions delivered and billed, new vs revised counts, and the accepted snapshot row count. 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-accreditation-watch",
    "userAgentContact": "you@example.com",
    "watches": [
        {
            "watchId": "california-adverse",
            "actionCategory": "adverse",
            "state": "CA",
            "lookbackDays": 365
        }
    ],
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/us-college-accreditation-action-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-accreditation-watch",
    "userAgentContact": "you@example.com",
    "watches": [{
            "watchId": "california-adverse",
            "actionCategory": "adverse",
            "state": "CA",
            "lookbackDays": 365,
        }],
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/us-college-accreditation-action-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-accreditation-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "california-adverse",
      "actionCategory": "adverse",
      "state": "CA",
      "lookbackDays": 365
    }
  ],
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/us-college-accreditation-action-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/us-college-accreditation-action-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/HoZ978lE6842ci8Fq/builds/9GGzfnPK4tHHD2szb/openapi.json
